From b0c46c390cc80a9ecf3824d9963d2af4d3203b37 Mon Sep 17 00:00:00 2001
From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
Date: Wed, 24 Jun 2026 00:27:06 +0300
Subject: [PATCH] =?UTF-8?q?RU=20math=20phrases,=20half/=D0=BF=D0=BE=D0=BB?=
=?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20constants,=20export=20button?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- vocab: EN+RU aliases for abs/log/ln/round; RU already had sqrt/cbrt
- engine: "half"/"a half"/"one half"/"половина" → 0.5; "one and a half"/"полтора"/"полторы" → 1.5 as named constants; widen const union in parser/tokenizer/evaluator/registry
- export: ⤓ button in statusbar dropdown — copy sheet, save .sum, export .txt, print/PDF; write_text_file Tauri command; print media CSS hides chrome
---
index.html | 9 ++++++++
src-tauri/src/main.rs | 8 ++++++-
src/engine/engine.test.ts | 17 +++++++++++++++
src/engine/evaluator.ts | 7 +++++-
src/engine/parser.ts | 2 +-
src/engine/registry.ts | 12 ++++++++++-
src/engine/tokenizer.ts | 2 +-
src/engine/vocab-data.ts | 8 +++++++
src/i18n.ts | 10 +++++++++
src/main.ts | 35 ++++++++++++++++++++++++++++++
src/ui/app.css | 45 +++++++++++++++++++++++++++++++++++++++
11 files changed, 150 insertions(+), 5 deletions(-)
diff --git a/index.html b/index.html
index 9806666..7832af7 100644
--- a/index.html
+++ b/index.html
@@ -30,6 +30,15 @@
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index c90a22b..f74af91 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -37,6 +37,11 @@ fn safe_name(name: &str) -> bool {
!name.is_empty() && !name.contains(['/', '\\', ':']) && !name.contains("..")
}
+#[tauri::command]
+fn write_text_file(path: String, contents: String) -> Result<(), String> {
+ write_atomic(std::path::Path::new(&path), &contents)
+}
+
/// write via a temp file + rename so a crash mid-write cannot corrupt the target
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
let tmp = path.with_extension("tmp");
@@ -521,7 +526,8 @@ fn main() {
get_launch_file,
read_text_file,
exit_app,
- is_hidden_launch
+ is_hidden_launch,
+ write_text_file
])
.setup(|app| {
let handle = app.handle().clone();
diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts
index 11d3e7b..2e93492 100644
--- a/src/engine/engine.test.ts
+++ b/src/engine/engine.test.ts
@@ -71,6 +71,23 @@ describe("arithmetic", () => {
expect(calc("round(2.6)")).toBe("3");
expect(calc("log(1000)")).toBe("3");
expect(calc("ln(1)")).toBe("0");
+ // russian aliases
+ expect(calc("квадратный корень из 16")).toBe("4");
+ expect(calc("кубический корень из 27")).toBe("3");
+ expect(calc("модуль(-5)")).toBe("5");
+ expect(calc("логарифм(1000)")).toBe("3");
+ expect(calc("натуральный логарифм(1)")).toBe("0");
+ expect(calc("округлить(2.6)")).toBe("3");
+ });
+ it("fraction constants", () => {
+ expect(calc("a half")).toBe("0.5");
+ expect(calc("one half")).toBe("0.5");
+ expect(calc("половина")).toBe("0.5");
+ expect(calc("a half million")).toBe("500,000");
+ expect(calc("one and a half")).toBe("1.5");
+ expect(calc("полтора")).toBe("1.5");
+ expect(calc("полтора миллиона")).toBe("1,500,000");
+ expect(calc("полторы тысячи")).toBe("1,500");
});
});
diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts
index 8c2fdae..34aec2f 100644
--- a/src/engine/evaluator.ts
+++ b/src/engine/evaluator.ts
@@ -27,7 +27,12 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "num":
return qty(node.v, null, node.repr);
case "const":
- return qty(node.name === "pi" ? PI : E);
+ switch (node.name) {
+ case "pi": return qty(PI);
+ case "e": return qty(E);
+ case "half": return qty(new Decimal("0.5"));
+ case "onehalf": return qty(new Decimal("1.5"));
+ }
case "var": {
const v = ctx.vars.get(node.name);
if (!v) throw new EvalError(`unknown variable ${node.name}`);
diff --git a/src/engine/parser.ts b/src/engine/parser.ts
index efacc01..9b50e54 100644
--- a/src/engine/parser.ts
+++ b/src/engine/parser.ts
@@ -16,7 +16,7 @@ export type ConvTarget =
export type Node =
| { k: "num"; v: Decimal; repr: NumeralRepr }
- | { k: "const"; name: "pi" | "e" }
+ | { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
| { k: "var"; name: string }
| { k: "date"; word: DateWord }
| { k: "agg"; name: "sum" | "avg" | "prev" }
diff --git a/src/engine/registry.ts b/src/engine/registry.ts
index 4deffc5..83bb562 100644
--- a/src/engine/registry.ts
+++ b/src/engine/registry.ts
@@ -32,7 +32,7 @@ export type Payload =
| { t: "repr"; repr: NumeralRepr }
| { t: "special"; name: "unix" | "todate" }
| { t: "date"; word: DateWord }
- | { t: "const"; name: "pi" | "e" };
+ | { t: "const"; name: "pi" | "e" | "half" | "onehalf" };
interface PhraseEntry {
lexemes: string[]; // raw, as in the phrase
@@ -172,6 +172,10 @@ export function buildRegistry(): Registry {
for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
@@ -231,6 +235,12 @@ export function buildRegistry(): Registry {
reg.addPhrase("π", { t: "const", name: "pi" });
reg.addPhrase("пи", { t: "const", name: "pi" }, { caseSensitive: false });
reg.addPhrase("e", { t: "const", name: "e" }, { caseSensitive: true });
+ reg.addPhrase("a half", { t: "const", name: "half" }, { caseSensitive: false });
+ reg.addPhrase("one half", { t: "const", name: "half" }, { caseSensitive: false });
+ reg.addPhrase("половина", { t: "const", name: "half" }, { caseSensitive: false });
+ reg.addPhrase("one and a half", { t: "const", name: "onehalf" }, { caseSensitive: false });
+ reg.addPhrase("полтора", { t: "const", name: "onehalf" }, { caseSensitive: false });
+ reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
diff --git a/src/engine/tokenizer.ts b/src/engine/tokenizer.ts
index f6f78f2..8a0ef5f 100644
--- a/src/engine/tokenizer.ts
+++ b/src/engine/tokenizer.ts
@@ -19,7 +19,7 @@ export type Token =
| { t: "scale"; mult: Decimal; start: number; end: number }
| { t: "repr"; repr: NumeralRepr; start: number; end: number }
| { t: "date"; word: DateWord; start: number; end: number }
- | { t: "const"; name: "pi" | "e"; start: number; end: number }
+ | { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
| { t: "lparen"; start: number; end: number }
| { t: "rparen"; start: number; end: number }
| { t: "semicolon"; start: number; end: number }
diff --git a/src/engine/vocab-data.ts b/src/engine/vocab-data.ts
index 99944f6..81f3d2c 100644
--- a/src/engine/vocab-data.ts
+++ b/src/engine/vocab-data.ts
@@ -423,6 +423,10 @@ export const EN: VocabLocale = {
"plus.variants": "with,plus,and",
"prev.variants": "prev",
"sqrt.variants": "sqrt,square root",
+ "abs.variants": "absolute value,absolute",
+ "ln.variants": "natural log,natural logarithm",
+ "log.variants": "logarithm",
+ "round.variants": "rounded",
"sum.variants": "sum,total"
},
"Percent": {
@@ -889,6 +893,10 @@ export const RU: VocabLocale = {
"plus.variants": "и,плюс,вместе с",
"prev.variants": "пред.,пред",
"sqrt.variants": "квадратный корень,кв корень,кв. корень",
+ "abs.variants": "модуль,абсолютное значение",
+ "ln.variants": "натуральный логарифм,нат. логарифм",
+ "log.variants": "логарифм",
+ "round.variants": "округлить,округление",
"sum.variants": "всего,сумма,итог,итого"
},
"Percent": {
diff --git a/src/i18n.ts b/src/i18n.ts
index 94eb6bb..5b9afdd 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -42,6 +42,11 @@ const dict: Record> = {
folderError: "Could not change the data folder",
hotkeyFailed: "Hotkey is taken by another app",
decimalsep: "Decimal separator",
+ exportCopy: "Copy sheet",
+ exportSum: "Save as .sum",
+ exportTxt: "Export as text",
+ exportPrint: "Print / PDF",
+ saved: "Saved",
},
ru: {
settings: "Настройки",
@@ -84,6 +89,11 @@ const dict: Record> = {
folderError: "Не удалось сменить папку данных",
hotkeyFailed: "Хоткей занят другой программой",
decimalsep: "Десятичный разделитель",
+ exportCopy: "Скопировать лист",
+ exportSum: "Сохранить как .sum",
+ exportTxt: "Экспорт в текст",
+ exportPrint: "Печать / PDF",
+ saved: "Сохранено",
},
};
diff --git a/src/main.ts b/src/main.ts
index 190c561..9e6e269 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -594,6 +594,41 @@ async function boot(): Promise {
}
});
+ // export button + dropdown
+ const exportBtn = $("#export-btn");
+ const exportMenu = $("#export-menu");
+ exportBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ exportMenu.classList.toggle("hidden");
+ });
+ document.addEventListener("click", () => exportMenu.classList.add("hidden"));
+ exportMenu.addEventListener("click", async (e) => {
+ const btn = (e.target as HTMLElement).closest("[data-action]");
+ if (!btn) return;
+ exportMenu.classList.add("hidden");
+ const action = btn.dataset.action;
+ if (action === "copy") {
+ void navigator.clipboard.writeText(editor.getSheetWithResults());
+ toast(t("copied"));
+ } else if (action === "print") {
+ window.print();
+ } else if (isTauri()) {
+ const { save } = await import("@tauri-apps/plugin-dialog");
+ const { invoke } = await import("@tauri-apps/api/core");
+ const isSum = action === "save-sum";
+ const path = await save({
+ filters: isSum
+ ? [{ name: "Summarum Sheet", extensions: ["sum"] }]
+ : [{ name: "Text file", extensions: ["txt"] }],
+ });
+ if (path) {
+ const content = isSum ? editor.getText() : editor.getSheetWithResults();
+ await invoke("write_text_file", { path, contents: content });
+ toast(t("saved"));
+ }
+ }
+ });
+
$("#rates-info").addEventListener("click", () => void refreshRates(true));
setInterval(renderRatesInfo, 60_000);
diff --git a/src/ui/app.css b/src/ui/app.css
index 9ef8d95..cbb89a8 100644
--- a/src/ui/app.css
+++ b/src/ui/app.css
@@ -188,6 +188,51 @@ body {
#total-line:hover { text-decoration: underline; }
#total-line .total-label { color: var(--fg-muted); font-family: var(--font); margin-right: 8px; }
+/* ---------- export */
+#export-wrap { position: relative; }
+#export-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--fg-muted);
+ font-size: 15px;
+ padding: 0 4px;
+ line-height: 1;
+}
+#export-btn:hover { color: var(--fg); }
+#export-menu {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ right: 0;
+ background: var(--toolbar-bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
+ min-width: 160px;
+ padding: 4px 0;
+ z-index: 100;
+}
+#export-menu.hidden { display: none; }
+#export-menu button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: none;
+ padding: 7px 14px;
+ font-size: 13px;
+ color: var(--fg);
+ cursor: pointer;
+ font-family: var(--font);
+}
+#export-menu button:hover { background: var(--hover); }
+
+@media print {
+ #titlebar, #statusbar, #sidebar { display: none !important; }
+ #content { display: block; }
+ #editor-wrap { overflow: visible; height: auto; }
+}
+
/* ---------- editor + results */
#editor-wrap { position: relative; flex: 1; overflow: hidden; --results-width: 42%; }
#editor { height: 100%; }