Skip to content

Foundations for editable math: replaceNode fix, caret preservation, baseline-correct cursor#129

Open
shubham030 wants to merge 5 commits intosimpleclub:mainfrom
shubham030:fix/editable-math-foundations
Open

Foundations for editable math: replaceNode fix, caret preservation, baseline-correct cursor#129
shubham030 wants to merge 5 commits intosimpleclub:mainfrom
shubham030:fix/editable-math-foundations

Conversation

@shubham030
Copy link
Copy Markdown

Four small, independently reviewable fixes that together make flutter_math_fork usable as the rendering core of an editable math widget. Each commit stands alone; the test suite passes after each one.

Summary

  1. fix(ast)SyntaxTree.replaceNode currently throws a runtime _TypeError on any in-tree mutation because it hands a List<GreenNode?> to concrete updateChildren overrides that narrow the parameter type via covariance. Dispatched on concrete parent type to build a correctly-typed list.

  2. fix(widgets)MathController.ast = hard-reset the selection to offset -1 on every tree change, making the caret unusable for any editing. Calls sanitizeSelection instead (same logic the selection = setter already uses).

  3. feat(widgets)SelectableMath and InternalSelectableMath now accept an optional external MathController. When provided, the widget forwards it to the rendering Provider tree instead of creating its own. Lets downstream code drive selection from outside. Also fixes a plumbing bug where `showCursor` was never passed into the `SelectionStyle` Provider value, so the caret never painted regardless of `SelectableMath(showCursor: true)`. Exports `MathController` from the public library surface.

  4. fix(render) — The caret was anchored at `size.height` in `RenderEditableLine.paint`, which put it visually below the main text on any line containing a superscript or fraction. Anchored at `maxHeightAboveBaseline` instead, and overrode `computeDistanceToActualBaseline` to return a stable fallback (`preferredLineHeight * 0.8`) for empty rows so ancestor `Baseline` widgets don't see a 0→nonzero jump when the first character is typed.

Test plan

  • New test file `test/replace_node_test.dart` with 4 regression tests covering all three parent-type shapes (`EquationRowNode`, `FracNode`, `MultiscriptsNode` with nullable slot) plus root replacement.
  • All existing tests still pass (`flutter test`).
  • End-to-end verified by building a downstream editable-math widget on top of this fork and running its 51-test suite (insertions, backspace, cursor movement, structural wraps, nested sup/sub, LaTeX command mode).

Why this bundle

These four changes are coupled in practice — fixing any one in isolation doesn't unlock editing because the other three still block it (no mutation → no replaceNode needed; mutation but reset selection → cursor dies; preserved cursor but not painted → invisible; painted but at size.height → visually wrong). Splitting further would produce dependent PRs.

Happy to split into separate PRs per commit if preferred.

`SyntaxTree.replaceNode` previously hit a runtime `_TypeError` on any
mutation whose parent was a concrete subclass of `GreenNode`:

    type 'List<GreenNode?>' is not a subtype of
    type 'List<GreenNode>' of 'newChildren'

The naïve `posParent.children.map((c) => identical(c, pos) ? newNode : c?.value).toList()`
builds a `List<GreenNode?>`, but every concrete subclass of `GreenNode`
narrows the parameter of `updateChildren` via the `covariant` keyword:

  - EquationRowNode  → List<GreenNode>        (non-null)
  - FracNode, AccentNode, etc. → List<EquationRowNode> (typed, non-null)
  - MultiscriptsNode, SqrtNode, NaryOperatorNode → List<EquationRowNode?>
    (nullable)

The covariant check fires at runtime whenever the static type differs
from the concrete override's expected list element type — which is
effectively always. Dispatch on the concrete parent type and materialise
a correctly-typed list before calling `updateChildren`.

Regression tests cover all three parent-type shapes plus root replacement.
`MathController.ast = newTree` hard-reset the selection to
`TextSelection.collapsed(offset: -1)` on every tree change. This made
the caret unusable for any editable-math use case — every insertion,
deletion, or structural transform would erase the cursor position.

Call `sanitizeSelection(_ast, _selection)` instead, which clamps the
existing selection to the new tree's valid range (exactly what the
`selection =` setter already does). Selection is preserved as long as
the new tree contains the old cursor position; otherwise it's clamped
to the valid range instead of blown away.
…r plumbing

Two related changes required for downstream packages that want to build
editable math (read: caret that survives tree mutations, driven by
external input logic):

1. **External controller.** `SelectableMath` and `InternalSelectableMath`
   now accept an optional `controller: MathController`. When provided,
   the widget uses it instead of creating its own, and doesn't dispose
   it on unmount. `didUpdateWidget` handles both the owned-controller
   case (mutates `controller.ast`, preserving selection via the setter
   fix in the previous commit) and the external-controller case
   (passes through without re-creating).

   Also exports `MathController` from the public library surface so
   callers can actually construct one.

