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%; }