From 52aa2f730bc6fcc9506675956bb6ab3faa1a69d2 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 12:03:55 +0800 Subject: [PATCH] feat: navigate table rows vertically --- CSharpMath.Core.Tests/Editor/KeyPressTests.cs | 52 ++++++++++++++ CSharpMath/Editor/Extensions/MathList.cs | 11 ++- CSharpMath/Editor/MathKeyboard.cs | 67 +++++++++++++++++++ CSharpMath/Editor/MathListIndex.cs | 15 ++++- CSharpMath/PublicAPI.Unshipped.txt | 4 ++ 5 files changed, 145 insertions(+), 4 deletions(-) diff --git a/CSharpMath.Core.Tests/Editor/KeyPressTests.cs b/CSharpMath.Core.Tests/Editor/KeyPressTests.cs index abf345f7..8881003f 100644 --- a/CSharpMath.Core.Tests/Editor/KeyPressTests.cs +++ b/CSharpMath.Core.Tests/Editor/KeyPressTests.cs @@ -619,5 +619,57 @@ public void AssigningInsertionIndexClearsVerticalNavigationState() { Assert.Equal("1^■", keyboard.LaTeX); } + [Fact] + public void TableIndexRetainsRowAndColumnAndMovesVertically() { + var table = new CSharpMath.Atom.Atoms.Table(); + table.SetCell(new CSharpMath.Atom.MathList(new CSharpMath.Atom.Atoms.Number("12")), 0, 0); + table.SetCell(new CSharpMath.Atom.MathList(new CSharpMath.Atom.Atoms.Number("3")), 1, 0); + var keyboard = new MathKeyboard(context, new TestFont(10)); + keyboard.MathList.Add(table); + var atom = new MathListIndex(0).TableCell(0, 0, new MathListIndex(0)); + var first = new MathListIndex(0).TableCell(0, 0, new MathListIndex(1)); + Assert.IsType(keyboard.MathList.AtomAt(atom)); + keyboard.InsertionIndex = first; + keyboard.KeyPress(K.Down); + Assert.Equal(new MathListIndex(0).TableCell(1, 0, new MathListIndex(1)), keyboard.InsertionIndex); + keyboard.KeyPress(K.Up); + Assert.Equal(first, keyboard.InsertionIndex); + } + + [Fact] + public void TableNavigationSkipsMissingRowsAndStopsAtBoundaries() { + var table = new CSharpMath.Atom.Atoms.Table(); + table.SetCell(new CSharpMath.Atom.MathList(new CSharpMath.Atom.Atoms.Number("1")), 0, 0); + table.SetCell(new CSharpMath.Atom.MathList(new CSharpMath.Atom.Atoms.Number("2")), 2, 0); + var keyboard = new MathKeyboard(context, new TestFont(10)); + keyboard.MathList.Add(table); + var first = new MathListIndex(0).TableCell(0, 0, new MathListIndex(0)); + keyboard.InsertionIndex = first; + keyboard.KeyPress(K.Up); + Assert.Equal(first, keyboard.InsertionIndex); + keyboard.KeyPress(K.Down); + var last = new MathListIndex(0).TableCell(2, 0, new MathListIndex(0)); + Assert.Equal(last, keyboard.InsertionIndex); + keyboard.KeyPress(K.Up); + Assert.Equal(first, keyboard.InsertionIndex); + } + + [Fact] + public void TableNavigationComposesWithFractionPathAndSkipsEmptyRows() { + var table = new CSharpMath.Atom.Atoms.Table(); + table.SetCell(new CSharpMath.Atom.MathList(new CSharpMath.Atom.Atoms.Number("1")), 0, 0); + table.SetCell(new CSharpMath.Atom.MathList(new CSharpMath.Atom.Atoms.Number("2")), 2, 0); + var fraction = new CSharpMath.Atom.Atoms.Fraction( + new CSharpMath.Atom.MathList(table), new CSharpMath.Atom.MathList()); + var keyboard = new MathKeyboard(context, new TestFont(10)); + keyboard.MathList.Add(fraction); + var tableIndex = new MathListIndex(0).TableCell(0, 0, new MathListIndex(0)); + var nestedIndex = tableIndex.Wrap(0, MathListSubIndexType.Numerator); + Assert.IsType(keyboard.MathList.AtomAt(nestedIndex)); + keyboard.InsertionIndex = nestedIndex; + keyboard.KeyPress(K.Down); + Assert.Equal(new MathListIndex(0).TableCell(2, 0, new MathListIndex(0)).Wrap(0, MathListSubIndexType.Numerator), keyboard.InsertionIndex); + } + } } diff --git a/CSharpMath/Editor/Extensions/MathList.cs b/CSharpMath/Editor/Extensions/MathList.cs index 3b60f896..97d2e4cb 100644 --- a/CSharpMath/Editor/Extensions/MathList.cs +++ b/CSharpMath/Editor/Extensions/MathList.cs @@ -205,7 +205,7 @@ when self.Atoms[start.AtomIndex] is Atoms.Inner inner ? true } public static MathAtom? AtomAt(this MathList self, MathListIndex? index) { - if (index is null || index.AtomIndex >= self.Atoms.Count) return null; + if (index is null || index.AtomIndex < 0 || index.AtomIndex >= self.Atoms.Count) return null; var atom = self.Atoms[index.AtomIndex]; return index.SubIndexInfo switch { null => atom, @@ -217,8 +217,15 @@ when self.Atoms[start.AtomIndex] is Atoms.Inner inner ? true (MathListSubIndexType.Numerator, var subIndex) => atom is Atoms.Fraction frac ? frac.Numerator.AtomAt(subIndex) : null, (MathListSubIndexType.Denominator, var subIndex) => atom is Atoms.Fraction frac ? frac.Denominator.AtomAt(subIndex) : null, (MathListSubIndexType.Inner, var subIndex) => atom is Atoms.Inner inner ? inner.InnerList.AtomAt(subIndex) : null, + (MathListSubIndexType.TableRow, var rowIndex) => atom is Atoms.Table table + && rowIndex.AtomIndex >= 0 && rowIndex.AtomIndex < table.Cells.Count + && rowIndex.SubIndexInfo is (MathListSubIndexType.TableColumn, var columnIndex) + && columnIndex.AtomIndex >= 0 && columnIndex.AtomIndex < table.Cells[rowIndex.AtomIndex].Count + && columnIndex.SubIndexInfo is (MathListSubIndexType.TableCell, var cellIndex) + ? table.Cells[rowIndex.AtomIndex][columnIndex.AtomIndex].AtomAt(cellIndex) + : null, (var type, _) => throw new ArgumentOutOfRangeException(nameof(index), type, "Index type out of valid range."), }; } } -} \ No newline at end of file +} diff --git a/CSharpMath/Editor/MathKeyboard.cs b/CSharpMath/Editor/MathKeyboard.cs index b1c53c87..9cb12f89 100644 --- a/CSharpMath/Editor/MathKeyboard.cs +++ b/CSharpMath/Editor/MathKeyboard.cs @@ -385,7 +385,73 @@ static MathListIndex Enter(MathListIndex owner, MathAtom atom, break; } } + // Tables use an explicit row/column path; this keeps vertical movement + // independent of the display tree (which may contain empty cells). + static MathList? ChildList(MathAtom atom, MathListSubIndexType type) => type switch { + MathListSubIndexType.Superscript => atom.Superscript, + MathListSubIndexType.Subscript => atom.Subscript, + MathListSubIndexType.Numerator when atom is Atoms.Fraction f => f.Numerator, + MathListSubIndexType.Denominator when atom is Atoms.Fraction f => f.Denominator, + MathListSubIndexType.Radicand when atom is Atoms.Radical r => r.Radicand, + MathListSubIndexType.Degree when atom is Atoms.Radical r => r.Degree, + MathListSubIndexType.Inner when atom is Atoms.Inner i => i.InnerList, + _ => null, + }; + + MathListIndex FindTable(MathList list, MathListIndex index, bool down, out bool handled) { + handled = false; + if (index.AtomIndex < 0 || index.AtomIndex >= list.Count || index.SubIndexInfo is not { } info) + return index; + var atom = list[index.AtomIndex]; + if (info.SubIndexType == MathListSubIndexType.TableRow + && atom is Atoms.Table table + && info.SubIndex.SubIndexInfo is (MathListSubIndexType.TableColumn, var column) + && column.SubIndexInfo is (MathListSubIndexType.TableCell, var cell)) { + if (info.SubIndex.AtomIndex < 0 || info.SubIndex.AtomIndex >= table.Cells.Count + || column.AtomIndex < 0 || column.AtomIndex >= table.Cells[info.SubIndex.AtomIndex].Count) { + handled = true; + return index; + } + // Prefer a nested table in the current cell, retaining this complete prefix. + var cellResult = FindTable(table.Cells[info.SubIndex.AtomIndex][column.AtomIndex], cell, down, out handled); + if (handled) + return new(index.AtomIndex, (info.SubIndexType, + new(info.SubIndex.AtomIndex, (MathListSubIndexType.TableColumn, + new(column.AtomIndex, (MathListSubIndexType.TableCell, cellResult)))))); + var row = info.SubIndex.AtomIndex; + var sourceColumn = column.AtomIndex; + var targetRow = row + (down ? 1 : -1); + while (targetRow >= 0 && targetRow < table.NRows && table.Cells[targetRow].Count == 0) + targetRow += down ? 1 : -1; + if (targetRow < 0 || targetRow >= table.NRows) { handled = true; return index; } + var targetColumn = Math.Min(sourceColumn, table.Cells[targetRow].Count - 1); + var targetCell = table.Cells[targetRow][targetColumn]; + var targetCaret = Math.Max(0, Math.Min(cell.AtomIndex, targetCell.Count)); + var candidate = new MathListIndex(index.AtomIndex).TableCell(targetRow, targetColumn, new MathListIndex(targetCaret)); + // Older display backends do not yet expose table row paths to PointForIndex; + // retain the deterministic caret fallback when that seam cannot resolve one. + try { + var sourcePoint = ClosestPointToIndex(index); + if (sourcePoint is PointF point && ClosestPointToIndex(candidate) is PointF) + candidate = VerticalIndexAtPoint(candidate, point) ?? candidate; + } catch (ArgumentOutOfRangeException) { + // The model path remains valid even when the display path is not indexed. + } + handled = true; + return candidate; + } + var child = ChildList(atom, info.SubIndexType); + if (child is null) return index; + var nested = FindTable(child, info.SubIndex, down, out handled); + return handled ? new(index.AtomIndex, (info.SubIndexType, nested)) : index; + } + bool MoveCursorInTable(bool down) { + var result = FindTable(MathList, _insertionIndex, down, out var handled); + if (handled) _insertionIndex = result; + return handled; + } void MoveCursorUp() { + if (MoveCursorInTable(false)) return; if (Display is null) RecreateDisplayFromMathList(); if (MathList.AtomAt(_insertionIndex) is Atoms.Placeholder { Superscript: { Count: var superCount } } && superCount > 0) { _insertionIndex = _insertionIndex.LevelUpWithSubIndex(MathListSubIndexType.Superscript, 0); @@ -455,6 +521,7 @@ atom is Atoms.Placeholder } } void MoveCursorDown() { + if (MoveCursorInTable(true)) return; if (Display is null) RecreateDisplayFromMathList(); if (MathList.AtomAt(_insertionIndex) is Atoms.Placeholder { Subscript: { Count: var subCount } } && subCount > 0) { _insertionIndex = _insertionIndex.LevelUpWithSubIndex(MathListSubIndexType.Subscript, 0); diff --git a/CSharpMath/Editor/MathListIndex.cs b/CSharpMath/Editor/MathListIndex.cs index df246cae..17fb6d0d 100644 --- a/CSharpMath/Editor/MathListIndex.cs +++ b/CSharpMath/Editor/MathListIndex.cs @@ -16,7 +16,13 @@ public enum MathListSubIndexType : byte { ///The subindex indexes into the degree (only valid for radicals) Degree, ///The subindex indexes into the inner list (only valid for inners) - Inner + Inner, + /// The subindex selects a row in a table. + TableRow, + /// The subindex selects a cell in a table row. + TableColumn, + /// The subindex is the caret index within a table cell. + TableCell } /** @@ -35,6 +41,11 @@ public enum MathListSubIndexType : byte { * The level of an index is the number of nodes in the LinkedList to get to the final path. * */ public record class MathListIndex(int AtomIndex, (MathListSubIndexType SubIndexType, MathListIndex SubIndex)? SubIndexInfo = null) { + /// Creates an index into a table cell while retaining its row and column. + public MathListIndex TableCell(int row, int column, MathListIndex cellIndex) => + new(AtomIndex, (MathListSubIndexType.TableRow, + new(row, (MathListSubIndexType.TableColumn, + new(column, (MathListSubIndexType.TableCell, cellIndex)))))); /// /// Creates a new MathListIndex that represents a subindex within this list, wrapped at the specified outer atom /// index and subindex type. @@ -111,4 +122,4 @@ public override string ToString() => var (type, subIndex) => $@"[{AtomIndex}, {type}:{subIndex.ToString().Trim('[', ']')}]" }; } -} \ No newline at end of file +} diff --git a/CSharpMath/PublicAPI.Unshipped.txt b/CSharpMath/PublicAPI.Unshipped.txt index 3611bfa8..df85dfe2 100644 --- a/CSharpMath/PublicAPI.Unshipped.txt +++ b/CSharpMath/PublicAPI.Unshipped.txt @@ -4,3 +4,7 @@ CSharpMath.Editor.MathKeyboardHorizontalNavigationMode CSharpMath.Editor.MathKeyboardHorizontalNavigationMode.Exhaustive = 0 -> CSharpMath.Editor.MathKeyboardHorizontalNavigationMode CSharpMath.Editor.MathKeyboardHorizontalNavigationMode.VisualLower = 2 -> CSharpMath.Editor.MathKeyboardHorizontalNavigationMode CSharpMath.Editor.MathKeyboardHorizontalNavigationMode.VisualUpper = 1 -> CSharpMath.Editor.MathKeyboardHorizontalNavigationMode +CSharpMath.Editor.MathListSubIndexType.TableRow = 8 -> CSharpMath.Editor.MathListSubIndexType +CSharpMath.Editor.MathListSubIndexType.TableColumn = 9 -> CSharpMath.Editor.MathListSubIndexType +CSharpMath.Editor.MathListSubIndexType.TableCell = 10 -> CSharpMath.Editor.MathListSubIndexType +CSharpMath.Editor.MathListIndex.TableCell(int row, int column, CSharpMath.Editor.MathListIndex! cellIndex) -> CSharpMath.Editor.MathListIndex!