2. **showCursor in SelectionStyle.** `InternalSelectableMath.build`
   constructed the `SelectionStyle` Provider value without passing
   `widget.showCursor`. The render path (`EditableLine` →
   `RenderEditableLine.paint`) reads `showCursor` from that
   SelectionStyle, so the caret was never painted regardless of what
   the caller passed to `SelectableMath(showCursor: true)`. Now
   threaded through.
… empty rows

Two caret-positioning bugs that only manifested for editable use:

1. **Caret drifted below text on lines with sup/sub/frac.** `RenderEditableLine.paint`
   anchored the caret at `Offset(cursorOffset, size.height)`. For any row
   containing a superscript, subscript, or fraction, `size.height` is
   substantially larger than the alphabetic baseline (it includes the
   ascent of the superscript / the height of the fraction). The caret
   ended up painted well below the main text line.

   Anchor at the row's actual alphabetic baseline
   (`maxHeightAboveBaseline`) instead.

2. **Empty rows reported zero baseline.** `computeDistanceToActualBaseline`
   on an empty row returned 0 (no children → `maxHeightAboveBaseline`
   is 0), which broke ancestor `Baseline` widgets: the row would align
   at Y=0 when empty, then jump when the first character was typed as
   the real baseline appeared.

   Override `computeDistanceToActualBaseline` on `RenderEditableLine`
   to fall back to `~80% of preferredLineHeight` when there are no
   children. Same fallback is used for the caret paint, so paint and
   layout always agree — empty↔non-empty transitions no longer shift
   the caret visually.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 12, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53f415fd-090e-4292-946e-b35e21e46dd1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request exposes MathController as a public API export, refactors SyntaxTree.replaceNode to fix type covariance issues during parent node rebuilding, overrides baseline computation in RenderEditableLine to correct cursor positioning, preserves selection state when the AST changes, and enables optional controller injection into selectable math widgets with proper lifecycle management.

Changes

Cohort / File(s) Summary
Public API Export
lib/flutter_math.dart
Exports MathController to the public API surface.
AST Node Replacement
lib/src/ast/syntax_tree.dart, test/replace_node_test.dart
Refactored SyntaxTree.replaceNode to delegate parent rebuilding to a new static helper _rebuildParent that dispatches on concrete parent node types to avoid type covariance violations. Added comprehensive regression test suite validating replacement behavior across different parent node shapes (EquationRowNode, FracNode, MultiscriptsNode, root node).
Rendering and Baseline Computation
lib/src/render/layout/line_editable.dart
Added computeDistanceToActualBaseline override to RenderEditableLine that anchors cursor positioning to effective baseline height rather than render box bottom, preventing misalignment on rows with tall content like superscripts.
Controller State and Widget Lifecycle
lib/src/widgets/controller.dart, lib/src/widgets/selectable.dart
Updated MathController.ast setter to preserve and constrain existing selection on tree change; added optional controller parameter to SelectableMath and InternalSelectableMath with ownership tracking, enabling controller injection and proper lifecycle teardown in InternalSelectableMathState.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the four key changes in the PR: a replaceNode fix for AST mutations, selection/caret preservation across tree changes, and baseline-correct cursor positioning.
Description check ✅ Passed The description comprehensively explains all four fixes, their rationale, and the test plan, with clear alignment to the actual changes in the PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
test/replace_node_test.dart (1)

62-91: Consider adding a test for replacing with null in a nullable slot.

The MultiscriptsNode test replaces a non-null superscript with another non-null value. Since MultiscriptsNode supports nullable children, consider adding a test that replaces a non-null slot with an empty/different structure to verify the nullable path in _rebuildParent works correctly when hasNulls is true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/replace_node_test.dart` around lines 62 - 91, Add a new test that covers
replacing a non-null nullable slot with null for MultiscriptsNode: use the
existing parsing of r'x^{2}' to find the MultiscriptsNode and its superscript
child (same logic as locating supRed), then call tree.replaceNode(supRed, null)
(or the API variant that accepts null) and assert the resulting
updated.greenRoot MultiscriptsNode has sup == null while base remains unchanged
(base.children contains SymbolNode 'x'); this verifies the nullable path in
_rebuildParent when hasNulls is true.
lib/src/widgets/controller.dart (1)

34-37: Minor: Early return at selection.end <= 0 may skip constraining for offset 0.

The guard selection.end <= 0 returns early without constraining. If selection.end == 0 is a valid position (e.g., caret before the first child), this works correctly. However, if the tree shrinks such that offset 0 becomes invalid, the selection won't be constrained.

In practice this is likely fine since ast.root.range.start is typically 0 or positive, but worth confirming the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/src/widgets/controller.dart` around lines 34 - 37, The early-return in
sanitizeSelection (the selection.end <= 0 check) can skip constraining when the
tree's root range doesn't start at 0; update the guard so the selection is
constrained against ast.root.range in all relevant cases — either remove the
early return and always return selection.constrainedBy(ast.root.range), or
change the condition to compare against ast.root.range.start (e.g., only return
early if selection.end < ast.root.range.start). Modify the sanitizeSelection
function accordingly so it always uses ast.root.range to constrain the selection
when needed.
lib/src/ast/syntax_tree.dart (1)

