From 198b1ef4686351decadd279859e0d824d9448787 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:47:06 +0000 Subject: [PATCH] [no-new-version] Math tools: a second app around the editor, with a scientific keypad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit samples/SharpEquations.Avalonia.MathTools.Demo (desktop) and samples/SharpEquations.Avalonia.Browser.MathTools.Demo (WebAssembly, published by demo.yml under math/ of the site) share MathToolsView, a page per tool. The first is the calculator: functions, powers, structures, constants, digits and operators that type into a MathEditBox as the keyboard does (sin( waits for its argument, x² squares what is before the caret, a/b and √ take the selection), with the equation as UnicodeMath and LaTeX. Tests: the keys type the equation they show, and the window opens and types sin(x)²+cos(x)²=1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyVio8afJsGwHW9i5v5HDs --- .github/workflows/demo.yml | 11 +- AGENTS.md | 2 + README.md | 4 +- SharpEquations.slnx | 4 + .../Program.cs | 36 +++++ ...ons.Avalonia.Browser.MathTools.Demo.csproj | 34 ++++ .../wwwroot/app.css | 123 +++++++++++++++ .../wwwroot/favicon.svg | 1 + .../wwwroot/index.html | 29 ++++ .../wwwroot/main.js | 28 ++++ .../CalculatorKeys.cs | 109 +++++++++++++ .../CalculatorPage.cs | 149 ++++++++++++++++++ .../MainWindow.cs | 16 ++ .../MathToolsTheme.cs | 23 +++ .../MathToolsView.cs | 137 ++++++++++++++++ .../Program.cs | 26 +++ ...rpEquations.Avalonia.MathTools.Demo.csproj | 21 +++ .../Ui.cs | 98 ++++++++++++ .../MathTools/CalculatorKeysTests.cs | 76 +++++++++ .../MathTools/MathToolsWindowTests.cs | 50 ++++++ .../SharpEquations.Avalonia.Tests.csproj | 3 +- 21 files changed, 976 insertions(+), 4 deletions(-) create mode 100644 samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs create mode 100644 samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj create mode 100644 samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/app.css create mode 100644 samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/favicon.svg create mode 100644 samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/index.html create mode 100644 samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/main.js create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorKeys.cs create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsTheme.cs create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj create mode 100644 samples/SharpEquations.Avalonia.MathTools.Demo/Ui.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/MathTools/CalculatorKeysTests.cs create mode 100644 tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index 327cde7..a0495d4 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -1,6 +1,7 @@ name: Web demo -# The equation editor on WebAssembly (samples/SharpEquations.Avalonia.Browser.Demo): built on every pull request, +# The equation editor on WebAssembly (samples/SharpEquations.Avalonia.Browser.Demo), and the math tools under math/ +# (samples/SharpEquations.Avalonia.Browser.MathTools.Demo): built on every pull request, # published to GitHub Pages from main, or from any branch when the workflow is run by hand (Settings → Pages → Source: # GitHub Actions; a branch other than main must be allowed in Settings → Environments → github-pages). on: @@ -22,6 +23,7 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: 'true' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' PROJECT: samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj + MATH_TOOLS_PROJECT: samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj jobs: build: @@ -43,11 +45,16 @@ jobs: - name: Publish run: dotnet publish "$PROJECT" -c Release -o publish - # GitHub Pages serves the site from a folder: no Jekyll, and the index for any path that is not a file + - name: Publish the math tools + run: dotnet publish "$MATH_TOOLS_PROJECT" -c Release -o publish-math-tools + + # GitHub Pages serves the site from a folder: no Jekyll, and the index for any path that is not a file; the math + # tools are a site of their own in math/ (their pages are relative to it) - name: Prepare the site run: | touch publish/wwwroot/.nojekyll cp publish/wwwroot/index.html publish/wwwroot/404.html + cp -r publish-math-tools/wwwroot publish/wwwroot/math - uses: actions/upload-pages-artifact@v4 with: diff --git a/AGENTS.md b/AGENTS.md index ff42ffb..3194c48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other | `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math | | `samples/SharpEquations.Avalonia.Demo/` | A window with the editor (`DemoView`, shared with the web demo) | | `samples/SharpEquations.Avalonia.Browser.Demo/` | The same demo on WebAssembly (needs the `wasm-tools` workload, so not in the solution; `.github/workflows/demo.yml` publishes it to GitHub Pages) | +| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad) | +| `samples/SharpEquations.Avalonia.Browser.MathTools.Demo/` | The math tools on WebAssembly (as the web demo, not in the solution build; `demo.yml` publishes them under `math/`) | | `benchmarks/SharpEquations.Benchmarks/` | BenchmarkDotNet benchmarks of reading, writing and converting the corpora | | `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) | | `tests/SharpEquations.Avalonia.Tests/` | TUnit tests of the editor (Editing, Rendering, and the controls in a headless Avalonia session with Skia) | diff --git a/README.md b/README.md index dc29be1..ad6cfff 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ three projects, so that the core keeps no dependency: `SharpEquations.Editing` ( [docs/EDITOR.md](docs/EDITOR.md); `samples/SharpEquations.Avalonia.Demo` is a window with the editor and its MathML, UnicodeMath and LaTeX, and `samples/SharpEquations.Avalonia.Browser.Demo` the same demo in the browser, on WebAssembly (published to GitHub Pages from `main`; to run it locally: `dotnet workload install wasm-tools`, then -`dotnet run --project samples/SharpEquations.Avalonia.Browser.Demo`). +`dotnet run --project samples/SharpEquations.Avalonia.Browser.Demo`). `samples/SharpEquations.Avalonia.MathTools.Demo` is a +second app around the editor, a set of math tools (a scientific keypad that types into the editor), with +`samples/SharpEquations.Avalonia.Browser.MathTools.Demo` in the browser (published under `math/` of the same site). ## Architecture diff --git a/SharpEquations.slnx b/SharpEquations.slnx index 4c701fe..221091a 100644 --- a/SharpEquations.slnx +++ b/SharpEquations.slnx @@ -4,7 +4,11 @@ + + + + diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs new file mode 100644 index 0000000..eed1006 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs @@ -0,0 +1,36 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System.Runtime.Versioning; + using System.Threading.Tasks; + using global::Avalonia; + using global::Avalonia.Browser; + using global::Avalonia.Controls.ApplicationLifetimes; + using global::Avalonia.Media; + + [SupportedOSPlatform("browser")] + public static class Program + { + public static Task Main() => BuildAvaloniaApp().StartBrowserAppAsync("out"); + + // A page has no fonts of its own: Inter for the text, and the embedded Libertinus Math for the symbols it lacks. + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .WithInterFont() + .With(new FontManagerOptions + { + DefaultFamilyName = "fonts:Inter#Inter", + FontFallbacks = new[] { new FontFallback { FontFamily = new FontFamily("fonts:SharpEquations#Libertinus Math") } }, + }); + } + + public sealed class App : Application + { + public override void Initialize() => MathToolsTheme.Apply(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is ISingleViewApplicationLifetime view) + view.MainView = new MathToolsView(); + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj new file mode 100644 index 0000000..384028e --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj @@ -0,0 +1,34 @@ + + + + + net10.0-browser + Exe + false + enable + SharpEquations.Avalonia.MathTools + true + true + true + false + true + + + + + + + + + + + + + + + + + + diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/app.css b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/app.css new file mode 100644 index 0000000..77a5803 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/app.css @@ -0,0 +1,123 @@ +:root { + --page: #f3f4fa; + --ink: #1f2333; + --muted: #6b7280; + --track: rgba(15, 118, 110, 0.15); +} + +@media (prefers-color-scheme: dark) { + :root { + --page: #0d0f16; + --ink: #e7e9f2; + --muted: #9aa3b5; + --track: rgba(94, 234, 212, 0.18); + } +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + background: var(--page); + color: var(--ink); + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +#out { + position: fixed; + inset: 0; +} + +#splash { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: + radial-gradient(60rem 40rem at 10% -10%, rgba(20, 184, 166, 0.35), transparent 60%), + radial-gradient(50rem 40rem at 110% 110%, rgba(37, 99, 235, 0.28), transparent 60%), + var(--page); + transition: opacity 0.45s ease, visibility 0.45s; + z-index: 10; +} + +#splash.done { + opacity: 0; + visibility: hidden; +} + +#splash .card { + text-align: center; + padding: 2.5rem 3rem; + max-width: 26rem; +} + +#splash .formula { + display: flex; + justify-content: center; + gap: 0.9rem; + font-size: 2.6rem; + font-family: "Cambria Math", "STIX Two Math", "Latin Modern Math", serif; + background: linear-gradient(120deg, #0f766e, #0891b2 55%, #2563eb); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +#splash .formula span { + display: inline-block; + animation: float 1.8s ease-in-out infinite; +} + +#splash .formula span:nth-child(2) { animation-delay: 0.15s; } +#splash .formula span:nth-child(3) { animation-delay: 0.3s; } +#splash .formula span:nth-child(4) { animation-delay: 0.45s; } + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-0.4rem); } +} + +#splash h1 { + margin: 0.8rem 0 0.3rem; + font-size: 1.7rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +#splash p { + margin: 0 0 1.4rem; + color: var(--muted); +} + +#splash .bar { + height: 6px; + border-radius: 999px; + background: var(--track); + overflow: hidden; +} + +#splash #progress { + width: 4%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #0f766e, #0891b2, #2563eb); + transition: width 0.25s ease; +} + +#splash small { + display: block; + margin-top: 1rem; + color: var(--muted); + font-size: 0.75rem; + letter-spacing: 0.02em; +} + +#splash.failed .bar { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + #splash .formula span { animation: none; } +} diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/favicon.svg b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/favicon.svg new file mode 100644 index 0000000..afdf5ff --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/favicon.svg @@ -0,0 +1 @@ +∑ diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/index.html b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/index.html new file mode 100644 index 0000000..80ef56c --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/index.html @@ -0,0 +1,29 @@ + + + + + + SharpEquations Math Tools + + + + + + + + +
+
+
+
+ sin√π△ +
+

SharpEquations Math Tools

+

Loading the math tools…

+
+ .NET on WebAssembly · Avalonia · OpenType MATH +
+
+ + + diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/main.js b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/main.js new file mode 100644 index 0000000..ad340f0 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/wwwroot/main.js @@ -0,0 +1,28 @@ +import { dotnet } from './_framework/dotnet.js'; + +const splash = document.getElementById('splash'); +const progress = document.getElementById('progress'); + +try { + const runtime = await dotnet + .withDiagnosticTracing(false) + .withApplicationArgumentsFromQuery() + .withResourceLoader((type, name, url) => url) + .withModuleConfig({ + onDownloadResourceProgress: (loaded, total) => { + if (total > 0) progress.style.width = `${Math.round(100 * loaded / total)}%`; + }, + }) + .create(); + const config = runtime.getConfig(); + const started = runtime.runMain(config.mainAssemblyName, [globalThis.location.href]); + const hide = () => splash.classList.add('done'); + new MutationObserver((_, observer) => { + if (document.querySelector('#out canvas')) { hide(); observer.disconnect(); } + }).observe(document.getElementById('out'), { childList: true, subtree: true }); + await started; +} catch (error) { + splash.classList.add('failed'); + document.getElementById('message').textContent = `The math tools could not start: ${error}`; + throw error; +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorKeys.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorKeys.cs new file mode 100644 index 0000000..5faec2b --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorKeys.cs @@ -0,0 +1,109 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System; + using System.Collections.Generic; + using SharpEquations.Editing; + + /// A key of the keypad: its face (UnicodeMath, or plain text), its tool tip, and what it does to the editor. + public sealed class CalculatorKey + { + public CalculatorKey(string face, string tip, Action press, bool isMath = true) + { + Face = face; + Tip = tip; + Press = press; + IsMath = isMath; + } + + /// The face of the key: UnicodeMath, drawn as an equation, when , otherwise plain text. + public string Face { get; } + + public string Tip { get; } + + public Action Press { get; } + + public bool IsMath { get; } + } + + /// + /// The keys of the scientific keypad. A key types into the editor as the keyboard would (sin(, ^2␣, + /// ×), so that what it types builds up with what is before it, or inserts a structure with its slots, as the + /// buttons of the equation tools of Word (a fraction, a root, a sum with its limits), around the selection if any. + /// + public static class CalculatorKeys + { + private static readonly MathNode Empty = MathNode.Row(Array.Empty()); + + /// The functions, powers, structures and constants, in six columns. + public static IReadOnlyList Functions { get; } = new[] + { + Function("sin"), Function("cos"), Function("tan"), + Typed("sin^-1", "Arcsine", "sin^(-1)("), Typed("cos^-1", "Arccosine", "cos^(-1)("), Typed("tan^-1", "Arctangent", "tan^(-1)("), + Function("sinh"), Function("cosh"), Function("tanh"), + Function("log"), Function("ln"), + Template("log_b", "Logarithm to a base", MathNode.Element("msub", new[] { MathNode.Row(new[] { MathNode.Token("mi", "log") }), Empty })), + Power("x^2", "Square", "2"), Typed("x^n", "Power", "^"), Typed("x_n", "Subscript", "_"), + Structure("√x", "Square root", "msqrt"), Structure("√(n&x)", "Root", "mroot"), Structure("a/b", "Fraction", "mfrac"), + Typed("e^x", "Exponential", "e^"), Typed("10^x", "Power of ten", "10^"), + Fences("|x|", "Absolute value", "|", "|"), Fences("(x)", "Parentheses", "(", ")"), + Typed("n!", "Factorial", "!"), Typed("±", "Plus or minus", "±"), + Typed("π", "Pi", "π"), Typed("e", "Euler's number", "e"), Typed("i", "Imaginary unit", "i"), + Typed("∞", "Infinity", "∞"), Typed("θ", "Theta", "θ"), Typed("°", "Degrees", "°"), + Template("∑_(k=1)^n", "Sum", MathNode.Element("munderover", new[] { Operator("∑"), Empty, Empty })), + Template("∏_(k=1)^n", "Product", MathNode.Element("munderover", new[] { Operator("∏"), Empty, Empty })), + Typed("∫", "Integral", "∫"), + Template("∫_a^b", "Integral with limits", MathNode.Element("msubsup", new[] { Operator("∫"), Empty, Empty })), + Typed("ⅆ/ⅆx", "Derivative", "ⅆ/ⅆx "), + Template("lim┬(x→a)", "Limit", MathNode.Element("munder", new[] { MathNode.Row(new[] { MathNode.Token("mi", "lim") }), Empty })), + }; + + /// The digits, the operators, the brackets and the variables, in five columns. + public static IReadOnlyList Numbers { get; } = new[] + { + Digit("7"), Digit("8"), Digit("9"), Typed("÷", "Divide", "÷"), new CalculatorKey("(", "Open bracket", e => e.Insert("("), isMath: false), + Digit("4"), Digit("5"), Digit("6"), Typed("×", "Multiply", "×"), new CalculatorKey(")", "Close bracket", e => e.Insert(")"), isMath: false), + Digit("1"), Digit("2"), Digit("3"), Typed("−", "Subtract", "-"), Typed("x", "x", "x"), + Digit("0"), Typed(".", "Decimal point", "."), Typed(",", "Comma", ","), Typed("+", "Add", "+"), Typed("y", "y", "y"), + Typed("=", "Equals", "="), Typed("<", "Less than", "<"), Typed(">", "Greater than", ">"), Typed("≤", "Less than or equal to", "≤"), Typed("≥", "Greater than or equal to", "≥"), + }; + + /// The keys that move the caret, delete, undo and build up. + public static IReadOnlyList Editing { get; } = new[] + { + new CalculatorKey("←", "Move left", e => e.MoveLeft(), isMath: false), + new CalculatorKey("→", "Move right", e => e.MoveRight(), isMath: false), + new CalculatorKey("⌫", "Delete the character or the structure before the caret", e => e.Backspace(), isMath: false), + new CalculatorKey("↶", "Undo", e => e.Undo(), isMath: false), + new CalculatorKey("↷", "Redo", e => e.Redo(), isMath: false), + new CalculatorKey("Build up", "Build up what is typed", e => e.BuildUpAll(), isMath: false), + new CalculatorKey("Clear", "An empty equation", e => e.Load(null), isMath: false), + }; + + private static MathNode Operator(string text) => MathNode.Row(new[] { MathNode.Token("mo", text) }); + + private static CalculatorKey Digit(string digit) => Typed(digit, digit, digit); + + private static CalculatorKey Function(string name) => Typed(name, name, name + "("); + + private static CalculatorKey Typed(string face, string tip, string text) => new CalculatorKey(face, tip, e => e.Insert(text)); + + private static CalculatorKey Structure(string face, string tip, string name) => new CalculatorKey(face, tip, e => e.InsertStructure(name)); + + private static CalculatorKey Template(string face, string tip, MathNode node) => new CalculatorKey(face, tip, e => e.InsertTemplate(node)); + + private static CalculatorKey Fences(string face, string tip, string open, string close) => new CalculatorKey(face, tip, e => e.InsertFences(open, close)); + + // a power of the selection, or of what is before the caret: typed as ^2 and a space, which builds it up + private static CalculatorKey Power(string face, string tip, string exponent) => new CalculatorKey(face, tip, e => + { + if (e.Selection == null) + { + e.Insert("^" + exponent + " "); + return; + } + e.InsertStructure("msup"); + e.Insert(exponent); + e.MoveRight(); + }); + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs new file mode 100644 index 0000000..64cbbc7 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs @@ -0,0 +1,149 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System.Collections.Generic; + using System.Linq; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.Primitives; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using SharpEquations; + + /// + /// The scientific keypad: the keys type into the equation editor, which the keyboard types into too, and the equation + /// is shown as UnicodeMath and LaTeX as it is typed. + /// + public sealed class CalculatorPage : UserControl + { + private readonly TextBox unicodeMath = Code(); + private readonly TextBox latex = Code(); + + public CalculatorPage() + { + Editor = new MathEditBox + { + FontSize = 34, + MinHeight = 110, + Padding = new Thickness(22, 18), + BorderThickness = 0, + }; + Editor.Bind(MathEditBox.BackgroundProperty, Editor.GetResourceObservable("EditorBrush")); + Editor.EquationChanged += (_, _) => Refresh(); + + var frame = new Border + { + CornerRadius = new CornerRadius(12), + BorderThickness = new Thickness(1.5), + ClipToBounds = true, + Child = Editor, + }; + frame.Bind(Border.BorderBrushProperty, frame.GetResourceObservable("EditorBorderBrush")); + + var keypad = new WrapPanel + { + Orientation = Orientation.Horizontal, + ItemSpacing = 18, + LineSpacing = 12, + Children = + { + Keys(CalculatorKeys.Functions, 6, "KeyBrush"), + Keys(CalculatorKeys.Numbers, 5, "DigitBrush"), + }, + }; + var editing = new WrapPanel { Orientation = Orientation.Horizontal, ItemSpacing = 8, LineSpacing = 8 }; + foreach (var key in CalculatorKeys.Editing) + editing.Children.Add(Key(key, "KeyBrush")); + + var formats = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,16,*"), + Children = { Labelled("UnicodeMath", unicodeMath, 0), Labelled("LaTeX", latex, 2) }, + }; + + Content = new StackPanel + { + Spacing = 20, + Children = + { + Ui.Card("Calculator", "The keys type into the editor as the keyboard does: sin( waits for its argument, x² squares what is before the caret, a/b and √ take the selection", new StackPanel + { + Spacing = 16, + Children = { frame, keypad, editing }, + }), + Ui.Card("As text", "The equation in the linear formats, as it is typed", formats), + }, + }; + } + + /// The equation editor the keys type into. + public MathEditBox Editor { get; } + + /// Presses a key, as a click on it does. + public void Press(CalculatorKey key) + { + key.Press(Editor.Editor); + Editor.Focus(); + } + + private UniformGrid Keys(IReadOnlyList keys, int columns, string brush) + { + var grid = new UniformGrid { Columns = columns, ColumnSpacing = 6, RowSpacing = 6 }; + foreach (var key in keys) + grid.Children.Add(Key(key, brush)); + return grid; + } + + private Button Key(CalculatorKey key, string brush) + { + var button = new Button + { + MinWidth = 58, + MinHeight = 46, + Padding = new Thickness(8, 4), + CornerRadius = new CornerRadius(10), + BorderThickness = new Thickness(1), + Focusable = false, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Content = key.IsMath + ? Ui.Math(key.Face, 18) + : new TextBlock { Text = key.Face, FontSize = 15 }, + }; + button.Bind(Button.BackgroundProperty, button.GetResourceObservable(brush)); + button.Bind(Button.BorderBrushProperty, button.GetResourceObservable("CardBorderBrush")); + ToolTip.SetTip(button, key.Tip); + button.Click += (_, _) => Press(key); + return button; + } + + private void Refresh() + { + // text that is not built up yet (sin( waiting for its argument) is an merror of the equation + var equation = Editor.Equation; + var complete = equation != null && !equation.ToMathML().Descendants().Any(e => e.Name.LocalName == "merror"); + unicodeMath.Text = complete ? equation!.ToString(EquationFormat.UnicodeMath) : null; + latex.Text = complete ? equation!.ToString(EquationFormat.LaTeX) : null; + } + + private static StackPanel Labelled(string label, Control control, int column) + { + var panel = new StackPanel { Spacing = 6, Children = { Ui.Muted(label), control } }; + Grid.SetColumn(panel, column); + return panel; + } + + private static TextBox Code() => new TextBox + { + IsReadOnly = true, + TextWrapping = TextWrapping.Wrap, + FontFamily = new FontFamily("Cascadia Mono,Consolas,Menlo,DejaVu Sans Mono,monospace"), + FontSize = 13, + MinHeight = 44, + Padding = new Thickness(12, 10), + CornerRadius = new CornerRadius(10), + PlaceholderText = "Not built up yet: finish the argument, or press Build up (Enter)", + }; + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs new file mode 100644 index 0000000..558b530 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs @@ -0,0 +1,16 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using global::Avalonia.Controls; + + /// The window of the desktop app: the view the WebAssembly page shows too. + public sealed class MainWindow : Window + { + public MainWindow() + { + Title = "SharpEquations Math Tools"; + Width = 1180; + Height = 860; + Content = new MathToolsView(); + } + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsTheme.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsTheme.cs new file mode 100644 index 0000000..0e78242 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsTheme.cs @@ -0,0 +1,23 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using global::Avalonia; + using global::Avalonia.Media; + using global::Avalonia.Styling; + using global::Avalonia.Themes.Fluent; + + /// The Fluent theme of the app, with a teal accent in the light and the dark variant. + public static class MathToolsTheme + { + public static void Apply(Application application) + { + application.Styles.Add(new FluentTheme + { + Palettes = + { + [ThemeVariant.Light] = new ColorPaletteResources { Accent = Color.FromRgb(0x0F, 0x76, 0x6E) }, + [ThemeVariant.Dark] = new ColorPaletteResources { Accent = Color.FromRgb(0x2D, 0xD4, 0xBF) }, + }, + }); + } + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs new file mode 100644 index 0000000..39d35cd --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs @@ -0,0 +1,137 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using global::Avalonia.Styling; + using SharpEquations; + + /// + /// The math tools, a page each: the scientific keypad around the equation editor. The desktop window and the + /// WebAssembly page show the same view. + /// + public sealed class MathToolsView : UserControl + { + private const string RepositoryUrl = "https://github.com/simonebortolin/SharpEquations"; + + public MathToolsView() + { + Ui.AddBrushes(Resources); + Calculator = new CalculatorPage(); + + var tabs = new TabControl + { + Margin = new Thickness(20, 12, 20, 0), + ItemsSource = new[] + { + Tab("Calculator", Calculator), + }, + }; + + var root = new DockPanel { Children = { Ui.Docked(Header(), Dock.Top), tabs } }; + root.Bind(Panel.BackgroundProperty, root.GetResourceObservable("PageBrush")); + Content = root; + } + + /// The page of the scientific keypad. + public CalculatorPage Calculator { get; } + + private static TabItem Tab(string header, Control page) => new TabItem + { + Header = new TextBlock { Text = header, FontSize = 15, FontWeight = FontWeight.SemiBold }, + Content = new ScrollViewer + { + Content = new Border { Padding = new Thickness(0, 12, 0, 32), MaxWidth = 1180, Child = page }, + HorizontalScrollBarVisibility = global::Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + }, + }; + + private Border Header() + { + var title = new StackPanel + { + Spacing = 4, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + new TextBlock { Text = "SharpEquations Math Tools", FontSize = 26, FontWeight = FontWeight.Bold, Foreground = Brushes.White }, + new TextBlock + { + Text = "A scientific keypad, formulas, units and triangles around a real-time equation editor in .NET.", + FontSize = 14, + Foreground = new SolidColorBrush(Colors.White, 0.85), + TextWrapping = TextWrapping.Wrap, + }, + }, + }; + + var hero = new MathView + { + FontSize = 24, + Foreground = Brushes.White, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(24, 0), + Equation = Equation.Parse("∑_(k=1)^∞ 1/k^2=π^2/6", EquationFormat.UnicodeMath, new EquationOptions { Display = true }), + }; + + var theme = HeaderButton("Theme", "Light or dark theme"); + theme.Click += (_, _) => + { + if (Application.Current is { } app) + app.RequestedThemeVariant = app.ActualThemeVariant == ThemeVariant.Dark ? ThemeVariant.Light : ThemeVariant.Dark; + }; + var github = HeaderButton("GitHub ↗", RepositoryUrl); + github.Click += async (_, _) => + { + if (TopLevel.GetTopLevel(this)?.Launcher is { } launcher) + await launcher.LaunchUriAsync(new Uri(RepositoryUrl)).ConfigureAwait(true); + }; + + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center, Children = { theme, github } }; + var bar = new DockPanel { Children = { Ui.Docked(buttons, Dock.Right), Ui.Docked(hero, Dock.Right), title } }; + bar.SizeChanged += (_, e) => + { + var narrow = e.NewSize.Width < 640; + hero.IsVisible = e.NewSize.Width > 900; + DockPanel.SetDock(buttons, narrow ? Dock.Bottom : Dock.Right); + buttons.HorizontalAlignment = narrow ? HorizontalAlignment.Left : HorizontalAlignment.Right; + buttons.Margin = narrow ? new Thickness(0, 14, 0, 0) : default; + }; + + return new Border + { + Padding = new Thickness(28, 20), + Background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Color.FromRgb(0x0F, 0x76, 0x6E), 0), + new GradientStop(Color.FromRgb(0x08, 0x91, 0xB2), 0.55), + new GradientStop(Color.FromRgb(0x25, 0x63, 0xEB), 1), + }, + }, + Child = bar, + }; + } + + private static Button HeaderButton(string text, string tip) + { + var button = new Button + { + Content = text, + Foreground = Brushes.White, + Background = new SolidColorBrush(Colors.White, 0.16), + BorderBrush = new SolidColorBrush(Colors.White, 0.35), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(18), + Padding = new Thickness(14, 6), + }; + ToolTip.SetTip(button, tip); + return button; + } + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs new file mode 100644 index 0000000..85b9c18 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs @@ -0,0 +1,26 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System; + using global::Avalonia; + using global::Avalonia.Controls.ApplicationLifetimes; + + public static class Program + { + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure().UsePlatformDetect(); + } + + public sealed class App : Application + { + public override void Initialize() => MathToolsTheme.Apply(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj b/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj new file mode 100644 index 0000000..2158bb3 --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj @@ -0,0 +1,21 @@ + + + + + WinExe + net10.0 + false + enable + SharpEquations.Avalonia.MathTools + + + + + + + + + + + + diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Ui.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/Ui.cs new file mode 100644 index 0000000..d7161fe --- /dev/null +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/Ui.cs @@ -0,0 +1,98 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using global::Avalonia.Media.Immutable; + using global::Avalonia.Styling; + using SharpEquations; + + /// The pieces of the pages: cards, labels, rows, and the brushes of the light and the dark theme. + internal static class Ui + { + public static void AddBrushes(IResourceDictionary resources) + { + resources.ThemeDictionaries[ThemeVariant.Light] = new ResourceDictionary + { + ["PageBrush"] = Solid(0xF2F6F5), + ["CardBrush"] = Solid(0xFFFFFF), + ["CardBorderBrush"] = Solid(0xDDE7E5), + ["MutedBrush"] = Solid(0x64748B), + ["EditorBrush"] = Solid(0xFBFEFD), + ["CodeBrush"] = Solid(0xF4F8F7), + ["KeyBrush"] = Solid(0xF1F5F4), + ["DigitBrush"] = Solid(0xFFFFFF), + ["EditorBorderBrush"] = Solid(0x14B8A6), + }; + resources.ThemeDictionaries[ThemeVariant.Dark] = new ResourceDictionary + { + ["PageBrush"] = Solid(0x0B1113), + ["CardBrush"] = Solid(0x141C1F), + ["CardBorderBrush"] = Solid(0x24302F), + ["MutedBrush"] = Solid(0x94A3B8), + ["EditorBrush"] = Solid(0x0F1618), + ["CodeBrush"] = Solid(0x0F1517), + ["KeyBrush"] = Solid(0x1A2427), + ["DigitBrush"] = Solid(0x222E31), + ["EditorBorderBrush"] = Solid(0x0D9488), + }; + } + + public static Border Card(string title, string subtitle, Control content) + { + var heading = new TextBlock { Text = title, FontSize = 17, FontWeight = FontWeight.SemiBold }; + var caption = Muted(subtitle, 12.5); + var card = new Border + { + CornerRadius = new CornerRadius(16), + BorderThickness = new Thickness(1), + Padding = new Thickness(22, 18, 22, 22), + BoxShadow = BoxShadows.Parse("0 6 28 0 #12000000"), + Child = new StackPanel + { + Spacing = 14, + Children = { new StackPanel { Spacing = 2, Children = { heading, caption } }, content }, + }, + }; + card.Bind(Border.BackgroundProperty, card.GetResourceObservable("CardBrush")); + card.Bind(Border.BorderBrushProperty, card.GetResourceObservable("CardBorderBrush")); + return card; + } + + public static TextBlock Muted(string text, double size = 12) + { + var block = new TextBlock { Text = text, FontSize = size, TextWrapping = TextWrapping.Wrap }; + block.Bind(TextBlock.ForegroundProperty, block.GetResourceObservable("MutedBrush")); + return block; + } + + public static TextBlock Small(string text) => new TextBlock { Text = text, FontSize = 13, FontWeight = FontWeight.SemiBold }; + + public static MathView Math(string unicodeMath, double size, bool display = false) => new MathView + { + FontSize = size, + IsHitTestVisible = false, + Equation = Equation.Parse(unicodeMath, EquationFormat.UnicodeMath, new EquationOptions { Display = display }), + }; + + public static Control Docked(Control control, Dock dock) + { + DockPanel.SetDock(control, dock); + return control; + } + + public static WrapPanel Row(params Control[] children) + { + var row = new WrapPanel { Orientation = Orientation.Horizontal, ItemSpacing = 8, LineSpacing = 8 }; + foreach (var child in children) + { + child.VerticalAlignment = VerticalAlignment.Center; + row.Children.Add(child); + } + return row; + } + + public static ImmutableSolidColorBrush Solid(uint rgb) => new ImmutableSolidColorBrush(Color.FromUInt32(0xFF000000 | rgb)); + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/CalculatorKeysTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/CalculatorKeysTests.cs new file mode 100644 index 0000000..51ecf75 --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/MathTools/CalculatorKeysTests.cs @@ -0,0 +1,76 @@ +#if NET10_0_OR_GREATER +namespace SharpEquations.Avalonia.Tests.MathTools +{ + using System.Linq; + using System.Threading.Tasks; + using SharpEquations; + using SharpEquations.Avalonia.MathTools; + using SharpEquations.Editing; + + /// The keys of the keypad of the math tools type the equation they show. + public class CalculatorKeysTests + { + private static readonly EquationOptions Display = new EquationOptions { Display = true }; + + public static System.Collections.Generic.IEnumerable<(string Keys, string Expected)> Sequences() => new[] + { + ("sin x )", "sin(x)"), + ("ln x )", "ln(x)"), + ("sin^-1 x )", "sin^(-1)(x)"), + ("7 x^2", "7^2"), + ("( x + 1 ) x^2", "(x+1)^2"), + ("x x^n 3 +", "x^3+"), + ("a/b 1 ↓ 2", "1/2"), + ("√x 2", "√2"), + ("e^x x +", "e^x+"), + ("|x| x", "|x|"), + ("5 n! ×", "5!×"), + ("(x) x", "(x)"), + ("|x| x + 1", "|x+1|"), + ("2 × π", "2×π"), + ("1 . 5 ÷ 3 = x", "1.5÷3=x"), + ("ⅆ/ⅆx x", "ⅆ/ⅆx x"), + }; + + [Test] + [MethodDataSource(nameof(Sequences))] + public async Task KeysTypeTheEquation(string keys, string expected) + { + var editor = new MathEditor { IsDisplay = true }; + foreach (var face in keys.Split(' ')) + { + if (face == "↓") + { + editor.MoveDown(); + continue; + } + var key = CalculatorKeys.Functions.Concat(CalculatorKeys.Numbers).Concat(CalculatorKeys.Editing).FirstOrDefault(k => k.Face == face); + if (key == null) + editor.Insert(face); + else + key.Press(editor); + } + editor.BuildUpAll(); + await Assert.That(editor.ToEquation()).IsEqualTo(Equation.Parse(expected, EquationFormat.UnicodeMath, Display)); + } + + [Test] + public async Task EveryKeyHasAFaceThatParses() + { + foreach (var key in CalculatorKeys.Functions.Concat(CalculatorKeys.Numbers).Where(k => k.IsMath)) + await Assert.That(Equation.TryParse(key.Face, EquationFormat.UnicodeMath, out _)).IsTrue().Because(key.Face); + } + + [Test] + public async Task EveryKeyTypesSomething() + { + foreach (var key in CalculatorKeys.Functions.Concat(CalculatorKeys.Numbers)) + { + var editor = new MathEditor(); + key.Press(editor); + await Assert.That(editor.IsEmpty).IsFalse().Because(key.Face); + } + } + } +} +#endif diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs new file mode 100644 index 0000000..ef42b8b --- /dev/null +++ b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs @@ -0,0 +1,50 @@ +#if NET10_0_OR_GREATER +namespace SharpEquations.Avalonia.Tests.MathTools +{ + using System; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + using global::Avalonia.Controls; + using global::Avalonia.Headless; + using global::Avalonia.VisualTree; + using SharpEquations; + using SharpEquations.Avalonia.MathTools; + + /// The window of the math tools opens, and its pages work (a picture of each with SHARPEQUATIONS_SCREENSHOTS). + [NotInParallel("headless")] + public class MathToolsWindowTests + { + private static void Save(Window window, string name) + { + window.UpdateLayout(); + if (Environment.GetEnvironmentVariable("SHARPEQUATIONS_SCREENSHOTS") is { } directory && window.CaptureRenderedFrame() is { } frame) + { + Directory.CreateDirectory(directory); + frame.Save(Path.Combine(directory, name + ".png"), global::Avalonia.Media.Imaging.PngBitmapEncoderOptions.Default); + } + } + + [Test] + public async Task KeypadTypesIntoTheEditor() + { + var (equation, unicodeMath) = await Headless.Run(() => + { + var window = new MainWindow(); + window.Show(); + var page = window.GetVisualDescendants().OfType().Single(); + foreach (var face in new[] { "sin", "x", ")", "x^2", "+", "cos", "x", ")", "x^2", "=", "1" }) + page.Press(CalculatorKeys.Functions.Concat(CalculatorKeys.Numbers).First(k => k.Face == face)); + page.Press(CalculatorKeys.Editing.Single(k => k.Face == "Build up")); + Save(window, "math-tools-calculator"); + var texts = window.GetVisualDescendants().OfType().Where(t => t.IsReadOnly).Select(t => t.Text).ToList(); + var result = page.Editor.Equation; + window.Close(); + return (result, texts.FirstOrDefault()); + }); + await Assert.That(equation).IsEqualTo(Equation.Parse("sin(x)^2+cos(x)^2=1", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + await Assert.That(unicodeMath).IsNotNull(); + } + } +} +#endif diff --git a/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj b/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj index 6589c67..a7fd0cd 100644 --- a/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj +++ b/tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj @@ -19,8 +19,9 @@ - + +