diff --git a/src/main/i18n/locales/en_US/preferences.json b/src/main/i18n/locales/en_US/preferences.json index 79c19aeb..1a94d38d 100644 --- a/src/main/i18n/locales/en_US/preferences.json +++ b/src/main/i18n/locales/en_US/preferences.json @@ -115,6 +115,17 @@ "label": "Markdown", "codeRenderer": "Code block Renderer" }, + "math": { + "label": "Math Notebook", + "locale": { + "label": "Region", + "description": "Number and date display format." + }, + "decimalPlaces": { + "label": "Decimal Places", + "description": "Maximum decimal places in results (0\u201314)." + } + }, "api": { "label": "API Port", "port": { diff --git a/src/main/store/module/preferences.ts b/src/main/store/module/preferences.ts index 95c95e61..494694f2 100644 --- a/src/main/store/module/preferences.ts +++ b/src/main/store/module/preferences.ts @@ -1,6 +1,7 @@ import type { EditorSettings, MarkdownSettings, + MathSettings, NotesEditorSettings, PreferencesStore, } from '../types' @@ -20,6 +21,11 @@ const isWin = platform() === 'win32' const storagePath = isWin ? `${homedir()}\\massCode` : `${homedir()}/massCode` +const MATH_DEFAULTS: MathSettings = { + locale: 'en-US', + decimalPlaces: 6, +} + const PREFERENCES_DEFAULTS: PreferencesStore = { appearance: { theme: 'auto', @@ -41,6 +47,7 @@ const PREFERENCES_DEFAULTS: PreferencesStore = { scale: 1, }, }, + math: MATH_DEFAULTS, } function sanitizeCodeEditorSettings(value: unknown): EditorSettings { @@ -143,6 +150,19 @@ function sanitizeMarkdownSettings(value: unknown): MarkdownSettings { } } +function sanitizeMathSettings(value: unknown): MathSettings { + const source = asRecord(value) + + return { + locale: readString(source, 'locale', MATH_DEFAULTS.locale), + decimalPlaces: readNumber( + source, + 'decimalPlaces', + MATH_DEFAULTS.decimalPlaces, + ), + } +} + function sanitizePreferences(value: unknown): PreferencesStore { const source = asRecord(value) const appearanceSource = asRecord(source.appearance) @@ -162,6 +182,7 @@ function sanitizePreferences(value: unknown): PreferencesStore { = Object.keys(asRecord(editorSource.markdown)).length > 0 ? asRecord(editorSource.markdown) : asRecord(source.markdown) + const mathSource = asRecord(source.math) return { appearance: { @@ -210,6 +231,7 @@ function sanitizePreferences(value: unknown): PreferencesStore { notes: sanitizeNotesEditorSettings(notesEditorSource), markdown: sanitizeMarkdownSettings(markdownSource), }, + math: sanitizeMathSettings(mathSource), } } diff --git a/src/main/store/types/index.ts b/src/main/store/types/index.ts index 0b536b6c..be22cfc4 100644 --- a/src/main/store/types/index.ts +++ b/src/main/store/types/index.ts @@ -78,6 +78,11 @@ export interface NotesEditorSettings { indentSize: number } +export interface MathSettings { + locale: string + decimalPlaces: number +} + export interface PreferencesStore { appearance: { theme: string @@ -96,6 +101,7 @@ export interface PreferencesStore { notes: NotesEditorSettings markdown: MarkdownSettings } + math: MathSettings } export interface MathSheet { diff --git a/src/renderer/components/math-notebook/ResultsPanel.vue b/src/renderer/components/math-notebook/ResultsPanel.vue index 0b96dd7d..3275fb10 100644 --- a/src/renderer/components/math-notebook/ResultsPanel.vue +++ b/src/renderer/components/math-notebook/ResultsPanel.vue @@ -2,6 +2,7 @@ import type { LineResult } from '@/composables/math-notebook' import { Button } from '@/components/ui/shadcn/button' import { useCopyToClipboard } from '@/composables' +import { formatMathNumber } from '@/composables/math-notebook/math-engine/format' import { i18n, ipc } from '@/electron' import { LoaderCircle, Sigma } from 'lucide-vue-next' @@ -9,6 +10,8 @@ interface Props { results: LineResult[] scrollTop: number activeLine: number + locale: string + decimalPlaces: number } const props = defineProps() @@ -21,7 +24,10 @@ const MATH_NOTEBOOK_DOCUMENTATION_URL const total = computed(() => { return props.results.reduce((sum, r) => { if (r.type === 'number' || r.type === 'assignment') { - const num = Number.parseFloat((r.value || '').replace(/,/g, '')) + const raw = r.value || '' + if (raw.includes(':')) + return sum + const num = Number.parseFloat(raw.replace(/[^\d.\-e+]/gi, '')) if (!Number.isNaN(num)) return sum + num } @@ -30,9 +36,7 @@ const total = computed(() => { }) const formattedTotal = computed(() => { - return Number.isInteger(total.value) - ? total.value.toLocaleString('en-US') - : total.value.toLocaleString('en-US', { maximumFractionDigits: 6 }) + return formatMathNumber(total.value, props.locale, props.decimalPlaces) }) const resultsStyle = computed(() => { diff --git a/src/renderer/components/math-notebook/Workspace.vue b/src/renderer/components/math-notebook/Workspace.vue index cd915744..b3b466da 100644 --- a/src/renderer/components/math-notebook/Workspace.vue +++ b/src/renderer/components/math-notebook/Workspace.vue @@ -1,7 +1,8 @@ + + diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 9625f64a..8070d572 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -543,7 +543,7 @@ describe('prev', () => { const results = evalLines('fromunix(1446587186)\nprev + 1 day') expect(results[1].type).toBe('date') expect(results[1].value).toBe( - new Date((1446587186 + 86400) * 1000).toLocaleString(), + new Date((1446587186 + 86400) * 1000).toLocaleString('en-US'), ) }) }) @@ -658,7 +658,7 @@ describe('fromunix', () => { const result = evalLine('fromunix(1446587186) + 2 day') expect(result.type).toBe('date') expect(result.value).toBe( - new Date((1446587186 + 2 * 86400) * 1000).toLocaleString(), + new Date((1446587186 + 2 * 86400) * 1000).toLocaleString('en-US'), ) }) @@ -667,16 +667,20 @@ describe('fromunix', () => { expect(results[0].type).toBe('assignment') expect(results[1].type).toBe('date') expect(results[1].value).toBe( - new Date((1446587186 + 2 * 86400) * 1000).toLocaleString(), + new Date((1446587186 + 2 * 86400) * 1000).toLocaleString('en-US'), ) }) it('local dotted date assignment + 2 day', () => { const results = evalLines('x = 12.03.2025\nx + 2 day') expect(results[0].type).toBe('assignment') - expect(results[0].value).toBe(new Date(2025, 2, 12).toLocaleString()) + expect(results[0].value).toBe( + new Date(2025, 2, 12).toLocaleString('en-US'), + ) expect(results[1].type).toBe('date') - expect(results[1].value).toBe(new Date(2025, 2, 14).toLocaleString()) + expect(results[1].value).toBe( + new Date(2025, 2, 14).toLocaleString('en-US'), + ) }) }) @@ -706,7 +710,7 @@ describe('time zones', () => { const result = evalLine('time() + 1 day') expect(result.type).toBe('date') expect(result.value).toBe( - new Date('2026-03-07T12:00:00Z').toLocaleString(), + new Date('2026-03-07T12:00:00Z').toLocaleString('en-US'), ) }) @@ -720,7 +724,7 @@ describe('time zones', () => { const result = evalLine('now + 1 day') expect(result.type).toBe('date') expect(result.value).toBe( - new Date('2026-03-07T12:00:00Z').toLocaleString(), + new Date('2026-03-07T12:00:00Z').toLocaleString('en-US'), ) }) diff --git a/src/renderer/composables/math-notebook/math-engine/__tests__/format.test.ts b/src/renderer/composables/math-notebook/math-engine/__tests__/format.test.ts new file mode 100644 index 00000000..7aea617c --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/__tests__/format.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { formatMathDate, formatMathNumber } from '../format' + +describe('formatMathNumber', () => { + it('formats integer with en-US locale', () => { + expect(formatMathNumber(1234, 'en-US', 6)).toBe('1,234') + }) + + it('formats integer with de-DE locale', () => { + expect(formatMathNumber(1234, 'de-DE', 6)).toBe('1.234') + }) + + it('formats integer with ru-RU locale', () => { + const result = formatMathNumber(1234, 'ru-RU', 6) + // ru-RU uses narrow no-break space (U+202F) as thousands separator + expect(result.replace(/\s/g, ' ')).toBe('1 234') + }) + + it('formats decimal with en-US locale and 6 decimal places', () => { + expect(formatMathNumber(1 / 3, 'en-US', 6)).toBe('0.333333') + }) + + it('formats decimal with de-DE locale and 6 decimal places', () => { + expect(formatMathNumber(1 / 3, 'de-DE', 6)).toBe('0,333333') + }) + + it('formats decimal with 2 decimal places', () => { + expect(formatMathNumber(1 / 3, 'en-US', 2)).toBe('0.33') + }) + + it('formats decimal with 0 decimal places', () => { + expect(formatMathNumber(1 / 3, 'en-US', 0)).toBe('0') + }) + + it('formats decimal with 14 decimal places', () => { + const result = formatMathNumber(1 / 3, 'en-US', 14) + expect(result).toBe('0.33333333333333') + }) + + it('trims trailing zeros', () => { + expect(formatMathNumber(2, 'en-US', 6)).toBe('2') + }) + + it('formats large number with thousands separator', () => { + const result = formatMathNumber(1234567.89, 'fr-FR', 6) + // fr-FR uses narrow no-break space as thousands separator and comma as decimal + expect(result.replace(/\s/g, ' ')).toBe('1 234 567,89') + }) +}) + +describe('formatMathDate', () => { + const date = new Date(2026, 2, 28) // March 28, 2026 + + it('formats date with en-US locale', () => { + expect(formatMathDate(date, 'en-US')).toBe('3/28/2026, 12:00:00 AM') + }) + + it('formats date with de-DE locale', () => { + expect(formatMathDate(date, 'de-DE')).toBe('28.3.2026, 0:00:00') + }) + + it('formats date with ru-RU locale', () => { + expect(formatMathDate(date, 'ru-RU')).toBe('28.03.2026, 0:00:00') + }) +}) diff --git a/src/renderer/composables/math-notebook/math-engine/format.ts b/src/renderer/composables/math-notebook/math-engine/format.ts new file mode 100644 index 00000000..a3caf01e --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/format.ts @@ -0,0 +1,21 @@ +export function formatMathNumber( + value: number, + locale: string, + decimalPlaces: number, +): string { + return new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: decimalPlaces, + }).format(value) +} + +export function formatMathDate(date: Date, locale: string): string { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }).format(date) +} diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 6d1428f4..b4beed12 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -10,6 +10,7 @@ import { HUMANIZED_UNIT_NAMES, } from './math-engine/constants' import { evaluateCssLine } from './math-engine/css' +import { formatMathDate, formatMathNumber } from './math-engine/format' import { createMathInstance } from './math-engine/mathInstance' import { hasCurrencyExpression, @@ -33,6 +34,9 @@ let currencyServiceState: CurrencyServiceState = 'loading' let currencyUnavailableMessage = '' let math = createMathInstance(activeCurrencyRates) +let activeLocale = 'en-US' +let activeDecimalPlaces = 6 + function detectFormatDirective(line: string): FormatDirective { const formatMap: Record = { 'in hex': 'hex', @@ -102,18 +106,25 @@ function humanizeFormattedUnits(value: string) { return value.replace( /(-?\d[\d,]*(?:\.\d+)?)\s+([a-z][a-z0-9]*)\b/gi, (match, amountText: string, unitId: string) => { - const displayUnit = HUMANIZED_UNIT_NAMES[unitId] - if (!displayUnit) { + const numericAmount = Number.parseFloat(amountText.replace(/,/g, '')) + if (Number.isNaN(numericAmount)) return match - } - const numericAmount = Number.parseFloat(amountText.replace(/,/g, '')) + const formattedAmount = formatMathNumber( + numericAmount, + activeLocale, + activeDecimalPlaces, + ) + const displayUnit = HUMANIZED_UNIT_NAMES[unitId] + if (!displayUnit) + return `${formattedAmount} ${unitId}` + const unitLabel = Math.abs(numericAmount) === 1 ? displayUnit.singular : displayUnit.plural - return `${amountText} ${unitLabel}` + return `${formattedAmount} ${unitLabel}` }, ) } @@ -125,7 +136,7 @@ function formatResult(result: any): LineResult { if (result instanceof Date) { return { - value: result.toLocaleString(), + value: formatMathDate(result, activeLocale), error: null, type: 'date', } @@ -138,18 +149,20 @@ function formatResult(result: any): LineResult { && result.units ) { return { - value: humanizeFormattedUnits(math.format(result, { precision: 6 })), + value: humanizeFormattedUnits( + math.format(result, { precision: activeDecimalPlaces }), + ), error: null, type: 'unit', } } if (typeof result === 'number') { - const formatted = Number.isInteger(result) - ? result.toLocaleString('en-US') - : result.toLocaleString('en-US', { maximumFractionDigits: 6 }) - - return { value: formatted, error: null, type: 'number' } + return { + value: formatMathNumber(result, activeLocale, activeDecimalPlaces), + error: null, + type: 'number', + } } if (typeof result === 'string') { @@ -158,7 +171,9 @@ function formatResult(result: any): LineResult { if (result && typeof result.toString === 'function') { return { - value: humanizeFormattedUnits(math.format(result, { precision: 6 })), + value: humanizeFormattedUnits( + math.format(result, { precision: activeDecimalPlaces }), + ), error: null, type: 'number', } @@ -626,9 +641,15 @@ export function useMathEngine() { } } + function setFormatSettings(locale: string, decimalPlaces: number) { + activeLocale = locale + activeDecimalPlaces = decimalPlaces + } + return { evaluateDocument, setCurrencyServiceState, updateCurrencyRates, + setFormatSettings, } } diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index 5247805f..4ff051ab 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -8,6 +8,7 @@ export const RouterName = { preferencesAppearance: 'preferences/appearance', preferencesEditor: 'preferences/editor', preferencesNotesEditor: 'preferences/notes-editor', + preferencesMath: 'preferences/math', preferencesAPI: 'preferences/api', devtools: 'devtools', devtoolsCaseConverter: 'devtools/case-converter', @@ -68,6 +69,11 @@ const routes = [ name: RouterName.preferencesNotesEditor, component: () => import('@/components/preferences/NotesEditor.vue'), }, + { + path: 'math', + name: RouterName.preferencesMath, + component: () => import('@/components/preferences/Math.vue'), + }, { path: 'api', name: RouterName.preferencesAPI, diff --git a/src/renderer/views/Preferences.vue b/src/renderer/views/Preferences.vue index 7abdd79c..9624ba0f 100644 --- a/src/renderer/views/Preferences.vue +++ b/src/renderer/views/Preferences.vue @@ -5,6 +5,7 @@ import { i18n } from '@/electron' import { router, RouterName } from '@/router' import { isMac } from '@/utils' import { + Calculator, Code2, Globe, HardDrive, @@ -38,6 +39,11 @@ const nav: { label: string, name: string, icon: Component }[] = [ name: RouterName.preferencesNotesEditor, icon: Notebook, }, + { + label: i18n.t('preferences:math.label'), + name: RouterName.preferencesMath, + icon: Calculator, + }, { label: i18n.t('preferences:language.label'), name: RouterName.preferencesLanguage,