Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/flutter_math.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export 'src/parser/tex/parse_error.dart';
export 'src/parser/tex/settings.dart';
export 'src/widgets/exception.dart';
export 'src/widgets/math.dart';
export 'src/widgets/controller.dart' show MathController;
export 'src/widgets/selectable.dart' show SelectableMath;
52 changes: 48 additions & 4 deletions lib/src/ast/syntax_tree.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,54 @@ class SyntaxTree {
'The replaced node is not the root of this tree but has no parent');
}
return replaceNode(
posParent,
posParent.value.updateChildren(posParent.children
.map((child) => identical(child, pos) ? newNode : child?.value)
.toList(growable: false)));
posParent, _rebuildParent(posParent, pos, newNode));
}

/// Build a new green node equivalent to [posParent].value but with [pos]'s
/// green node replaced by [newNode]. Dispatches on the concrete parent type
/// because every subclass of [GreenNode] narrows the type of the list
/// argument passed to [updateChildren]:
/// - [EquationRowNode] → List<GreenNode> (non-null)
/// - [FracNode] & friends → List<EquationRowNode> (typed, non-null)
/// - [MultiscriptsNode] & friends → List<EquationRowNode?> (nullable)
///
/// A naive `posParent.value.updateChildren(children.map(...).toList())`
/// builds a `List<GreenNode?>` which fails every covariant check at runtime.
/// We materialise the correctly-typed list here instead.
static GreenNode _rebuildParent(
SyntaxNode posParent, SyntaxNode pos, GreenNode newNode) {
final parentValue = posParent.value;
final rawChildren = posParent.children;

if (parentValue is EquationRowNode) {
final newChildren = <GreenNode>[
for (final child in rawChildren)
identical(child, pos) ? newNode : child!.value,
];
return parentValue.updateChildren(newChildren);
}

// Every other non-leaf parent type expects EquationRowNode children
// (either nullable or non-nullable). Build the correctly-typed list.
final nullableChildren = <EquationRowNode?>[];
var hasNulls = false;
for (final child in rawChildren) {
if (child == null) {
nullableChildren.add(null);
hasNulls = true;
} else if (identical(child, pos)) {
// newNode has static type GreenNode (non-null), so no need to set
// hasNulls here; the cast validates the element type.
nullableChildren.add(newNode as EquationRowNode?);
} else {
nullableChildren.add(child.value as EquationRowNode);
}
}
if (!hasNulls) {
return parentValue
.updateChildren(nullableChildren.cast<EquationRowNode>());
}
return parentValue.updateChildren(nullableChildren);
}

