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 @@
-
+
+