Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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;
51 changes: 47 additions & 4 deletions lib/src/ast/syntax_tree.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,53 @@ 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)) {
nullableChildren.add(newNode as EquationRowNode?);
if (newNode == null) hasNulls = true;
} 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
41 changes: 37 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,38 @@ 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) {
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
shbmx marked this conversation as resolved.
if (_oldFocusNode != focusNode) {
_oldFocusNode.removeListener(updateKeepAlive);
Expand Down Expand Up @@ -465,7 +495,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 +544,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');
});
});
}