List<SyntaxNode> findNodesAtPosition(int position) {
Expand Down
25 changes: 24 additions & 1 deletion lib/src/render/layout/line_editable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,23 @@ class RenderEditableLine extends RenderLine {

double preferredLineHeight;

/// Effective baseline used for cursor positioning and for reporting
/// this row's distance-to-baseline to ancestors (e.g. [Baseline]
/// widgets). Empty rows have no natural baseline (no children →
/// `maxHeightAboveBaseline` is 0), so we fall back to ~80% of
/// [preferredLineHeight] — roughly where a typical font's alphabetic
/// baseline sits. This keeps the caret stable across empty↔non-empty
/// transitions when the row is inside a [Baseline] widget.
double get _effectiveBaselineY => maxHeightAboveBaseline > 0
? maxHeightAboveBaseline
: preferredLineHeight * 0.8;

@override
double computeDistanceToActualBaseline(TextBaseline baseline) {
assert(!debugNeedsLayout);
return _effectiveBaselineY;
}

TextSelection get selection => _selection;
TextSelection _selection;
set selection(TextSelection value) {
Expand Down Expand Up @@ -447,7 +464,13 @@ class RenderEditableLine extends RenderLine {

if (showCursor && _selection.isCollapsed && isSelectionInRange) {
final cursorOffset = caretOffsets[selection.baseOffset];
_paintCaret(context.canvas, Offset(cursorOffset, size.height) + offset);
// Anchor the caret at the alphabetic baseline (reported via
// [computeDistanceToActualBaseline]), not the bottom of the render
// box. For rows containing superscripts / fractions, size.height is
// much greater than the baseline, and using it pushes the caret
// visually below the main text.
_paintCaret(
context.canvas, Offset(cursorOffset, _effectiveBaselineY) + offset);
}

if (!_paintCursorAboveText) {
Expand Down
5 changes: 4 additions & 1 deletion lib/src/widgets/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ class MathController extends ChangeNotifier {
set ast(SyntaxTree value) {
if (_ast != value) {
_ast = value;
_selection = const TextSelection.collapsed(offset: -1);
// Preserve the selection across tree mutations, clamped to the new
// tree's valid range. Required for editable-math widgets that need
// the caret to survive insertions/deletions.
_selection = sanitizeSelection(_ast, _selection);
notifyListeners();
}
}
Expand Down
47 changes: 43 additions & 4 deletions lib/src/widgets/selectable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class SelectableMath extends StatelessWidget {
const SelectableMath({
Key? key,
this.ast,
this.controller,
this.autofocus = false,
this.cursorColor,
this.cursorRadius,
Expand Down Expand Up @@ -72,6 +73,12 @@ class SelectableMath extends StatelessWidget {
/// It can be null only when [parseException] is not null.
final SyntaxTree? ast;

/// Optional externally-owned [MathController]. When non-null, the widget
/// uses this controller instead of creating its own. Lets callers drive
/// the selection (cursor position) from outside — required for editable
/// math widgets that mutate the tree at the cursor.
final MathController? controller;

/// {@macro flutter.widgets.editableText.autofocus}
final bool autofocus;

Expand Down Expand Up @@ -308,6 +315,7 @@ class SelectableMath extends StatelessWidget {
return RepaintBoundary(
child: InternalSelectableMath(
ast: ast!,
controller: controller,
autofocus: autofocus,
cursorColor: cursorColor,
cursorOffset: cursorOffset,
Expand Down Expand Up @@ -339,6 +347,7 @@ class InternalSelectableMath extends StatefulWidget {
const InternalSelectableMath({
Key? key,
required this.ast,
this.controller,
this.autofocus = false,
required this.cursorColor,
this.cursorOffset,
Expand All @@ -361,6 +370,9 @@ class InternalSelectableMath extends StatefulWidget {

final SyntaxTree ast;

/// Optional externally-owned [MathController]. See [SelectableMath.controller].
final MathController? controller;

final bool autofocus;

final Color cursorColor;
Expand Down Expand Up @@ -424,20 +436,44 @@ class InternalSelectableMathState extends State<InternalSelectableMath>
DragStartBehavior get dragStartBehavior => widget.dragStartBehavior;

late MathController controller;
bool _ownsController = true;

late FocusNode _oldFocusNode;

@override
void initState() {
controller = MathController(ast: widget.ast);
if (widget.controller != null) {
controller = widget.controller!;
_ownsController = false;
} else {
controller = MathController(ast: widget.ast);
_ownsController = true;
}
_oldFocusNode = focusNode..addListener(updateKeepAlive);
super.initState();
}

@override
void didUpdateWidget(InternalSelectableMath oldWidget) {
if (widget.ast != controller.ast) {
controller = MathController(ast: widget.ast);
if (widget.controller != oldWidget.controller) {
// Dispose the previously-owned controller before swapping, otherwise
// the instance created in initState (or the last didUpdateWidget)
// leaks its listeners and internal state.
if (_ownsController) {
controller.dispose();
}
if (widget.controller != null) {
controller = widget.controller!;
_ownsController = false;
} else {
controller = MathController(ast: widget.ast);
_ownsController = true;
}
} else if (_ownsController && widget.ast != controller.ast) {
// We own the controller — update the ast on the existing instance so
// the selection is preserved (via MathController.ast setter, which
// now sanitizes the selection against the new tree instead of resetting).
controller.ast = widget.ast;
}
Comment thread
shubham030 marked this conversation as resolved.
if (_oldFocusNode != focusNode) {
_oldFocusNode.removeListener(updateKeepAlive);
Expand Down Expand Up @@ -465,7 +501,9 @@ class InternalSelectableMathState extends State<InternalSelectableMath>
void dispose() {
_oldFocusNode.removeListener(updateKeepAlive);
super.dispose();
controller.dispose();
if (_ownsController) {
controller.dispose();
}
}

void onSelectionChanged(
Expand Down Expand Up @@ -512,6 +550,7 @@ class InternalSelectableMathState extends State<InternalSelectableMath>
cursorHeight: widget.cursorHeight,
selectionColor: widget.selectionColor,
paintCursorAboveText: widget.paintCursorAboveText,
showCursor: widget.showCursor,
),
),
Provider.value(
Expand Down
103 changes: 103 additions & 0 deletions test/replace_node_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Regression tests for SyntaxTree.replaceNode.
//
// Before the fix, replaceNode built a `List<GreenNode?>` and passed it to
// the concrete overrides of `updateChildren`, which narrow the parameter
// type in every subclass (EquationRowNode wants List<GreenNode>, FracNode
// wants List<EquationRowNode>, etc.). This produced a runtime _TypeError
// on virtually any in-tree mutation. These tests cover the three parent
// shapes exhaustively: an EquationRowNode parent, a FracNode parent, and a
// MultiscriptsNode parent (nullable-slot case).

import 'package:flutter_math_fork/ast.dart';
import 'package:flutter_math_fork/tex.dart';
import 'package:flutter_test/flutter_test.dart';

SyntaxTree _parse(String tex) =>
SyntaxTree(greenRoot: TexParser(tex, const TexParserSettings()).parse());

void main() {
group('SyntaxTree.replaceNode', () {
test('replaces a direct child of EquationRowNode', () {
final tree = _parse(r'x + y');
final root = tree.root;
// Find the first non-null red child — on `x + y` it will be the
// SymbolNode for `x`.
final firstChild = root.children.firstWhere((c) => c != null)!;
expect(firstChild.value, isA<SymbolNode>());

final replacement = SymbolNode(symbol: 'z');
final updated = tree.replaceNode(firstChild, replacement);

// The root row should still have the same number of children,
// with the first one replaced.
expect(updated.greenRoot, isA<EquationRowNode>());
final newFirst = updated.greenRoot.children.first;
expect(newFirst, isA<SymbolNode>());
expect((newFirst as SymbolNode).symbol, 'z');
expect(updated.greenRoot.children.length,
tree.greenRoot.children.length);
});

test('replaces the numerator of a FracNode', () {
final tree = _parse(r'\frac{1}{2}');
// Walk the red tree: root -> FracNode -> numerator (EquationRowNode)
final fracRed = tree.root.children.firstWhere((c) => c != null)!;
expect(fracRed.value, isA<FracNode>());
final numeratorRed = fracRed.children.firstWhere((c) => c != null)!;
expect(numeratorRed.value, isA<EquationRowNode>());

final replacement = EquationRowNode(
children: [SymbolNode(symbol: '9')],
);
final updated = tree.replaceNode(numeratorRed, replacement);

final newFrac = updated.greenRoot.children.first as FracNode;
final newNumLeaf = newFrac.numerator.children.first as SymbolNode;
expect(newNumLeaf.symbol, '9');
// Denominator must be untouched.
final denomLeaf = newFrac.denominator.children.first as SymbolNode;
expect(denomLeaf.symbol, '2');
});

test('replaces the superscript slot of a MultiscriptsNode (nullable)', () {
final tree = _parse(r'x^{2}');
final multiRed = tree.root.children.firstWhere((c) => c != null)!;
expect(multiRed.value, isA<MultiscriptsNode>());
// Children of MultiscriptsNode are [base, sub, sup, presub, presup] —
// many of which are null. The non-null ones here should be base & sup.
final nonNullChildren =
multiRed.children.whereType<SyntaxNode>().toList();
expect(nonNullChildren.length, greaterThanOrEqualTo(2));

// Locate the sup slot — it's the one whose green value contains the `2`.
final supRed = nonNullChildren.firstWhere((c) {
final row = c.value as EquationRowNode;
return row.children.any(
(x) => x is SymbolNode && x.symbol == '2');
});

final replacement = EquationRowNode(
children: [SymbolNode(symbol: '7')],
);
final updated = tree.replaceNode(supRed, replacement);

final newMulti = updated.greenRoot.children.first as MultiscriptsNode;
expect(newMulti.sup, isNotNull);
final newSupLeaf = newMulti.sup!.children.first as SymbolNode;
expect(newSupLeaf.symbol, '7');
// Base is untouched.
final newBaseLeaf = newMulti.base.children.first as SymbolNode;
expect(newBaseLeaf.symbol, 'x');
});

test('replacing the root wraps in an EquationRowNode', () {
final tree = _parse(r'\frac{1}{2}');
final replacement = SymbolNode(symbol: 'q');
final updated = tree.replaceNode(tree.root, replacement);
// A LeafNode replacement at the root must be wrapped.
expect(updated.greenRoot, isA<EquationRowNode>());
expect(updated.greenRoot.children.length, 1);
expect((updated.greenRoot.children.first as SymbolNode).symbol, 'q');
});
});
}