diff --git a/CSharpMath.Core.Tests/Editor/KeyPressTests.cs b/CSharpMath.Core.Tests/Editor/KeyPressTests.cs index 0f0aef58..7df0819f 100644 --- a/CSharpMath.Core.Tests/Editor/KeyPressTests.cs +++ b/CSharpMath.Core.Tests/Editor/KeyPressTests.cs @@ -402,5 +402,81 @@ public void Return(params K[] inputs) => T(@"\frac{\lim _{x\rightarrow 2}}{■}", K.LimitWithBase, K.SmallX, K.RightArrow, K.D2, K.Right, K.Slash), ] public void Slash(string latex, params K[] inputs) => Test(latex, inputs); + + [Theory] + [T(@"\log ", K.SmallL, K.SmallO, K.SmallG)] + [T(@"\ln ", K.SmallL, K.SmallN)] + [T(@"\exp ", K.SmallE, K.SmallX, K.SmallP)] + [T(@"\sin ", K.SmallS, K.SmallI, K.SmallN)] + [T(@"\cos ", K.SmallC, K.SmallO, K.SmallS)] + [T(@"\tan ", K.SmallT, K.SmallA, K.SmallN)] + [T(@"\sinh ", K.SmallS, K.SmallI, K.SmallN, K.SmallH)] + [T(@"\cosh ", K.SmallC, K.SmallO, K.SmallS, K.SmallH)] + [T(@"\tanh ", K.SmallT, K.SmallA, K.SmallN, K.SmallH)] + [T(@"\sec ", K.SmallS, K.SmallE, K.SmallC)] + [T(@"\csc ", K.SmallC, K.SmallS, K.SmallC)] + [T(@"\csc ", K.SmallC, K.SmallO, K.SmallS, K.SmallE, K.SmallC)] + [T(@"\cot ", K.SmallC, K.SmallO, K.SmallT)] + [T(@"\arcsin ", K.SmallA, K.SmallR, K.SmallC, K.SmallS, K.SmallI, K.SmallN)] + public void TypedFunctions(string latex, params K[] inputs) => Test(latex, inputs); + + [Fact] + public void TypedFunctionDoesNotRewriteLongerIdentifier() { + Test(@"sinx", new[] { K.SmallS, K.SmallI, K.SmallN, K.SmallX }); + } + + [Fact] + public void BackspaceExpandsFunctionAndDeletesLastLetter() { + Test(@"si", new[] { K.SmallS, K.SmallI, K.SmallN, K.Backspace }); + } + + [Fact] + public void MovingIntoTypedFunctionMakesItsLettersEditable() { + Test(@"sicn", new[] { K.SmallS, K.SmallI, K.SmallN, K.Left, K.SmallC }); + } + + [Fact] + public void TypedFunctionInFraction() { + Test(@"\frac{\sin }{■}", new[] { K.SmallS, K.SmallI, K.SmallN, K.Slash }); + } + + [Fact] + public void TypedFunctionInScript() { + Test(@"1^{\cos }", new[] { K.D1, K.Power, K.SmallC, K.SmallO, K.SmallS }); + } + + [Fact] + public void TypedFunctionInRadical() { + Test(@"\sqrt{\tan }", new[] { K.SquareRoot, K.SmallT, K.SmallA, K.SmallN }); + } + + [Fact] + public void TypedFunctionInInner() { + Test(@"\left( \cot \right) ", new[] { K.BothRoundBrackets, K.SmallC, K.SmallO, K.SmallT }); + } + + [Fact] + public void DedicatedFunctionFollowedByLetterStaysAnOperator() { + Test(@"\sin x", new[] { K.Sine, K.SmallX }); + } + + [Fact] + public void DedicatedFunctionBackspaceRemovesTheOperator() { + Test(@"", new[] { K.Sine, K.Backspace }); + } + + [Fact] + public void DedicatedFunctionLeftMovementDoesNotUnfoldIt() { + Test(@"c\sin ", new[] { K.Sine, K.Left, K.SmallC }); + } + + [Fact] + public void MovingRightIntoTypedFunctionPlacesCaretInside() { + var keyboard = new MathKeyboard(context, new TestFont()); + keyboard.KeyPress(K.SmallS, K.SmallI, K.SmallN); + keyboard.InsertionIndex = new MathListIndex(0); + keyboard.KeyPress(K.Right, K.SmallX); + Assert.Equal(@"sxin", keyboard.LaTeX); + } } -} \ No newline at end of file +} diff --git a/CSharpMath/Editor/MathKeyboard.cs b/CSharpMath/Editor/MathKeyboard.cs index 0e9305a3..147ae4c1 100644 --- a/CSharpMath/Editor/MathKeyboard.cs +++ b/CSharpMath/Editor/MathKeyboard.cs @@ -2,6 +2,7 @@ namespace CSharpMath.Editor { using System; using System.Collections.Generic; using System.Drawing; + using System.Runtime.CompilerServices; using System.Timers; using Atom; using Display; @@ -62,6 +63,10 @@ public bool InsertionPositionHighlighted { public MathList MathList { get; } = []; public string LaTeX => LaTeXParser.MathListToLaTeX(MathList).ToString(); private MathListIndex _insertionIndex = new(0); + // ConditionalWeakTable uses reference identity and does not retain operators + // after their enclosing math list has been removed. + readonly ConditionalWeakTable _typedFunctions = new(); + bool IsTypedFunction(MathAtom atom) => _typedFunctions.TryGetValue(atom, out _); public MathListIndex InsertionIndex { get => _insertionIndex; set { @@ -176,6 +181,13 @@ void InsertInner(string left, string right) => MathListSubIndexType.Inner); void MoveCursorLeft() { + if (_insertionIndex.Previous is MathListIndex functionIndex && + MathList.AtomAt(functionIndex) is Atoms.LargeOperator functionOperator && IsTypedFunction(functionOperator) && + TryTypedFunctionName(functionOperator, out var functionName)) { + ExpandFunction(functionIndex, functionName, removeLastLetter: false); + MoveCursorLeft(); + return; + } var prev = _insertionIndex.Previous; switch (MathList.AtomAt(prev)) { case var _ when prev is null: @@ -261,6 +273,14 @@ void MoveCursorLeft() { void MoveCursorRight() { if (_insertionIndex is null) throw new InvalidOperationException($"{nameof(_insertionIndex)} is null."); + if (MathList.AtomAt(_insertionIndex) is Atoms.LargeOperator functionOperator && IsTypedFunction(functionOperator) && + TryTypedFunctionName(functionOperator, out var functionName)) { + var functionIndex = _insertionIndex; + ExpandFunction(functionIndex, functionName, removeLastLetter: false); + _insertionIndex = ReplaceLeafIndex(_insertionIndex, functionIndex.FinalIndex); + MoveCursorRight(); + return; + } switch (MathList.AtomAt(_insertionIndex)) { case null: // After Count var levelDown = _insertionIndex.LevelDown(); @@ -348,7 +368,117 @@ void MoveCursorRight() { void DeleteBackwards() { // delete the last atom from the list if (HasText && _insertionIndex.Previous is MathListIndex previous) - _insertionIndex = MathList.RemoveAt(previous); + if (MathList.AtomAt(previous) is Atoms.LargeOperator op && IsTypedFunction(op) && TryTypedFunctionName(op, out var functionName)) + ExpandFunction(previous, functionName, removeLastLetter: true); + else + _insertionIndex = MathList.RemoveAt(previous); + } + + // A function typed as ordinary letters is promoted to the same operator atom + // used by the corresponding keyboard button. Promotion is deliberately + // local to the current list, so it also works inside fractions, scripts and + // other inner lists. + MathList CurrentList() { + MathList Find(MathList list, MathListIndex index) { + if (index.SubIndexInfo is not { } info) return list; + var atom = list[index.AtomIndex]; + var child = info.SubIndexType switch { + MathListSubIndexType.Superscript => atom.Superscript, + MathListSubIndexType.Subscript => atom.Subscript, + MathListSubIndexType.Numerator => ((Atoms.Fraction)atom).Numerator, + MathListSubIndexType.Denominator => ((Atoms.Fraction)atom).Denominator, + MathListSubIndexType.Radicand => ((Atoms.Radical)atom).Radicand, + MathListSubIndexType.Degree => ((Atoms.Radical)atom).Degree, + MathListSubIndexType.Inner => ((Atoms.Inner)atom).InnerList, + MathListSubIndexType.BetweenBaseAndScripts => list, + _ => throw new InvalidCodePathException("Unknown math list sub-index") + }; + return Find(child, info.SubIndex); + } + return Find(MathList, _insertionIndex); + } + static MathListIndex ReplaceLeafIndex(MathListIndex index, int leafIndex) => + index.SubIndexInfo is null + ? new(leafIndex) + : new(index.AtomIndex, (index.SubIndexInfo.Value.SubIndexType, + ReplaceLeafIndex(index.SubIndexInfo.Value.SubIndex, leafIndex))); + static string? TypedFunctionCommand(string text) => text switch { + "log" => @"\log", + "ln" => @"\ln", + "exp" => @"\exp", + "sin" => @"\sin", + "cos" => @"\cos", + "tan" => @"\tan", + "sinh" => @"\sinh", + "cosh" => @"\cosh", + "tanh" => @"\tanh", + "sec" => @"\sec", + "csc" => @"\csc", + "cosec" => @"\csc", + "cot" => @"\cot", + "arcsin" => @"\arcsin", + "arccos" => @"\arccos", + "arctan" => @"\arctan", + "arccot" => @"\arccot", + "arcsec" => @"\arcsec", + "arccsc" => @"\arccsc", + _ => null + }; + static bool TryTypedFunctionName(MathAtom atom, out string name) { + name = LaTeXSettings.CommandForAtom(atom) switch { + @"\log" => "log", + @"\ln" => "ln", + @"\exp" => "exp", + @"\sin" => "sin", + @"\cos" => "cos", + @"\tan" => "tan", + @"\sinh" => "sinh", + @"\cosh" => "cosh", + @"\tanh" => "tanh", + @"\sec" => "sec", + @"\csc" => "csc", + @"\cot" => "cot", + @"\arcsin" => "arcsin", + @"\arccos" => "arccos", + @"\arctan" => "arctan", + @"\arccot" => "arccot", + @"\arcsec" => "arcsec", + @"\arccsc" => "arccsc", + _ => null + } ?? ""; + return name.Length != 0; + } + void ExpandFunction(MathListIndex operatorIndex, string name, bool removeLastLetter) { + var list = CurrentList(); + var start = operatorIndex.FinalIndex; + var atom = list[start]; + _typedFunctions.Remove(atom); + list.RemoveAt(start); + var letters = name.Substring(0, removeLastLetter ? name.Length - 1 : name.Length); + for (var i = 0; i < letters.Length; i++) + list.Insert(start + i, new Atoms.Variable(letters[i].ToString())); + _insertionIndex = ReplaceLeafIndex(_insertionIndex, start + letters.Length); + } + void ExpandPreviousFunction() { + if (_insertionIndex.Previous is MathListIndex previous && + MathList.AtomAt(previous) is Atoms.LargeOperator op && IsTypedFunction(op) && TryTypedFunctionName(op, out var name)) + ExpandFunction(previous, name, removeLastLetter: false); + } + void RecognizeTypedFunction() { + var list = CurrentList(); + var end = _insertionIndex.FinalIndex; + var start = end; + const int maxFunctionNameLength = 7; + while (start > 0 && end - start < maxFunctionNameLength && list[start - 1] is Atoms.Variable) start--; + if (start > 0 && list[start - 1] is Atoms.Variable) return; + if (start == end) return; + var text = string.Concat(list.Atoms.GetRange(start, end - start).ConvertAll(a => a.Nucleus)); + var command = TypedFunctionCommand(text); + if (command is null || LaTeXSettings.AtomForCommand(command) is not { } atom) return; + list.RemoveAtoms(start, end - start); + list.Insert(start, atom); + _typedFunctions.Add(atom, new object()); + _insertionIndex = ReplaceLeafIndex(_insertionIndex, start + 1); } static bool IsPlaceholderList(MathList ml) => ml.Count == 1 && ml[0] is Atoms.Placeholder; @@ -727,8 +857,10 @@ void InsertSymbolName(string name, bool subscript = false, bool superscript = fa case MathKeyboardInput.SmallX: case MathKeyboardInput.SmallY: case MathKeyboardInput.SmallZ: + ExpandPreviousFunction(); InsertAtom(LaTeXSettings.AtomForCommand(new string((char)input, 1)) ?? throw new InvalidCodePathException($"{nameof(LaTeXSettings.AtomForCommand)} returned null for {input}")); + RecognizeTypedFunction(); break; case MathKeyboardInput.Alpha: case MathKeyboardInput.Beta: @@ -828,4 +960,4 @@ public void Dispose() { ((IDisposable)blinkTimer).Dispose(); } } -} \ No newline at end of file +}