Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9a67d399 | ||
|
|
307c803246 | ||
|
|
d16cd2db72 | ||
|
|
cd490dd9a7 |
No files matched your search
Generated
+1
-1
@@ -1540,7 +1540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"android_logger",
|
"android_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
|
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
|
||||||
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
||||||
|
|
||||||
|
### Formeln ausrechnen (experimentell)
|
||||||
|
- Einschalten im ☰-Menü unter Eingabe („Formeln nach „=“ ausrechnen“). Nach einem handgeschriebenen `=` und einer kurzen Pause erscheint daneben das Ergebnis samt dem Gelesenen, z. B. `2⁴ = 16` oder `(x + 3)² = x² + 6x + 9`; Antippen mit dem Stift oder Klicken fügt es als Textfeld in der Stiftfarbe und in der Höhe der Handschrift ein (Rückgängig entfernt es). Finger und Handballen lösen nichts aus.
|
||||||
|
- Gelesen werden Ziffern, `+ − · : /`, Klammern, Hochzahlen (hochgestellt geschrieben), Bruchstriche, Dezimalkommas und die Variablen `x`, `y`, `a`, `b`. Zahlen werden exakt als Brüche gerechnet, Ausdrücke mit Variablen ausmultipliziert. Vor der Formel etwas Abstand lassen, damit weiter links Geschriebenes nicht mitgelesen wird; in einer Kette wie `x = 2 · 3 =` wird der Teil nach dem vorigen `=` gerechnet.
|
||||||
|
- Die Zeichen werden mit Punktwolken-Vorlagen ($P) erkannt, ohne Machine Learning und offline. Zeigt der Vorschlag ein falsches Zeichen, ihn einfach ignorieren.
|
||||||
|
|
||||||
### Auswählen und Bearbeiten
|
### Auswählen und Bearbeiten
|
||||||
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
|
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
|
||||||
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
|
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
|
||||||
@@ -191,6 +196,9 @@ src/
|
|||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
||||||
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
||||||
├── library.rs # Ordner der Notizbücher (library.json)
|
├── library.rs # Ordner der Notizbücher (library.json)
|
||||||
|
├── math.rs # Exaktes Ausrechnen handgeschriebener Formeln (Issue #46)
|
||||||
|
├── math_ink.rs # Erkennung handgeschriebener Formeln ($P-Punktwolken)
|
||||||
|
├── math_assist.rs # Ergebnis neben einem handgeschriebenen „=“ (Auslöser, Vorschlag)
|
||||||
├── autosave.rs # Speichern im Hintergrund
|
├── autosave.rs # Speichern im Hintergrund
|
||||||
├── export.rs # PDF-Export
|
├── export.rs # PDF-Export
|
||||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- Recognized shapes: lines, arrows, circles, ellipses, rectangles, triangles, rhombuses, polygons, coordinate systems (L shape), axis crosses (a "+" in one stroke or two crossing straight lines, arrowheads on all four ends) and octahedra (sketch as a 3D solid).
|
- Recognized shapes: lines, arrows, circles, ellipses, rectangles, triangles, rhombuses, polygons, coordinate systems (L shape), axis crosses (a "+" in one stroke or two crossing straight lines, arrowheads on all four ends) and octahedra (sketch as a 3D solid).
|
||||||
- Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
|
- Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
|
||||||
|
|
||||||
|
### Computing formulas (experimental)
|
||||||
|
- Turn on in the ☰ menu under input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as a text box in the ink color, as high as the handwriting (undo removes it). Fingers and palms do not trigger it.
|
||||||
|
- Read are digits, `+ − · : /`, parentheses, exponents (written raised), fraction bars, decimal commas and the variables `x`, `y`, `a`, `b`. Numbers are computed exactly as fractions, expressions with variables are multiplied out. Leave some space before the formula, so that writing further to the left is not read with it; in a chain such as `x = 2 · 3 =` the part after the previous `=` is computed.
|
||||||
|
- The symbols are recognized with point-cloud templates ($P), without machine learning and offline. If the suggestion shows a wrong symbol, just ignore it.
|
||||||
|
|
||||||
### Selecting and editing
|
### Selecting and editing
|
||||||
- **Selection tool** (`V`): tap or drag a frame, then move, scale by the corners, rotate by the handle, or rotate by 90° with `R`. Works with mouse, finger and pen.
|
- **Selection tool** (`V`): tap or drag a frame, then move, scale by the corners, rotate by the handle, or rotate by 90° with `R`. Works with mouse, finger and pen.
|
||||||
- **Recolor** the selection with the palette, **copy/paste** (`Ctrl + C/V`), **delete** (`Del`).
|
- **Recolor** the selection with the palette, **copy/paste** (`Ctrl + C/V`), **delete** (`Del`).
|
||||||
@@ -193,6 +198,9 @@ src/
|
|||||||
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
||||||
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
||||||
├── library.rs # Notebook folders (library.json)
|
├── library.rs # Notebook folders (library.json)
|
||||||
|
├── math.rs # Exact evaluation of handwritten math (Issue #46)
|
||||||
|
├── math_ink.rs # Recognition of handwritten math ($P point clouds)
|
||||||
|
├── math_assist.rs # Result next to a handwritten "=" (trigger, suggestion)
|
||||||
├── autosave.rs # Saving in the background
|
├── autosave.rs # Saving in the background
|
||||||
├── export.rs # PDF export
|
├── export.rs # PDF export
|
||||||
├── page_export.rs # Page as PNG/SVG, selection as image
|
├── page_export.rs # Page as PNG/SVG, selection as image
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.41.0" date="2026-10-28">
|
||||||
|
<description>
|
||||||
|
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.40.0" date="2026-10-28">
|
<release version="0.40.0" date="2026-10-28">
|
||||||
<description>
|
<description>
|
||||||
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.41.0" date="2026-10-28">
|
||||||
|
<description>
|
||||||
|
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.40.0" date="2026-10-28">
|
<release version="0.40.0" date="2026-10-28">
|
||||||
<description>
|
<description>
|
||||||
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Computing formulas (experimental)**: Turn on in ☰ → Input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as text in the ink color and as high as the handwriting. Digits, `+ − · : /`, parentheses, exponents, fraction bars, decimal commas and `x`, `y`, `a`, `b` are read; numbers are computed exactly, expressions with variables multiplied out. Recognition runs offline without machine learning – feedback with real handwriting welcome (Issue #46)
|
||||||
- **Android**: `ink-notes_<version>_arm64.apk` brings ink-notes to Android 8 and newer (64-bit ARM) – download it on the device and open it to install. The pen is told apart from fingers (pressure, pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks live in the app's own storage and are removed when uninstalling; updates are installed by opening the new APK. Nothing changes on Linux and Windows
|
- **Android**: `ink-notes_<version>_arm64.apk` brings ink-notes to Android 8 and newer (64-bit ARM) – download it on the device and open it to install. The pen is told apart from fingers (pressure, pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks live in the app's own storage and are removed when uninstalling; updates are installed by opening the new APK. Nothing changes on Linux and Windows
|
||||||
- **Battery drain fixed**: ink-notes redrew its window at full frame rate even when nothing happened, which kept the app and the desktop (e.g. gnome-shell) busy and drained the battery. It now only draws when something changes; idle CPU load drops to practically zero. Also applies to Windows. Internally, the Android version now follows the app lifecycle and can be packed into an APK (not part of the release yet)
|
- **Battery drain fixed**: ink-notes redrew its window at full frame rate even when nothing happened, which kept the app and the desktop (e.g. gnome-shell) busy and drained the battery. It now only draws when something changes; idle CPU load drops to practically zero. Also applies to Windows. Internally, the Android version now follows the app lifecycle and can be packed into an APK (not part of the release yet)
|
||||||
- **PDF export on current lopdf**: The PDF export uses its own small PDF writer on top of lopdf 0.46 instead of the outdated printpdf 0.7 (which pinned lopdf 0.31). Exported PDFs look the same – layers, searchable text, images, highlighters and bookmarks stay as before
|
- **PDF export on current lopdf**: The PDF export uses its own small PDF writer on top of lopdf 0.46 instead of the outdated printpdf 0.7 (which pinned lopdf 0.31). Exported PDFs look the same – layers, searchable text, images, highlighters and bookmarks stay as before
|
||||||
|
|||||||
+115
-5
@@ -335,7 +335,7 @@ fn commit_stroke_to_notebook(
|
|||||||
notebook: &mut Notebook,
|
notebook: &mut Notebook,
|
||||||
manifest: &mut NotebookManifest,
|
manifest: &mut NotebookManifest,
|
||||||
autosave: &mut AutosaveController,
|
autosave: &mut AutosaveController,
|
||||||
ui_state: &UiState,
|
ui_state: &mut UiState,
|
||||||
window: &Window,
|
window: &Window,
|
||||||
snapped_shape: Option<RecognizedShape>,
|
snapped_shape: Option<RecognizedShape>,
|
||||||
) {
|
) {
|
||||||
@@ -357,6 +357,7 @@ fn commit_stroke_to_notebook(
|
|||||||
let page = notebook.current_page_mut();
|
let page = notebook.current_page_mut();
|
||||||
page.replace_stroke(previous_id, stroke);
|
page.replace_stroke(previous_id, stroke);
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
|
ui_state.math.stroke_committed(page, Instant::now());
|
||||||
autosave.mark_dirty(notebook, manifest);
|
autosave.mark_dirty(notebook, manifest);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
return;
|
return;
|
||||||
@@ -383,6 +384,8 @@ fn commit_stroke_to_notebook(
|
|||||||
let page = notebook.current_page_mut();
|
let page = notebook.current_page_mut();
|
||||||
page.add_stroke(stroke);
|
page.add_stroke(stroke);
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
|
// An `=` may have been completed (Issue #46)
|
||||||
|
ui_state.math.stroke_committed(page, Instant::now());
|
||||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
autosave.mark_dirty(notebook, manifest);
|
autosave.mark_dirty(notebook, manifest);
|
||||||
// Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter
|
// Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter
|
||||||
@@ -915,6 +918,54 @@ fn insert_text_on_page(
|
|||||||
ui_state.set_export_message(t!("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"));
|
ui_state.set_export_message(t!("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts the result of a math suggestion as a text box next to its `=`, in the ink color
|
||||||
|
/// and as high as the handwriting, as one undo step (Issue #46)
|
||||||
|
fn insert_math_result(
|
||||||
|
suggestion: &crate::math_assist::Suggestion,
|
||||||
|
canvas: &mut CanvasState,
|
||||||
|
notebook: &mut Notebook,
|
||||||
|
manifest: &mut NotebookManifest,
|
||||||
|
autosave: &mut AutosaveController,
|
||||||
|
) {
|
||||||
|
let page = notebook.current_page();
|
||||||
|
if page.id != suggestion.page_id || page.is_readonly() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font = crate::text::TextFont::Sans;
|
||||||
|
let ascent_per_em = crate::text::TextBox::new(String::new(), 1.0, font, 1.0)
|
||||||
|
.layout()
|
||||||
|
.ascent;
|
||||||
|
let (origin, size) = suggestion.text_placement(ascent_per_em);
|
||||||
|
let mut edit = crate::text::TextEditing::new_at(
|
||||||
|
page.id,
|
||||||
|
origin,
|
||||||
|
page.text_right_edge(origin.x),
|
||||||
|
size,
|
||||||
|
font,
|
||||||
|
suggestion.color,
|
||||||
|
);
|
||||||
|
edit.origin = origin;
|
||||||
|
edit.text.content = suggestion.result.clone();
|
||||||
|
// As wide as the result, so that it does not wrap near the edge of the page
|
||||||
|
edit.text.wrap_width = edit
|
||||||
|
.text
|
||||||
|
.layout()
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| line.width)
|
||||||
|
.fold(0.0, f32::max)
|
||||||
|
+ size;
|
||||||
|
let id = notebook.next_stroke_id();
|
||||||
|
let Some(stroke) = edit.to_stroke(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
page.add_stroke(stroke);
|
||||||
|
canvas.sync_page(page);
|
||||||
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
|
autosave.mark_dirty(notebook, manifest);
|
||||||
|
}
|
||||||
|
|
||||||
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
|
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
|
||||||
/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite.
|
/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite.
|
||||||
fn finish_text_edit(
|
fn finish_text_edit(
|
||||||
@@ -1419,6 +1470,7 @@ pub fn run_with(
|
|||||||
ui_state.pen_presets = ui_settings.pen_presets;
|
ui_state.pen_presets = ui_settings.pen_presets;
|
||||||
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
|
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
|
||||||
ui_state.set_eraser_radius_mm(ui_settings.eraser_radius_mm);
|
ui_state.set_eraser_radius_mm(ui_settings.eraser_radius_mm);
|
||||||
|
ui_state.math.set_enabled(ui_settings.math_assist);
|
||||||
ui_state.select_pen(ui_settings.active_pen);
|
ui_state.select_pen(ui_settings.active_pen);
|
||||||
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||||
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||||
@@ -1864,7 +1916,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
stroke_snapped_shape.take(),
|
stroke_snapped_shape.take(),
|
||||||
);
|
);
|
||||||
@@ -2048,6 +2100,26 @@ pub fn run_with(
|
|||||||
stylus_tool_id = Some(tool);
|
stylus_tool_id = Some(tool);
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
|
||||||
|
// Tapping a math suggestion inserts its result (Issue #46)
|
||||||
|
if let Some(suggestion) = (!ui_state.view_mode)
|
||||||
|
.then(|| {
|
||||||
|
ui_state.math.take_hit(egui::pos2(sx / scale, sy / scale))
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
insert_math_result(
|
||||||
|
&suggestion,
|
||||||
|
&mut canvas,
|
||||||
|
&mut notebook,
|
||||||
|
&mut manifest,
|
||||||
|
&mut autosave,
|
||||||
|
);
|
||||||
|
// The rest of this tap goes nowhere
|
||||||
|
stylus_ui_capture = true;
|
||||||
|
window.request_redraw();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||||
{
|
{
|
||||||
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||||
@@ -2275,7 +2347,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
snapped_shape,
|
snapped_shape,
|
||||||
);
|
);
|
||||||
@@ -2351,6 +2423,16 @@ pub fn run_with(
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pause after an `=` is over: compute the expression (Issue #46)
|
||||||
|
if ui_state.math.update(
|
||||||
|
notebook.current_page(),
|
||||||
|
Instant::now(),
|
||||||
|
is_drawing || is_stylus_drawing,
|
||||||
|
crate::i18n::language() == crate::i18n::Language::German,
|
||||||
|
) {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// Nachgleiten nach dem Wischen
|
// Nachgleiten nach dem Wischen
|
||||||
if let Some(delta) = fling.step(Instant::now()) {
|
if let Some(delta) = fling.step(Instant::now()) {
|
||||||
canvas.pan(delta.x, delta.y);
|
canvas.pan(delta.x, delta.y);
|
||||||
@@ -2377,6 +2459,7 @@ pub fn run_with(
|
|||||||
} else if let Some(at) = [
|
} else if let Some(at) = [
|
||||||
egui_repaint_at,
|
egui_repaint_at,
|
||||||
long_press.deadline(),
|
long_press.deadline(),
|
||||||
|
ui_state.math.deadline(),
|
||||||
Some(disk_watch.deadline()),
|
Some(disk_watch.deadline()),
|
||||||
// The updater thread does not wake the loop: look for its results now and then
|
// The updater thread does not wake the loop: look for its results now and then
|
||||||
matches!(
|
matches!(
|
||||||
@@ -3176,6 +3259,25 @@ pub fn run_with(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clicking a math suggestion inserts its result (Issue #46)
|
||||||
|
if button == MouseButton::Left && !ui_state.view_mode {
|
||||||
|
let (sx, sy) = cursor_screen_pos;
|
||||||
|
let scale = window.scale_factor() as f32;
|
||||||
|
if let Some(suggestion) =
|
||||||
|
ui_state.math.take_hit(egui::pos2(sx / scale, sy / scale))
|
||||||
|
{
|
||||||
|
insert_math_result(
|
||||||
|
&suggestion,
|
||||||
|
&mut canvas,
|
||||||
|
&mut notebook,
|
||||||
|
&mut manifest,
|
||||||
|
&mut autosave,
|
||||||
|
);
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fling.stop();
|
fling.stop();
|
||||||
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
||||||
// In view-only mode the left and right buttons move the page too (Issue #35)
|
// In view-only mode the left and right buttons move the page too (Issue #35)
|
||||||
@@ -3380,7 +3482,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
snapped_shape,
|
snapped_shape,
|
||||||
);
|
);
|
||||||
@@ -3958,7 +4060,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
snapped_shape,
|
snapped_shape,
|
||||||
);
|
);
|
||||||
@@ -5187,6 +5289,14 @@ pub fn run_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::SetMathAssist(on) => {
|
||||||
|
ui_settings.math_assist = on;
|
||||||
|
save_ui_settings(
|
||||||
|
&ui_settings,
|
||||||
|
ui_settings_path.as_deref(),
|
||||||
|
&mut ui_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
UiAction::SetSidebarSide(side) => {
|
UiAction::SetSidebarSide(side) => {
|
||||||
ui_settings.sidebar_side = side;
|
ui_settings.sidebar_side = side;
|
||||||
save_ui_settings(
|
save_ui_settings(
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
||||||
("Fehler: {err}", "Error: {err}"),
|
("Fehler: {err}", "Error: {err}"),
|
||||||
("Fehlermeldung: {message}", "Error message: {message}"),
|
("Fehlermeldung: {message}", "Error message: {message}"),
|
||||||
|
("Formeln nach „=“ ausrechnen", "Compute formulas after “=”"),
|
||||||
("Formen (Shape)", "Shapes"),
|
("Formen (Shape)", "Shapes"),
|
||||||
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
|
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
|
||||||
("Fünfeck (Pentagon)", "Pentagon"),
|
("Fünfeck (Pentagon)", "Pentagon"),
|
||||||
@@ -186,6 +187,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Mindestdruck", "Minimum pressure"),
|
("Mindestdruck", "Minimum pressure"),
|
||||||
("Mit dem Finger zeichnen", "Draw with finger"),
|
("Mit dem Finger zeichnen", "Draw with finger"),
|
||||||
("Mittel", "Medium"),
|
("Mittel", "Medium"),
|
||||||
|
("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b.", "After a handwritten “=” the result appears next to it, e.g. 2⁴ = 16. Tapping it with the pen or clicking inserts it as text.\nDigits, + − · : /, parentheses, exponents, fractions and x, y, a, b are read."),
|
||||||
("Name …", "Name …"),
|
("Name …", "Name …"),
|
||||||
("Neon-Gelb", "Neon yellow"),
|
("Neon-Gelb", "Neon yellow"),
|
||||||
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
|
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ pub mod i18n;
|
|||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod math;
|
||||||
|
pub mod math_assist;
|
||||||
|
pub mod math_ink;
|
||||||
pub mod osk;
|
pub mod osk;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
|||||||
+757
@@ -0,0 +1,757 @@
|
|||||||
|
//! Exact evaluation of handwritten math (Issue #46, stage 1).
|
||||||
|
//!
|
||||||
|
//! Takes the text of a recognised expression such as `2^4`, `2,5 · 3` or `(x + 3)²`
|
||||||
|
//! (optionally ending with `=`) and returns its result as text: `16`, `7,5` and
|
||||||
|
//! `x² + 6x + 9`. Numbers are exact fractions, so `1 : 3 · 3` gives `1`, and expressions
|
||||||
|
//! with variables are expanded into polynomials.
|
||||||
|
//!
|
||||||
|
//! The input is lenient towards how math is written by hand: decimal comma or point,
|
||||||
|
//! `·`, `×`, `*` and `:`, `÷`, `/`, superscript exponents, Unicode minus signs and implicit
|
||||||
|
//! multiplication (`2x`, `3(x + 1)`, `(a + b)(a − b)`).
|
||||||
|
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
/// Largest exponent for a polynomial that is not a plain number
|
||||||
|
const MAX_POLYNOMIAL_EXPONENT: u32 = 32;
|
||||||
|
/// Largest exponent for a plain number
|
||||||
|
const MAX_NUMBER_EXPONENT: u32 = 1000;
|
||||||
|
/// Results with more terms are not shown (they would not fit next to the `=` anyway)
|
||||||
|
const MAX_TERMS: usize = 64;
|
||||||
|
/// Terminating decimals with more digits after the separator are shown as fractions
|
||||||
|
const MAX_DECIMALS: u32 = 12;
|
||||||
|
|
||||||
|
/// Computes the result of `expression`, written for display after its `=` sign.
|
||||||
|
///
|
||||||
|
/// `decimal_comma` selects the decimal separator of the result. Returns `None` if the
|
||||||
|
/// expression cannot be read or computed (e.g. division by zero, a variable in a
|
||||||
|
/// denominator or overflow), and also if there is nothing to compute, i.e. the result would
|
||||||
|
/// just repeat the input (`5`, `x`, `2x`).
|
||||||
|
pub fn solve(expression: &str, decimal_comma: bool) -> Option<String> {
|
||||||
|
let tokens = tokenize(expression)?;
|
||||||
|
let ast = Parser::new(&tokens).parse()?;
|
||||||
|
if matches!(ast, Expr::Number(_) | Expr::Variable(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let result = ast.eval()?;
|
||||||
|
if result.terms.len() > MAX_TERMS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = result.format(decimal_comma);
|
||||||
|
let compact = |s: &str| -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.map(|c| if c == '−' { '-' } else { c })
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if compact(&text) == compact(expression.trim().trim_end_matches('=')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `expression` written neatly for showing it, e.g. `2⁴` for `2^(4)`, `(x + 1)/2` for
|
||||||
|
/// `((x+1)/(2))` or `2,5 · 3` for `2,5*3`. `None` if it cannot be read.
|
||||||
|
pub fn display(expression: &str, decimal_comma: bool) -> Option<String> {
|
||||||
|
let tokens = tokenize(expression)?;
|
||||||
|
let ast = Parser::new(&tokens).parse()?;
|
||||||
|
Some(ast.display(decimal_comma))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rational numbers ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Exact fraction `num / den` with `den > 0`, always reduced
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct Rational {
|
||||||
|
num: i128,
|
||||||
|
den: i128,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gcd(mut a: i128, mut b: i128) -> i128 {
|
||||||
|
a = a.abs();
|
||||||
|
b = b.abs();
|
||||||
|
while b != 0 {
|
||||||
|
(a, b) = (b, a % b);
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rational {
|
||||||
|
const ZERO: Rational = Rational { num: 0, den: 1 };
|
||||||
|
const ONE: Rational = Rational { num: 1, den: 1 };
|
||||||
|
|
||||||
|
fn new(num: i128, den: i128) -> Option<Rational> {
|
||||||
|
// i128::MIN has no positive counterpart, which `gcd` and `neg` need
|
||||||
|
if den == 0 || num == i128::MIN || den == i128::MIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let g = gcd(num, den).max(1);
|
||||||
|
let (mut num, mut den) = (num / g, den / g);
|
||||||
|
if den < 0 {
|
||||||
|
num = num.checked_neg()?;
|
||||||
|
den = den.checked_neg()?;
|
||||||
|
}
|
||||||
|
Some(Rational { num, den })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn integer(n: i128) -> Rational {
|
||||||
|
Rational { num: n, den: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_zero(self) -> bool {
|
||||||
|
self.num == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(self, other: Rational) -> Option<Rational> {
|
||||||
|
let g = gcd(self.den, other.den);
|
||||||
|
let den = (self.den / g).checked_mul(other.den)?;
|
||||||
|
let num = self
|
||||||
|
.num
|
||||||
|
.checked_mul(other.den / g)?
|
||||||
|
.checked_add(other.num.checked_mul(self.den / g)?)?;
|
||||||
|
Rational::new(num, den)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neg(self) -> Option<Rational> {
|
||||||
|
Some(Rational {
|
||||||
|
num: self.num.checked_neg()?,
|
||||||
|
den: self.den,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(self, other: Rational) -> Option<Rational> {
|
||||||
|
// Cross-reducing first keeps the intermediate products small
|
||||||
|
let g1 = gcd(self.num, other.den).max(1);
|
||||||
|
let g2 = gcd(other.num, self.den).max(1);
|
||||||
|
let num = (self.num / g1).checked_mul(other.num / g2)?;
|
||||||
|
let den = (self.den / g2).checked_mul(other.den / g1)?;
|
||||||
|
Rational::new(num, den)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recip(self) -> Option<Rational> {
|
||||||
|
Rational::new(self.den, self.num)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pow(self, exponent: i128) -> Option<Rational> {
|
||||||
|
if exponent.unsigned_abs() > MAX_NUMBER_EXPONENT as u128 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = if exponent < 0 { self.recip()? } else { self };
|
||||||
|
let mut result = Rational::ONE;
|
||||||
|
for _ in 0..exponent.unsigned_abs() {
|
||||||
|
result = result.mul(base)?;
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_integer(self) -> Option<i128> {
|
||||||
|
(self.den == 1).then_some(self.num)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number without its sign: as a terminating decimal if it has one with at most
|
||||||
|
/// [`MAX_DECIMALS`] digits, otherwise as a fraction `p/q`.
|
||||||
|
fn format_abs(self, decimal_comma: bool) -> String {
|
||||||
|
let num = self.num.unsigned_abs();
|
||||||
|
let den = self.den.unsigned_abs();
|
||||||
|
let whole = num / den;
|
||||||
|
let mut rest = num % den;
|
||||||
|
if rest == 0 {
|
||||||
|
return whole.to_string();
|
||||||
|
}
|
||||||
|
let mut reduced = den;
|
||||||
|
let mut decimals = 0;
|
||||||
|
for factor in [2, 5] {
|
||||||
|
while reduced.is_multiple_of(factor) {
|
||||||
|
reduced /= factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reduced == 1 {
|
||||||
|
let mut digits = String::new();
|
||||||
|
while rest != 0 && decimals <= MAX_DECIMALS {
|
||||||
|
let Some(shifted) = rest.checked_mul(10) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
rest = shifted;
|
||||||
|
digits.push(char::from(b'0' + (rest / den) as u8));
|
||||||
|
rest %= den;
|
||||||
|
decimals += 1;
|
||||||
|
}
|
||||||
|
if rest == 0 {
|
||||||
|
let separator = if decimal_comma { ',' } else { '.' };
|
||||||
|
return format!("{whole}{separator}{digits}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("{num}/{den}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Polynomials --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Product of variables with their exponents, e.g. `a²b` = `{a: 2, b: 1}`
|
||||||
|
type Monomial = BTreeMap<char, u32>;
|
||||||
|
|
||||||
|
/// Sum of monomials with exact coefficients; a plain number is the empty monomial
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Polynomial {
|
||||||
|
terms: BTreeMap<Monomial, Rational>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Polynomial {
|
||||||
|
fn constant(value: Rational) -> Polynomial {
|
||||||
|
let mut terms = BTreeMap::new();
|
||||||
|
if !value.is_zero() {
|
||||||
|
terms.insert(Monomial::new(), value);
|
||||||
|
}
|
||||||
|
Polynomial { terms }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variable(name: char) -> Polynomial {
|
||||||
|
let mut terms = BTreeMap::new();
|
||||||
|
terms.insert(Monomial::from([(name, 1)]), Rational::ONE);
|
||||||
|
Polynomial { terms }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value if this is a plain number
|
||||||
|
fn as_constant(&self) -> Option<Rational> {
|
||||||
|
match self.terms.len() {
|
||||||
|
0 => Some(Rational::ZERO),
|
||||||
|
1 => self.terms.get(&Monomial::new()).copied(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_term(&mut self, monomial: Monomial, coefficient: Rational) -> Option<()> {
|
||||||
|
let sum = match self.terms.get(&monomial) {
|
||||||
|
Some(existing) => existing.add(coefficient)?,
|
||||||
|
None => coefficient,
|
||||||
|
};
|
||||||
|
if sum.is_zero() {
|
||||||
|
self.terms.remove(&monomial);
|
||||||
|
} else {
|
||||||
|
self.terms.insert(monomial, sum);
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(mut self, other: &Polynomial) -> Option<Polynomial> {
|
||||||
|
for (monomial, coefficient) in &other.terms {
|
||||||
|
self.add_term(monomial.clone(), *coefficient)?;
|
||||||
|
}
|
||||||
|
Some(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neg(&self) -> Option<Polynomial> {
|
||||||
|
let mut terms = BTreeMap::new();
|
||||||
|
for (monomial, coefficient) in &self.terms {
|
||||||
|
terms.insert(monomial.clone(), coefficient.neg()?);
|
||||||
|
}
|
||||||
|
Some(Polynomial { terms })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(&self, other: &Polynomial) -> Option<Polynomial> {
|
||||||
|
let mut product = Polynomial::constant(Rational::ZERO);
|
||||||
|
for (m1, c1) in &self.terms {
|
||||||
|
for (m2, c2) in &other.terms {
|
||||||
|
let mut monomial = m1.clone();
|
||||||
|
for (&name, &exponent) in m2 {
|
||||||
|
let e = monomial.entry(name).or_insert(0);
|
||||||
|
*e = e.checked_add(exponent)?;
|
||||||
|
}
|
||||||
|
product.add_term(monomial, c1.mul(*c2)?)?;
|
||||||
|
}
|
||||||
|
if product.terms.len() > MAX_TERMS * 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn div(&self, divisor: &Polynomial) -> Option<Polynomial> {
|
||||||
|
let factor = divisor.as_constant()?.recip()?;
|
||||||
|
self.mul(&Polynomial::constant(factor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pow(&self, exponent: &Polynomial) -> Option<Polynomial> {
|
||||||
|
let exponent = exponent.as_constant()?.as_integer()?;
|
||||||
|
if let Some(base) = self.as_constant() {
|
||||||
|
return Some(Polynomial::constant(base.pow(exponent)?));
|
||||||
|
}
|
||||||
|
if !(0..=MAX_POLYNOMIAL_EXPONENT as i128).contains(&exponent) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut result = Polynomial::constant(Rational::ONE);
|
||||||
|
for _ in 0..exponent {
|
||||||
|
result = result.mul(self)?;
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// E.g. `x² + 6x + 9`, `a² − b²`, `7,5`: highest degree first, then in alphabetical
|
||||||
|
/// order of the variables (`a² + 2ab + b²`).
|
||||||
|
fn format(&self, decimal_comma: bool) -> String {
|
||||||
|
if self.terms.is_empty() {
|
||||||
|
return "0".to_owned();
|
||||||
|
}
|
||||||
|
let degree = |m: &Monomial| m.values().sum::<u32>();
|
||||||
|
let mut terms: Vec<_> = self.terms.iter().collect();
|
||||||
|
terms.sort_by(|(a, _), (b, _)| degree(b).cmp(°ree(a)).then_with(|| graded_order(a, b)));
|
||||||
|
let mut out = String::new();
|
||||||
|
for (index, (monomial, coefficient)) in terms.into_iter().enumerate() {
|
||||||
|
let negative = coefficient.num < 0;
|
||||||
|
match (index, negative) {
|
||||||
|
(0, true) => out.push('−'),
|
||||||
|
(0, false) => {}
|
||||||
|
(_, true) => out.push_str(" − "),
|
||||||
|
(_, false) => out.push_str(" + "),
|
||||||
|
}
|
||||||
|
let magnitude = coefficient.format_abs(decimal_comma);
|
||||||
|
if monomial.is_empty() {
|
||||||
|
out.push_str(&magnitude);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if magnitude.contains('/') {
|
||||||
|
let _ = write!(out, "({magnitude})");
|
||||||
|
} else if magnitude != "1" {
|
||||||
|
out.push_str(&magnitude);
|
||||||
|
}
|
||||||
|
for (&name, &exponent) in monomial {
|
||||||
|
out.push(name);
|
||||||
|
if exponent > 1 {
|
||||||
|
out.push_str(&superscript(exponent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order within the same degree: the monomial with the higher power of the alphabetically
|
||||||
|
/// first variable comes first (`a²`, `ab`, `b²`).
|
||||||
|
fn graded_order(a: &Monomial, b: &Monomial) -> Ordering {
|
||||||
|
let names: std::collections::BTreeSet<char> = a.keys().chain(b.keys()).copied().collect();
|
||||||
|
for name in names {
|
||||||
|
let ea = a.get(&name).copied().unwrap_or(0);
|
||||||
|
let eb = b.get(&name).copied().unwrap_or(0);
|
||||||
|
match eb.cmp(&ea) {
|
||||||
|
Ordering::Equal => continue,
|
||||||
|
other => return other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ordering::Equal
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPERSCRIPT_DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
||||||
|
|
||||||
|
fn superscript(n: u32) -> String {
|
||||||
|
n.to_string()
|
||||||
|
.chars()
|
||||||
|
.map(|d| SUPERSCRIPT_DIGITS[d.to_digit(10).unwrap_or(0) as usize])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reading the expression ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Token {
|
||||||
|
Number(Rational),
|
||||||
|
Variable(char),
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Times,
|
||||||
|
/// `/` for a fraction, `:` for a division sign (`:` and `÷`)
|
||||||
|
Divide(char),
|
||||||
|
Power,
|
||||||
|
Open,
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokenize(input: &str) -> Option<Vec<Token>> {
|
||||||
|
let input = input.trim().trim_end_matches('=').trim_end();
|
||||||
|
let chars: Vec<char> = input.chars().collect();
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
let (value, next) = read_number(&chars, i)?;
|
||||||
|
tokens.push(Token::Number(value));
|
||||||
|
i = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if superscript_value(c).is_some() || c == '⁻' {
|
||||||
|
// A run of superscript characters is the exponent: x²³ = x^23, 2⁻¹ = 2^(-1)
|
||||||
|
let negative = c == '⁻';
|
||||||
|
let start = if negative { i + 1 } else { i };
|
||||||
|
let mut end = start;
|
||||||
|
let mut value: i128 = 0;
|
||||||
|
while let Some(digit) = chars.get(end).and_then(|&d| superscript_value(d)) {
|
||||||
|
value = value.checked_mul(10)?.checked_add(digit)?;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end == start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tokens.push(Token::Power);
|
||||||
|
if negative {
|
||||||
|
tokens.push(Token::Minus);
|
||||||
|
}
|
||||||
|
tokens.push(Token::Number(Rational::integer(value)));
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let token = match c {
|
||||||
|
_ if c.is_whitespace() => None,
|
||||||
|
'+' => Some(Token::Plus),
|
||||||
|
'-' | '−' | '–' => Some(Token::Minus),
|
||||||
|
'*' | '·' | '⋅' | '∙' | '×' => Some(Token::Times),
|
||||||
|
'/' => Some(Token::Divide('/')),
|
||||||
|
':' | '÷' => Some(Token::Divide(':')),
|
||||||
|
'^' => Some(Token::Power),
|
||||||
|
'(' | '[' | '{' => Some(Token::Open),
|
||||||
|
')' | ']' | '}' => Some(Token::Close),
|
||||||
|
_ if c.is_ascii_alphabetic() => Some(Token::Variable(c)),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
tokens.extend(token);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Some(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn superscript_value(c: char) -> Option<i128> {
|
||||||
|
SUPERSCRIPT_DIGITS
|
||||||
|
.iter()
|
||||||
|
.position(|&d| d == c)
|
||||||
|
.map(|p| p as i128)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `12`, `2,5` or `0.125` from `start`; returns the value and the index after it
|
||||||
|
fn read_number(chars: &[char], start: usize) -> Option<(Rational, usize)> {
|
||||||
|
let mut i = start;
|
||||||
|
let mut num: i128 = 0;
|
||||||
|
let mut den: i128 = 1;
|
||||||
|
let mut in_fraction = false;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if let Some(digit) = c.to_digit(10) {
|
||||||
|
num = num.checked_mul(10)?.checked_add(digit as i128)?;
|
||||||
|
if in_fraction {
|
||||||
|
den = den.checked_mul(10)?;
|
||||||
|
}
|
||||||
|
} else if (c == ',' || c == '.')
|
||||||
|
&& !in_fraction
|
||||||
|
&& chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())
|
||||||
|
{
|
||||||
|
in_fraction = true;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Some((Rational::new(num, den)?, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Expr {
|
||||||
|
Number(Rational),
|
||||||
|
Variable(char),
|
||||||
|
Neg(Box<Expr>),
|
||||||
|
Add(Box<Expr>, Box<Expr>),
|
||||||
|
Sub(Box<Expr>, Box<Expr>),
|
||||||
|
Mul(Box<Expr>, Box<Expr>),
|
||||||
|
/// With the sign it was written with, for [`display`]
|
||||||
|
Div(Box<Expr>, Box<Expr>, char),
|
||||||
|
Pow(Box<Expr>, Box<Expr>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Expr {
|
||||||
|
fn eval(&self) -> Option<Polynomial> {
|
||||||
|
Some(match self {
|
||||||
|
Expr::Number(value) => Polynomial::constant(*value),
|
||||||
|
Expr::Variable(name) => Polynomial::variable(*name),
|
||||||
|
Expr::Neg(e) => e.eval()?.neg()?,
|
||||||
|
Expr::Add(a, b) => a.eval()?.add(&b.eval()?)?,
|
||||||
|
Expr::Sub(a, b) => a.eval()?.add(&b.eval()?.neg()?)?,
|
||||||
|
Expr::Mul(a, b) => a.eval()?.mul(&b.eval()?)?,
|
||||||
|
Expr::Div(a, b, _) => a.eval()?.div(&b.eval()?)?,
|
||||||
|
Expr::Pow(a, b) => a.eval()?.pow(&b.eval()?)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Expr {
|
||||||
|
/// Binding strength for deciding on parentheses: sums < products < signs < powers < atoms
|
||||||
|
fn precedence(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Expr::Add(..) | Expr::Sub(..) => 1,
|
||||||
|
Expr::Mul(..) | Expr::Div(..) => 2,
|
||||||
|
Expr::Neg(_) => 3,
|
||||||
|
Expr::Pow(..) => 4,
|
||||||
|
Expr::Number(_) | Expr::Variable(_) => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of this expression, in parentheses if it binds weaker than `min`
|
||||||
|
fn display_at(&self, min: u8, decimal_comma: bool) -> String {
|
||||||
|
let text = self.display(decimal_comma);
|
||||||
|
if self.precedence() < min {
|
||||||
|
format!("({text})")
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display(&self, decimal_comma: bool) -> String {
|
||||||
|
let dc = decimal_comma;
|
||||||
|
match self {
|
||||||
|
Expr::Number(value) => value.format_abs(dc),
|
||||||
|
Expr::Variable(name) => name.to_string(),
|
||||||
|
Expr::Neg(e) => format!("−{}", e.display_at(3, dc)),
|
||||||
|
Expr::Add(a, b) => format!("{} + {}", a.display_at(1, dc), b.display_at(1, dc)),
|
||||||
|
Expr::Sub(a, b) => format!("{} − {}", a.display_at(1, dc), b.display_at(2, dc)),
|
||||||
|
Expr::Mul(a, b) => {
|
||||||
|
let left = a.display_at(2, dc);
|
||||||
|
let right = b.display_at(3, dc);
|
||||||
|
// 2x, 3(x + 1), x², but 2 · 3 and x · 2
|
||||||
|
let implicit = !matches!(**a, Expr::Neg(_))
|
||||||
|
&& (matches!(**b, Expr::Variable(_))
|
||||||
|
|| matches!(&**b, Expr::Pow(base, _) if matches!(**base, Expr::Variable(_)))
|
||||||
|
|| right.starts_with('('));
|
||||||
|
if implicit {
|
||||||
|
format!("{left}{right}")
|
||||||
|
} else {
|
||||||
|
format!("{left} · {right}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Div(a, b, sign) => match sign {
|
||||||
|
'/' => format!("{}/{}", a.display_at(4, dc), b.display_at(4, dc)),
|
||||||
|
_ => format!("{} : {}", a.display_at(2, dc), b.display_at(3, dc)),
|
||||||
|
},
|
||||||
|
Expr::Pow(base, exponent) => {
|
||||||
|
let base = base.display_at(5, dc);
|
||||||
|
let integer = |e: &Expr| match e {
|
||||||
|
Expr::Number(v) => v.as_integer().map(|n| superscript(n as u32)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let small = match &**exponent {
|
||||||
|
Expr::Neg(e) => integer(e).map(|s| format!("⁻{s}")),
|
||||||
|
e => integer(e),
|
||||||
|
};
|
||||||
|
match small {
|
||||||
|
Some(small) => format!("{base}{small}"),
|
||||||
|
None => format!("{base}^({})", exponent.display(dc)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursive descent with the usual precedence: `+ −` < `· :` and implicit
|
||||||
|
/// multiplication < unary minus < `^` (right-associative, so `−2² = −4` and `2^3^2 = 2^9`).
|
||||||
|
struct Parser<'a> {
|
||||||
|
tokens: &'a [Token],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Parser<'a> {
|
||||||
|
fn new(tokens: &'a [Token]) -> Self {
|
||||||
|
Parser { tokens, pos: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(mut self) -> Option<Expr> {
|
||||||
|
let expr = self.sum()?;
|
||||||
|
(self.pos == self.tokens.len()).then_some(expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(&self) -> Option<&Token> {
|
||||||
|
self.tokens.get(self.pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eat(&mut self, token: &Token) -> bool {
|
||||||
|
let found = self.peek() == Some(token);
|
||||||
|
if found {
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sum(&mut self) -> Option<Expr> {
|
||||||
|
let mut left = self.product()?;
|
||||||
|
loop {
|
||||||
|
if self.eat(&Token::Plus) {
|
||||||
|
left = Expr::Add(Box::new(left), Box::new(self.product()?));
|
||||||
|
} else if self.eat(&Token::Minus) {
|
||||||
|
left = Expr::Sub(Box::new(left), Box::new(self.product()?));
|
||||||
|
} else {
|
||||||
|
return Some(left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn product(&mut self) -> Option<Expr> {
|
||||||
|
let mut left = self.unary()?;
|
||||||
|
loop {
|
||||||
|
if self.eat(&Token::Times) {
|
||||||
|
left = Expr::Mul(Box::new(left), Box::new(self.unary()?));
|
||||||
|
} else if let Some(&Token::Divide(sign)) = self.peek() {
|
||||||
|
self.pos += 1;
|
||||||
|
left = Expr::Div(Box::new(left), Box::new(self.unary()?), sign);
|
||||||
|
} else if matches!(self.peek(), Some(Token::Variable(_) | Token::Open)) {
|
||||||
|
// Implicit multiplication: 2x, 3(x + 1), (a + b)(a − b). Two numbers in a
|
||||||
|
// row are not multiplied, they are more likely a misread.
|
||||||
|
left = Expr::Mul(Box::new(left), Box::new(self.power()?));
|
||||||
|
} else {
|
||||||
|
return Some(left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unary(&mut self) -> Option<Expr> {
|
||||||
|
if self.eat(&Token::Minus) {
|
||||||
|
return Some(Expr::Neg(Box::new(self.unary()?)));
|
||||||
|
}
|
||||||
|
if self.eat(&Token::Plus) {
|
||||||
|
return self.unary();
|
||||||
|
}
|
||||||
|
self.power()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn power(&mut self) -> Option<Expr> {
|
||||||
|
let base = self.atom()?;
|
||||||
|
if self.eat(&Token::Power) {
|
||||||
|
return Some(Expr::Pow(Box::new(base), Box::new(self.unary()?)));
|
||||||
|
}
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atom(&mut self) -> Option<Expr> {
|
||||||
|
let token = self.peek()?.clone();
|
||||||
|
self.pos += 1;
|
||||||
|
match token {
|
||||||
|
Token::Number(value) => Some(Expr::Number(value)),
|
||||||
|
Token::Variable(name) => Some(Expr::Variable(name)),
|
||||||
|
Token::Open => {
|
||||||
|
let inner = self.sum()?;
|
||||||
|
self.eat(&Token::Close).then_some(inner)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn de(expression: &str) -> Option<String> {
|
||||||
|
solve(expression, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arithmetic() {
|
||||||
|
assert_eq!(de("2^4 ="), Some("16".into()));
|
||||||
|
assert_eq!(de("2⁴="), Some("16".into()));
|
||||||
|
assert_eq!(de("1 + 2 · 3"), Some("7".into()));
|
||||||
|
assert_eq!(de("(1 + 2) × 3"), Some("9".into()));
|
||||||
|
assert_eq!(de("7 − 10"), Some("−3".into()));
|
||||||
|
assert_eq!(de("−2^2"), Some("−4".into()));
|
||||||
|
assert_eq!(de("(−2)²"), Some("4".into()));
|
||||||
|
assert_eq!(de("2^3^2"), Some("512".into()));
|
||||||
|
assert_eq!(de("10 : 4"), Some("2,5".into()));
|
||||||
|
assert_eq!(de("10 ÷ 4"), Some("2,5".into()));
|
||||||
|
assert_eq!(de("3 * [2 + 1]"), Some("9".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimals_and_fractions() {
|
||||||
|
assert_eq!(de("2,5 · 3"), Some("7,5".into()));
|
||||||
|
assert_eq!(solve("2.5 * 3", false), Some("7.5".into()));
|
||||||
|
assert_eq!(de("0,1 + 0,2"), Some("0,3".into()));
|
||||||
|
assert_eq!(de("1 : 3"), Some("1/3".into()));
|
||||||
|
assert_eq!(de("1 : 3 · 3"), Some("1".into()));
|
||||||
|
assert_eq!(de("2^-2"), Some("0,25".into()));
|
||||||
|
assert_eq!(de("2⁻¹"), Some("0,5".into()));
|
||||||
|
assert_eq!(de("1 : 8"), Some("0,125".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polynomials() {
|
||||||
|
assert_eq!(de("(x + 3)^2 ="), Some("x² + 6x + 9".into()));
|
||||||
|
assert_eq!(de("(x+3)²"), Some("x² + 6x + 9".into()));
|
||||||
|
assert_eq!(de("(a + b)²"), Some("a² + 2ab + b²".into()));
|
||||||
|
assert_eq!(de("(a + b)(a − b)"), Some("a² − b²".into()));
|
||||||
|
assert_eq!(de("(a − b)²"), Some("a² − 2ab + b²".into()));
|
||||||
|
assert_eq!(de("2x + 3x"), Some("5x".into()));
|
||||||
|
assert_eq!(de("x · x"), Some("x²".into()));
|
||||||
|
assert_eq!(de("3(x + 1)"), Some("3x + 3".into()));
|
||||||
|
assert_eq!(de("(x + 1)^3"), Some("x³ + 3x² + 3x + 1".into()));
|
||||||
|
assert_eq!(de("(2x − 1)²"), Some("4x² − 4x + 1".into()));
|
||||||
|
assert_eq!(de("x − x"), Some("0".into()));
|
||||||
|
assert_eq!(de("(x + 1) : 2"), Some("0,5x + 0,5".into()));
|
||||||
|
assert_eq!(de("x : 3"), Some("(1/3)x".into()));
|
||||||
|
assert_eq!(de("−(x + 1)"), Some("−x − 1".into()));
|
||||||
|
assert_eq!(
|
||||||
|
de("(x+1)^10").map(|s| s.starts_with("x¹⁰ + 10x⁹")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_to_compute() {
|
||||||
|
assert_eq!(de("5"), None);
|
||||||
|
assert_eq!(de("x ="), None);
|
||||||
|
assert_eq!(de("2x"), None);
|
||||||
|
assert_eq!(de("x² + 1"), None);
|
||||||
|
assert_eq!(de("2,5"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_or_not_computable() {
|
||||||
|
assert_eq!(de(""), None);
|
||||||
|
assert_eq!(de("="), None);
|
||||||
|
assert_eq!(de("1 +"), None);
|
||||||
|
assert_eq!(de("(1 + 2"), None);
|
||||||
|
assert_eq!(de("1 + 2)"), None);
|
||||||
|
assert_eq!(de("2 3"), None);
|
||||||
|
assert_eq!(de("1 : 0"), None);
|
||||||
|
assert_eq!(de("0^-1"), None);
|
||||||
|
assert_eq!(de("1 : x"), None);
|
||||||
|
assert_eq!(de("x^y"), None);
|
||||||
|
assert_eq!(de("x^0,5"), None);
|
||||||
|
assert_eq!(de("2 ? 3"), None);
|
||||||
|
assert_eq!(de("1,2,3"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn displays_expressions() {
|
||||||
|
let show = |e: &str| display(e, true).unwrap();
|
||||||
|
assert_eq!(show("2^(4)"), "2⁴");
|
||||||
|
assert_eq!(show("(x+3)^(2)"), "(x + 3)²");
|
||||||
|
assert_eq!(show("2,5·3"), "2,5 · 3");
|
||||||
|
assert_eq!(show("9:3"), "9 : 3");
|
||||||
|
assert_eq!(show("((x+1)/(2))"), "(x + 1)/2");
|
||||||
|
assert_eq!(show("((1)/(2))+((1)/(2·3))"), "1/2 + 1/(2 · 3)");
|
||||||
|
assert_eq!(show("2^(-1)"), "2⁻¹");
|
||||||
|
assert_eq!(show("2^(x+1)"), "2^(x + 1)");
|
||||||
|
assert_eq!(show("3(x+1)"), "3(x + 1)");
|
||||||
|
assert_eq!(show("2x^(2)"), "2x²");
|
||||||
|
assert_eq!(show("x·2"), "x · 2");
|
||||||
|
assert_eq!(show("(a+b)(a-b)"), "(a + b)(a − b)");
|
||||||
|
assert_eq!(show("7-(5-2)"), "7 − (5 − 2)");
|
||||||
|
assert_eq!(show("-2^(2)"), "−2²");
|
||||||
|
assert_eq!(show("(-2)^(2)"), "(−2)²");
|
||||||
|
assert_eq!(show("0.5"), "0,5");
|
||||||
|
assert!(display("1 +", true).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_and_limits() {
|
||||||
|
assert_eq!(de("10^100"), None);
|
||||||
|
assert_eq!(de("2^100000"), None);
|
||||||
|
assert_eq!(de("(x + 1)^33"), None);
|
||||||
|
assert_eq!(de("(a+b+c+d+e)^8"), None);
|
||||||
|
assert_eq!(de("99999999999999999999999999999999999999999999 + 1"), None);
|
||||||
|
assert_eq!(de("2^64"), Some("18446744073709551616".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
//! Computes handwritten math after an `=` (Issue #46, stage 3).
|
||||||
|
//!
|
||||||
|
//! When the last two strokes on a page form an `=` and the pen then rests for
|
||||||
|
//! [`TRIGGER_DELAY`], the strokes left of it are recognized ([`crate::math_ink`]) and computed
|
||||||
|
//! ([`crate::math::solve`]). The result is offered next to the `=` as a suggestion that also
|
||||||
|
//! shows what was read (`2⁴ = 16`), so that a misread symbol is noticed. Tapping it with the
|
||||||
|
//! pen or clicking it inserts the result as a text box; fingers and palms are ignored. Any new
|
||||||
|
//! stroke drops the suggestion. Off by default (☰ → Input).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use glam::Vec2;
|
||||||
|
|
||||||
|
use crate::document::{Page, Stroke, StrokeTool};
|
||||||
|
use crate::math_ink::Recognizer;
|
||||||
|
|
||||||
|
/// Pause after the `=` before its expression is computed; a new stroke cancels it
|
||||||
|
pub const TRIGGER_DELAY: Duration = Duration::from_millis(500);
|
||||||
|
/// Estimated symbol height relative to the width of the `=`
|
||||||
|
const SYMBOL_PER_EQUALS: f32 = 1.3;
|
||||||
|
/// Strokes left of the `=` belong to the expression up to a gap of this many symbol heights
|
||||||
|
const MAX_GAP: f32 = 2.0;
|
||||||
|
/// At most this many strokes are read as the expression
|
||||||
|
const MAX_STROKES: usize = 80;
|
||||||
|
/// Cap height of the text font relative to its size (Ubuntu: 0.69 em)
|
||||||
|
const CAP_HEIGHT_PER_EM: f32 = 0.7;
|
||||||
|
|
||||||
|
/// A result offered next to an `=`
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Suggestion {
|
||||||
|
pub page_id: u64,
|
||||||
|
/// The two strokes of the `=`
|
||||||
|
pub equals: [u64; 2],
|
||||||
|
/// What was read, written neatly (`2⁴`)
|
||||||
|
pub expression: String,
|
||||||
|
/// The result (`16`)
|
||||||
|
pub result: String,
|
||||||
|
/// Where the result goes: left end of the text on the vertical middle of the `=`
|
||||||
|
pub anchor: Vec2,
|
||||||
|
/// Height of the handwritten digits in page units
|
||||||
|
pub symbol_height: f32,
|
||||||
|
/// Ink color of the `=`
|
||||||
|
pub color: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Suggestion {
|
||||||
|
/// Top left corner and size (em) of a text box that puts the result next to the `=`, as
|
||||||
|
/// high as the handwriting
|
||||||
|
pub fn text_placement(&self, ascent_per_em: f32) -> (Vec2, f32) {
|
||||||
|
let size = self.symbol_height / CAP_HEIGHT_PER_EM;
|
||||||
|
// Baseline at the bottom of digits centered on the `=`
|
||||||
|
let baseline = self.anchor.y + self.symbol_height / 2.0;
|
||||||
|
(
|
||||||
|
Vec2::new(self.anchor.x, baseline - ascent_per_em * size),
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Pending {
|
||||||
|
page_id: u64,
|
||||||
|
equals: [u64; 2],
|
||||||
|
due: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the math assistant: waiting after an `=`, and the suggestion shown
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct MathAssist {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Built on first use
|
||||||
|
recognizer: Option<Arc<Recognizer>>,
|
||||||
|
pending: Option<Pending>,
|
||||||
|
suggestion: Option<Suggestion>,
|
||||||
|
/// Where the UI drew the suggestion (egui points), for [`Self::take_hit`]
|
||||||
|
pub chip_rect: Option<egui::Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for MathAssist {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("MathAssist")
|
||||||
|
.field("enabled", &self.enabled)
|
||||||
|
.field("pending", &self.pending)
|
||||||
|
.field("suggestion", &self.suggestion)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MathAssist {
|
||||||
|
/// After a stroke was added to `page`: drops the suggestion and waits if the stroke
|
||||||
|
/// completed an `=`
|
||||||
|
pub fn stroke_committed(&mut self, page: &Page, now: Instant) {
|
||||||
|
self.suggestion = None;
|
||||||
|
self.chip_rect = None;
|
||||||
|
self.pending = None;
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((a, b)) = equals_at_end(&page.strokes) {
|
||||||
|
self.pending = Some(Pending {
|
||||||
|
page_id: page.id,
|
||||||
|
equals: [page.strokes[a].id, page.strokes[b].id],
|
||||||
|
due: now + TRIGGER_DELAY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When [`Self::update`] has something to do
|
||||||
|
pub fn deadline(&self) -> Option<Instant> {
|
||||||
|
self.pending.as_ref().map(|p| p.due)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the expression once the pause after the `=` is over (not while a stroke is
|
||||||
|
/// being drawn). True if a suggestion appeared.
|
||||||
|
pub fn update(
|
||||||
|
&mut self,
|
||||||
|
page: &Page,
|
||||||
|
now: Instant,
|
||||||
|
drawing: bool,
|
||||||
|
decimal_comma: bool,
|
||||||
|
) -> bool {
|
||||||
|
let Some(pending) = self.pending.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if drawing || now < pending.due {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let pending = self.pending.take().expect("checked above");
|
||||||
|
if pending.page_id != page.id || !self.enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let recognizer = self
|
||||||
|
.recognizer
|
||||||
|
.get_or_insert_with(|| Arc::new(Recognizer::new()));
|
||||||
|
self.suggestion = suggest(recognizer, page, pending.equals, decimal_comma);
|
||||||
|
self.suggestion.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suggestion if it belongs to `page` and its `=` is still there
|
||||||
|
pub fn suggestion_for(&self, page: &Page) -> Option<&Suggestion> {
|
||||||
|
self.suggestion.as_ref().filter(|s| {
|
||||||
|
s.page_id == page.id
|
||||||
|
&& s.equals
|
||||||
|
.iter()
|
||||||
|
.all(|id| page.strokes.iter().any(|stroke| stroke.id == *id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suggestion if `pos` (egui points) is on it; it is then taken away
|
||||||
|
pub fn take_hit(&mut self, pos: egui::Pos2) -> Option<Suggestion> {
|
||||||
|
if !self.chip_rect.is_some_and(|r| r.contains(pos)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.chip_rect = None;
|
||||||
|
self.suggestion.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
|
self.enabled = enabled;
|
||||||
|
if !enabled {
|
||||||
|
self.pending = None;
|
||||||
|
self.suggestion = None;
|
||||||
|
self.chip_rect = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handwriting that can be read: pen strokes (also snapped lines), no highlighter, text or
|
||||||
|
/// images
|
||||||
|
fn is_ink(stroke: &Stroke) -> bool {
|
||||||
|
!stroke.is_object() && stroke.tool != StrokeTool::Highlighter && !stroke.points.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positions(stroke: &Stroke) -> Vec<Vec2> {
|
||||||
|
stroke.points.iter().map(|s| s.position).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A straight, roughly horizontal stroke: one bar of an `=`
|
||||||
|
fn is_bar(stroke: &Stroke) -> bool {
|
||||||
|
let size = stroke.aabb.max - stroke.aabb.min;
|
||||||
|
let length: f32 = positions(stroke)
|
||||||
|
.windows(2)
|
||||||
|
.map(|w| w[0].distance(w[1]))
|
||||||
|
.sum();
|
||||||
|
size.x > 0.0 && size.x >= 2.5 * size.y && length <= 1.3 * size.x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indices of the last two ink strokes if they form an `=`: two bars of similar length, one
|
||||||
|
/// above the other
|
||||||
|
fn equals_at_end(strokes: &[Stroke]) -> Option<(usize, usize)> {
|
||||||
|
let mut ink = strokes.iter().enumerate().rev().filter(|(_, s)| is_ink(s));
|
||||||
|
let (b, second) = ink.next()?;
|
||||||
|
let (a, first) = ink.next()?;
|
||||||
|
if !is_bar(first) || !is_bar(second) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (w1, w2) = (
|
||||||
|
first.aabb.max.x - first.aabb.min.x,
|
||||||
|
second.aabb.max.x - second.aabb.min.x,
|
||||||
|
);
|
||||||
|
let (short, long) = (w1.min(w2), w1.max(w2));
|
||||||
|
let overlap = first.aabb.max.x.min(second.aabb.max.x) - first.aabb.min.x.max(second.aabb.min.x);
|
||||||
|
let dy = ((first.aabb.min.y + first.aabb.max.y) - (second.aabb.min.y + second.aabb.max.y))
|
||||||
|
.abs()
|
||||||
|
/ 2.0;
|
||||||
|
(overlap >= 0.5 * short && long <= 2.0 * short && dy >= 0.1 * long && dy <= long)
|
||||||
|
.then_some((a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indices of the strokes left of the `=` that make up its expression, in the order they were
|
||||||
|
/// written: ink in a band around the `=`, going left until a wide gap
|
||||||
|
fn expression_strokes(strokes: &[Stroke], equals: (usize, usize)) -> Vec<usize> {
|
||||||
|
let (e1, e2) = (&strokes[equals.0], &strokes[equals.1]);
|
||||||
|
let min = e1.aabb.min.min(e2.aabb.min);
|
||||||
|
let max = e1.aabb.max.max(e2.aabb.max);
|
||||||
|
let height = (max.x - min.x) * SYMBOL_PER_EQUALS;
|
||||||
|
let middle = (min.y + max.y) / 2.0;
|
||||||
|
let mut candidates: Vec<usize> = (0..strokes.len())
|
||||||
|
.filter(|&i| i != equals.0 && i != equals.1 && is_ink(&strokes[i]))
|
||||||
|
.filter(|&i| {
|
||||||
|
let s = &strokes[i].aabb;
|
||||||
|
let center = (s.min.y + s.max.y) / 2.0;
|
||||||
|
(center - middle).abs() <= 2.0 * height
|
||||||
|
&& s.max.y - s.min.y <= 3.0 * height
|
||||||
|
&& s.max.x <= min.x + 0.2 * height
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
candidates.sort_by(|&a, &b| strokes[b].aabb.max.x.total_cmp(&strokes[a].aabb.max.x));
|
||||||
|
let mut left = min.x;
|
||||||
|
let mut chosen = Vec::new();
|
||||||
|
for i in candidates {
|
||||||
|
let s = &strokes[i].aabb;
|
||||||
|
if s.max.x < left - MAX_GAP * height || chosen.len() >= MAX_STROKES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
left = left.min(s.min.x);
|
||||||
|
chosen.push(i);
|
||||||
|
}
|
||||||
|
chosen.sort_unstable();
|
||||||
|
chosen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and computes the expression in front of the `=` made of the strokes `equals`
|
||||||
|
fn suggest(
|
||||||
|
recognizer: &Recognizer,
|
||||||
|
page: &Page,
|
||||||
|
equals: [u64; 2],
|
||||||
|
decimal_comma: bool,
|
||||||
|
) -> Option<Suggestion> {
|
||||||
|
let strokes = &page.strokes;
|
||||||
|
let index = |id: u64| strokes.iter().position(|s| s.id == id);
|
||||||
|
let pair = (index(equals[0])?, index(equals[1])?);
|
||||||
|
let mut chosen = expression_strokes(strokes, pair);
|
||||||
|
if chosen.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
chosen.extend([pair.0, pair.1]);
|
||||||
|
let points: Vec<Vec<Vec2>> = chosen.iter().map(|&i| positions(&strokes[i])).collect();
|
||||||
|
let recognition = recognizer.recognize(&points)?;
|
||||||
|
// The `=` must be read as the last symbol, from exactly its two strokes
|
||||||
|
let last = recognition.symbols.last()?;
|
||||||
|
let equals_strokes = [points.len() - 2, points.len() - 1];
|
||||||
|
let mut last_strokes = last.strokes.clone();
|
||||||
|
last_strokes.sort_unstable();
|
||||||
|
if last.ch != '=' || last_strokes != equals_strokes {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// In a chain like `x = 2 + 3 =` only the part after the previous `=` is computed
|
||||||
|
let text = recognition.text.strip_suffix('=')?;
|
||||||
|
let expression = text.rsplit('=').next()?;
|
||||||
|
// A dot or comma that was written decides the separator of the result
|
||||||
|
let decimal_comma = if expression.contains(',') {
|
||||||
|
true
|
||||||
|
} else if expression.contains('.') {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
decimal_comma
|
||||||
|
};
|
||||||
|
let result = crate::math::solve(expression, decimal_comma)?;
|
||||||
|
let shown = crate::math::display(expression, decimal_comma)?;
|
||||||
|
|
||||||
|
let (e1, e2) = (&strokes[pair.0], &strokes[pair.1]);
|
||||||
|
let right = e1.aabb.max.x.max(e2.aabb.max.x);
|
||||||
|
let middle = (e1.aabb.min.y.min(e2.aabb.min.y) + e1.aabb.max.y.max(e2.aabb.max.y)) / 2.0;
|
||||||
|
let mut heights: Vec<f32> = recognition
|
||||||
|
.symbols
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.ch.is_ascii_digit() || s.ch.is_ascii_alphabetic())
|
||||||
|
.map(|s| s.max.y - s.min.y)
|
||||||
|
.collect();
|
||||||
|
heights.sort_by(f32::total_cmp);
|
||||||
|
let width = right - e1.aabb.min.x.min(e2.aabb.min.x);
|
||||||
|
let symbol_height = heights
|
||||||
|
.last()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(width * SYMBOL_PER_EQUALS)
|
||||||
|
.max(1.0);
|
||||||
|
Some(Suggestion {
|
||||||
|
page_id: page.id,
|
||||||
|
equals,
|
||||||
|
expression: shown,
|
||||||
|
result,
|
||||||
|
anchor: Vec2::new(right + 0.4 * symbol_height, middle),
|
||||||
|
symbol_height,
|
||||||
|
color: e2.color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::StylusSample;
|
||||||
|
use crate::math_ink::tests::{write_line, Jitter};
|
||||||
|
|
||||||
|
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
|
||||||
|
|
||||||
|
fn stroke(id: u64, points: &[Vec2]) -> Stroke {
|
||||||
|
let samples = points
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &position)| StylusSample {
|
||||||
|
position,
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: i as f64 * 0.01,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Stroke::new(id, samples, Vec::new(), BLACK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page with `text` written at `origin`, scaled by `scale`
|
||||||
|
fn page_with(lines: &[(&str, Vec2)], scale: f32) -> Page {
|
||||||
|
let mut page = Page::new(7, crate::document::PageBackground::Blank);
|
||||||
|
let mut id = 1;
|
||||||
|
for (seed, (text, origin)) in lines.iter().enumerate() {
|
||||||
|
for points in write_line(text, &mut Jitter(seed as u64 + 3)) {
|
||||||
|
let points: Vec<Vec2> = points.iter().map(|p| *origin + *p * scale).collect();
|
||||||
|
page.add_stroke(stroke(id, &points));
|
||||||
|
id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(page: &Page) -> Option<Suggestion> {
|
||||||
|
let mut assist = MathAssist {
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let start = Instant::now();
|
||||||
|
assist.stroke_committed(page, start);
|
||||||
|
assert!(!assist.update(page, start, false, true), "too early");
|
||||||
|
assist.update(page, start + TRIGGER_DELAY, false, true);
|
||||||
|
assist.suggestion_for(page).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggests_result_after_equals() {
|
||||||
|
let page = page_with(&[("(x+3)^2=", Vec2::new(100.0, 200.0))], 3.0);
|
||||||
|
let suggestion = run(&page).unwrap();
|
||||||
|
assert_eq!(suggestion.expression, "(x + 3)²");
|
||||||
|
assert_eq!(suggestion.result, "x² + 6x + 9");
|
||||||
|
let equals_right = page
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(2)
|
||||||
|
.map(|s| s.aabb.max.x)
|
||||||
|
.fold(f32::MIN, f32::max);
|
||||||
|
assert!(suggestion.anchor.x > equals_right);
|
||||||
|
// The handwriting is 14 units high, scaled by 3
|
||||||
|
assert!(
|
||||||
|
(suggestion.symbol_height - 42.0).abs() < 6.0,
|
||||||
|
"{suggestion:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_writing_far_to_the_left_and_on_other_lines() {
|
||||||
|
let page = page_with(
|
||||||
|
&[
|
||||||
|
("7", Vec2::new(0.0, 200.0)),
|
||||||
|
("5·5", Vec2::new(0.0, 300.0)),
|
||||||
|
("2^4=", Vec2::new(150.0, 200.0)),
|
||||||
|
],
|
||||||
|
2.0,
|
||||||
|
);
|
||||||
|
let suggestion = run(&page).unwrap();
|
||||||
|
assert_eq!(suggestion.expression, "2⁴");
|
||||||
|
assert_eq!(suggestion.result, "16");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn computes_the_last_part_of_a_chain() {
|
||||||
|
let page = page_with(&[("x=2·3=", Vec2::new(0.0, 0.0))], 2.0);
|
||||||
|
assert_eq!(run(&page).unwrap().result, "6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_without_equals_or_when_disabled() {
|
||||||
|
let page = page_with(&[("2+3", Vec2::ZERO)], 2.0);
|
||||||
|
assert!(run(&page).is_none());
|
||||||
|
let page = page_with(&[("2+3=", Vec2::ZERO)], 2.0);
|
||||||
|
let mut assist = MathAssist::default();
|
||||||
|
assist.stroke_committed(&page, Instant::now());
|
||||||
|
assert!(assist.deadline().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_stroke_or_removed_equals_drops_the_suggestion() {
|
||||||
|
let mut page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
|
||||||
|
let mut assist = MathAssist {
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let start = Instant::now();
|
||||||
|
assist.stroke_committed(&page, start);
|
||||||
|
// A stroke still being drawn when the time is up: nothing yet
|
||||||
|
assert!(!assist.update(&page, start + TRIGGER_DELAY, true, true));
|
||||||
|
assert!(assist.update(&page, start + TRIGGER_DELAY, false, true));
|
||||||
|
assert_eq!(assist.suggestion_for(&page).unwrap().result, "2");
|
||||||
|
let last = page.strokes.last().unwrap().id;
|
||||||
|
page.remove_strokes_by_ids(&[last]);
|
||||||
|
assert!(assist.suggestion_for(&page).is_none());
|
||||||
|
assist.stroke_committed(&page, start);
|
||||||
|
assert!(assist.suggestion.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_takes_the_suggestion() {
|
||||||
|
let page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
|
||||||
|
let mut assist = MathAssist {
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let start = Instant::now();
|
||||||
|
assist.stroke_committed(&page, start);
|
||||||
|
assist.update(&page, start + TRIGGER_DELAY, false, true);
|
||||||
|
assist.chip_rect = Some(egui::Rect::from_min_size(
|
||||||
|
egui::pos2(10.0, 10.0),
|
||||||
|
egui::vec2(50.0, 20.0),
|
||||||
|
));
|
||||||
|
assert!(assist.take_hit(egui::pos2(5.0, 5.0)).is_none());
|
||||||
|
assert_eq!(assist.take_hit(egui::pos2(20.0, 20.0)).unwrap().result, "2");
|
||||||
|
assert!(assist.take_hit(egui::pos2(20.0, 20.0)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_text_sits_on_the_baseline() {
|
||||||
|
let suggestion = Suggestion {
|
||||||
|
page_id: 1,
|
||||||
|
equals: [1, 2],
|
||||||
|
expression: "1 + 1".into(),
|
||||||
|
result: "2".into(),
|
||||||
|
anchor: Vec2::new(100.0, 50.0),
|
||||||
|
symbol_height: 14.0,
|
||||||
|
color: BLACK,
|
||||||
|
};
|
||||||
|
let (top_left, size) = suggestion.text_placement(0.9);
|
||||||
|
assert!((size - 20.0).abs() < 0.01);
|
||||||
|
// Baseline (top + ascent) at the bottom of digits centered on the `=`
|
||||||
|
assert!((top_left.y + 0.9 * size - 57.0).abs() < 0.01);
|
||||||
|
assert_eq!(top_left.x, 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1259
File diff suppressed because it is too large.
Load diff
@@ -232,6 +232,8 @@ pub struct UiSettings {
|
|||||||
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
||||||
/// (Issue #49)
|
/// (Issue #49)
|
||||||
pub storage_dir: Option<PathBuf>,
|
pub storage_dir: Option<PathBuf>,
|
||||||
|
/// Compute handwritten math after `=` (Issue #46)
|
||||||
|
pub math_assist: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiSettings {
|
impl Default for UiSettings {
|
||||||
@@ -253,6 +255,7 @@ impl Default for UiSettings {
|
|||||||
language: None,
|
language: None,
|
||||||
eraser_radius_mm: 3.0,
|
eraser_radius_mm: 3.0,
|
||||||
storage_dir: None,
|
storage_dir: None,
|
||||||
|
math_assist: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,6 +353,7 @@ mod tests {
|
|||||||
language: Some(Language::English),
|
language: Some(Language::English),
|
||||||
eraser_radius_mm: 6.0,
|
eraser_radius_mm: 6.0,
|
||||||
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
||||||
|
math_assist: true,
|
||||||
};
|
};
|
||||||
settings.save_to(&path).unwrap();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ pub enum UiAction {
|
|||||||
UnlockCorruptedPage(u64),
|
UnlockCorruptedPage(u64),
|
||||||
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
||||||
SetSidebarSide(SidebarSide),
|
SetSidebarSide(SidebarSide),
|
||||||
|
/// Compute handwritten math after `=` on or off (Issue #46)
|
||||||
|
SetMathAssist(bool),
|
||||||
/// Sprache der Oberfläche; `None` = Sprache des Systems
|
/// Sprache der Oberfläche; `None` = Sprache des Systems
|
||||||
SetLanguage(Option<crate::i18n::Language>),
|
SetLanguage(Option<crate::i18n::Language>),
|
||||||
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
||||||
@@ -413,6 +415,8 @@ pub struct UiState {
|
|||||||
pub update_modal_rect: Option<egui::Rect>,
|
pub update_modal_rect: Option<egui::Rect>,
|
||||||
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
|
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
|
||||||
pub touch_draw_enabled: bool,
|
pub touch_draw_enabled: bool,
|
||||||
|
/// Computes handwritten math after an `=` (Issue #46)
|
||||||
|
pub math: crate::math_assist::MathAssist,
|
||||||
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
|
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
|
||||||
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
|
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
|
||||||
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
|
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
|
||||||
@@ -546,6 +550,7 @@ impl Default for UiState {
|
|||||||
show_updater_config: false,
|
show_updater_config: false,
|
||||||
update_modal_rect: None,
|
update_modal_rect: None,
|
||||||
touch_draw_enabled: false,
|
touch_draw_enabled: false,
|
||||||
|
math: crate::math_assist::MathAssist::default(),
|
||||||
last_deleted_notebook: None,
|
last_deleted_notebook: None,
|
||||||
pen_presets: DEFAULT_PEN_PRESETS,
|
pen_presets: DEFAULT_PEN_PRESETS,
|
||||||
active_pen: 0,
|
active_pen: 0,
|
||||||
@@ -1652,6 +1657,57 @@ fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) {
|
|||||||
|
|
||||||
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
|
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
|
||||||
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
|
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
|
||||||
|
/// The result offered next to a handwritten `=` (Issue #46): what was read and the result,
|
||||||
|
/// e.g. `2⁴ = 16`. Not interactive for egui, so that fingers and palms pass through to the
|
||||||
|
/// page; the pen and the mouse take it via [`crate::math_assist::MathAssist::take_hit`].
|
||||||
|
fn math_suggestion(ctx: &egui::Context, state: &mut UiState, notebook: &Notebook) {
|
||||||
|
state.math.chip_rect = None;
|
||||||
|
if state.view_mode {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (Some(view), Some(suggestion)) = (
|
||||||
|
state.page_view,
|
||||||
|
state.math.suggestion_for(notebook.current_page()).cloned(),
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let size = (suggestion.symbol_height * view.scale * 0.8).clamp(14.0, 48.0);
|
||||||
|
let response = egui::Area::new(egui::Id::new("math_suggestion"))
|
||||||
|
.fixed_pos(view.to_screen(suggestion.anchor))
|
||||||
|
.pivot(egui::Align2::LEFT_CENTER)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.interactable(false)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::none()
|
||||||
|
.fill(egui::Color32::from_rgba_unmultiplied(30, 34, 44, 235))
|
||||||
|
.stroke(egui::Stroke::new(
|
||||||
|
1.5_f32,
|
||||||
|
egui::Color32::from_rgb(70, 130, 230),
|
||||||
|
))
|
||||||
|
.rounding(size * 0.4)
|
||||||
|
.inner_margin(egui::vec2(size * 0.45, size * 0.2))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = size * 0.25;
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("{} =", suggestion.expression))
|
||||||
|
.size(size * 0.7)
|
||||||
|
.color(egui::Color32::from_gray(170)),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(&suggestion.result)
|
||||||
|
.size(size)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::WHITE),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
// A little larger than drawn, as the pen does not hit exactly
|
||||||
|
state.math.chip_rect = Some(response.rect.expand(6.0));
|
||||||
|
}
|
||||||
|
|
||||||
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
|
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||||
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
|
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
|
||||||
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
|
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
|
||||||
@@ -3150,6 +3206,7 @@ pub fn render_ui(
|
|||||||
space_preview(ctx, state, notebook);
|
space_preview(ctx, state, notebook);
|
||||||
ruler_overlay(ctx, state);
|
ruler_overlay(ctx, state);
|
||||||
text_edit_overlay(ctx, state);
|
text_edit_overlay(ctx, state);
|
||||||
|
math_suggestion(ctx, state, notebook);
|
||||||
context_menu(ctx, state, &mut actions);
|
context_menu(ctx, state, &mut actions);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
@@ -4383,6 +4440,15 @@ pub fn render_ui(
|
|||||||
.on_hover_text(
|
.on_hover_text(
|
||||||
t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."),
|
t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."),
|
||||||
);
|
);
|
||||||
|
let mut math = state.math.enabled;
|
||||||
|
if ui
|
||||||
|
.checkbox(&mut math, t!("Formeln nach „=“ ausrechnen"))
|
||||||
|
.on_hover_text(t!("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b."))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
state.math.set_enabled(math);
|
||||||
|
actions.push(UiAction::SetMathAssist(math));
|
||||||
|
}
|
||||||
if let Some(curve) = pressure_curve_editor(ui, state) {
|
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||||
actions.push(UiAction::SavePressureCurve(curve));
|
actions.push(UiAction::SavePressureCurve(curve));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user