70-103: Good type-dispatch implementation, but line 93 contains dead code.

The _rebuildParent helper correctly handles the covariant type requirements of different parent node types. However, at line 93, the check if (newNode == null) hasNulls = true; is unreachable because newNode is typed as GreenNode (non-nullable) — it can never be null.

🧹 Remove dead code
     } else if (identical(child, pos)) {
       nullableChildren.add(newNode as EquationRowNode?);
-      if (newNode == null) hasNulls = true;
     } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/src/ast/syntax_tree.dart` around lines 70 - 103, The branch in
_rebuildParent that does `if (newNode == null) hasNulls = true;` is dead because
newNode is non-nullable; remove that unreachable check and its surrounding
conditional (leave the nullableChildren.add(newNode as EquationRowNode?) cast in
place), ensuring hasNulls is only set when encountering actual null children and
the logic that chooses updateChildren(nullableChildren.cast<EquationRowNode>())
vs updateChildren(nullableChildren) remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/src/widgets/selectable.dart`:
- Around line 457-471: In didUpdateWidget inside InternalSelectableMath, ensure
you dispose any previously owned MathController before replacing it when
widget.controller changes; specifically, when widget.controller !=
oldWidget.controller and the new widget.controller is non-null, if
_ownsController is true call controller.dispose() (or the appropriate cleanup)
before assigning controller = widget.controller! and setting _ownsController =
false; likewise, when creating a new owned controller (controller =
MathController(...)) first dispose the old controller if _ownsController was
true to avoid leaking the previous owned instance.

---

Nitpick comments:
In `@lib/src/ast/syntax_tree.dart`:
- Around line 70-103: The branch in _rebuildParent that does `if (newNode ==
null) hasNulls = true;` is dead because newNode is non-nullable; remove that
unreachable check and its surrounding conditional (leave the
nullableChildren.add(newNode as EquationRowNode?) cast in place), ensuring
hasNulls is only set when encountering actual null children and the logic that
chooses updateChildren(nullableChildren.cast<EquationRowNode>()) vs
updateChildren(nullableChildren) remains unchanged.

In `@lib/src/widgets/controller.dart`:
- Around line 34-37: The early-return in sanitizeSelection (the selection.end <=
0 check) can skip constraining when the tree's root range doesn't start at 0;
update the guard so the selection is constrained against ast.root.range in all
relevant cases — either remove the early return and always return
selection.constrainedBy(ast.root.range), or change the condition to compare
against ast.root.range.start (e.g., only return early if selection.end <
ast.root.range.start). Modify the sanitizeSelection function accordingly so it
always uses ast.root.range to constrain the selection when needed.

In `@test/replace_node_test.dart`:
- Around line 62-91: Add a new test that covers replacing a non-null nullable
slot with null for MultiscriptsNode: use the existing parsing of r'x^{2}' to
find the MultiscriptsNode and its superscript child (same logic as locating
supRed), then call tree.replaceNode(supRed, null) (or the API variant that
accepts null) and assert the resulting updated.greenRoot MultiscriptsNode has
sup == null while base remains unchanged (base.children contains SymbolNode
'x'); this verifies the nullable path in _rebuildParent when hasNulls is true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bbe4a5af-53a4-4721-9e52-c36367ed2fdc

📥 Commits

Reviewing files that changed from the base of the PR and between 2f270ae and 5c5078f.

📒 Files selected for processing (6)
  • lib/flutter_math.dart
  • lib/src/ast/syntax_tree.dart
  • lib/src/render/layout/line_editable.dart
  • lib/src/widgets/controller.dart
  • lib/src/widgets/selectable.dart
  • test/replace_node_test.dart

Comment thread lib/src/widgets/selectable.dart
Two small fixes from the automated PR review:

1. `selectable.dart`: `InternalSelectableMathState.didUpdateWidget` was
   overwriting the `controller` field when `widget.controller` changed
   without first disposing the previously-owned instance. This leaked
   the internally-created MathController's listeners + notifiers
   whenever the caller swapped from null → external (or back). Now
   disposes the owned controller before the reassignment.

2. `syntax_tree.dart`: Removed a dead `newNode == null` check inside
   `_rebuildParent` — `newNode` is statically typed `GreenNode`
   (non-nullable), so the branch was unreachable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant