Skip to content

Commit 9cfd325

Browse files
committed
Implement calculator syntax highlighting and implicit multiplication support; enhance settings and tests
1 parent c56194c commit 9cfd325

6 files changed

Lines changed: 621 additions & 26 deletions

File tree

src/renderer/inputRouter.js

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ let activeFolderRequestId = null;
2323
let activeFilePickRequestId = null;
2424
const aiFileRefs = new Map(); // label -> absolute path
2525
let inputOverlayEl = null;
26+
let calcSyntaxEnabled = true;
2627

2728
function init() {
2829
const input = document.getElementById('search-input');
@@ -32,6 +33,11 @@ function init() {
3233
syncOverlayScroll(input);
3334
});
3435

36+
// Load calcSyntax setting (default: on)
37+
window.trim.loadSettings().then(s => {
38+
calcSyntaxEnabled = s && s.calcSyntax === false ? false : true;
39+
}).catch(() => {});
40+
3541
window.trim.offFolderSearchUpdate();
3642
window.trim.onFolderSearchUpdate((data) => {
3743
const currentRaw = input.value;
@@ -184,7 +190,14 @@ async function route(rawInput) {
184190
}
185191

186192
if (mode === 'calc') {
187-
const results = window._calculator.search(input.slice(2));
193+
const results = window._calculator.search(input.slice(2), (heavyResults) => {
194+
// Callback for heavy operations (plot, solve, symbolic)
195+
// Only render if we're still in calc mode with the same input
196+
const currentRaw = document.getElementById('search-input').value;
197+
if (currentRaw === rawInput) {
198+
window._ui.renderResults(heavyResults);
199+
}
200+
});
188201
window._ui.renderResults(results);
189202
return;
190203
}
@@ -304,6 +317,13 @@ function renderInputOverlay(inputEl) {
304317
const raw = inputEl.value || '';
305318
const hasRefs = /#\[[^\]]+\]/.test(raw);
306319

320+
// Calc syntax highlighting
321+
const mode = detectMode(raw);
322+
if (mode === 'calc' && calcSyntaxEnabled && raw.length > 2) {
323+
renderCalcOverlay(inputEl, raw);
324+
return;
325+
}
326+
307327
// Check for conversation follow-up hint: input is just the prefix (e.g. "? " or "?? ")
308328
const prefixOnly = /^(\?\??\s*)$/.test(raw);
309329
const inConversation = window._aiQuery && window._aiQuery.isFollowUp();
@@ -364,6 +384,65 @@ function renderInputOverlay(inputEl) {
364384
inputOverlayEl.innerHTML = html;
365385
}
366386

387+
function renderCalcOverlay(inputEl, raw) {
388+
// "c:" prefix is rendered as plain text, only highlight the expression part
389+
const prefix = raw.slice(0, 2); // "c:"
390+
const exprPart = raw.slice(2);
391+
392+
if (!exprPart.trim()) {
393+
inputOverlayEl.innerHTML = '';
394+
inputOverlayEl.style.display = 'none';
395+
inputEl.style.color = '';
396+
inputEl.style.webkitTextFillColor = '';
397+
return;
398+
}
399+
400+
const tokens = window._calculator.getCalcTokens(exprPart);
401+
if (!tokens || tokens.length === 0) {
402+
inputOverlayEl.innerHTML = '';
403+
inputOverlayEl.style.display = 'none';
404+
inputEl.style.color = '';
405+
inputEl.style.webkitTextFillColor = '';
406+
return;
407+
}
408+
409+
inputOverlayEl.style.display = '';
410+
inputEl.style.color = 'transparent';
411+
inputEl.style.webkitTextFillColor = 'transparent';
412+
413+
// Render the prefix as plain text
414+
let html = `<span class="input-overlay-text">${escapeHtml(prefix)}</span>`;
415+
let cursor = 0;
416+
417+
for (const tok of tokens) {
418+
// Preserve whitespace between tokens
419+
if (tok.start > cursor) {
420+
html += `<span class="input-overlay-text">${escapeHtml(exprPart.slice(cursor, tok.start))}</span>`;
421+
}
422+
const escaped = escapeHtml(tok.text);
423+
if (tok.type === 'func') {
424+
html += `<span class="calc-func">${escaped}</span>`;
425+
} else if (tok.type === 'const') {
426+
html += `<span class="calc-const">${escaped}</span>`;
427+
} else if (tok.type === 'unknown') {
428+
html += `<span class="input-overlay-text">${escaped}</span>`;
429+
} else if (tok.type === 'op') {
430+
html += `<span class="calc-op-text">${escaped}</span>`;
431+
} else {
432+
// numbers and other text
433+
html += `<span class="calc-num-text">${escaped}</span>`;
434+
}
435+
cursor = tok.end;
436+
}
437+
438+
// Any trailing text
439+
if (cursor < exprPart.length) {
440+
html += `<span class="input-overlay-text">${escapeHtml(exprPart.slice(cursor))}</span>`;
441+
}
442+
443+
inputOverlayEl.innerHTML = html;
444+
}
445+
367446
function syncOverlayScroll(inputEl) {
368447
if (!inputOverlayEl || inputOverlayEl.style.display === 'none') return;
369448
const containerW = inputOverlayEl.parentElement.clientWidth;
@@ -386,4 +465,4 @@ function isFilePickActive() {
386465
return filePickActive;
387466
}
388467

389-
window._inputRouter = { init, route, detectMode, isFilePickActive, resolveAIFileRefsInQuery, refreshInputDecor };
468+
window._inputRouter = { init, route, detectMode, isFilePickActive, resolveAIFileRefsInQuery, refreshInputDecor, setCalcSyntax(v) { calcSyntaxEnabled = v; } };

src/renderer/modules/calculator.js

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ function findClosingParen(str, start) {
7979
// --- Internal debounce for heavy operations ---
8080
let heavyTimer = null;
8181
let lastHeavyResult = null;
82+
let heavyGeneration = 0; // monotonic counter to discard stale results
8283

83-
function search(expression) {
84+
function search(expression, onHeavyResult) {
8485
if (!expression.trim()) return [];
8586
const trimmed = expression.trim();
8687
const detected = detectMathMode(trimmed);
@@ -92,8 +93,16 @@ function search(expression) {
9293
return handleEvaluate(trimmed);
9394
}
9495

95-
// Heavy operations - debounce internally
96-
return handleHeavy(detected);
96+
// Heavy operations — show loading, compute async
97+
clearTimeout(heavyTimer);
98+
const gen = ++heavyGeneration;
99+
heavyTimer = setTimeout(() => {
100+
const results = handleHeavy(detected);
101+
if (gen === heavyGeneration && typeof onHeavyResult === 'function') {
102+
onHeavyResult(results);
103+
}
104+
}, 0);
105+
return [{ type: 'calc-loading' }];
97106
}
98107

99108
function detectMathMode(expr) {
@@ -526,23 +535,34 @@ const MATH_FUNCS_2 = {
526535
const MATH_CONSTS = { pi: Math.PI, e: Math.E };
527536

528537
function evaluate(expr) {
529-
const tokens = tokenize(expr);
530-
if (!tokens || tokens.length === 0) return undefined;
531-
const ctx = { tokens, pos: 0 };
532-
const result = parseExpr(ctx);
533-
if (ctx.pos < ctx.tokens.length) return undefined;
534-
return result;
538+
try {
539+
const tokens = tokenize(expr);
540+
if (!tokens || tokens.length === 0) return undefined;
541+
const ctx = { tokens, pos: 0 };
542+
const result = parseExpr(ctx);
543+
if (ctx.pos < ctx.tokens.length) return undefined;
544+
// Never return NaN or Infinity — those are domain errors, not valid results
545+
if (typeof result !== 'number' || !isFinite(result)) return undefined;
546+
return result;
547+
} catch {
548+
return undefined;
549+
}
535550
}
536551

537552
function tokenize(expr) {
538553
const out = [];
539-
const s = expr.replace(/\s+/g, '');
554+
const s = expr.replace(/\s+/g, '');
540555
let i = 0;
541556
while (i < s.length) {
542557
const ch = s[i];
543558
if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < s.length && s[i + 1] >= '0' && s[i + 1] <= '9')) {
544559
let num = '';
545-
while (i < s.length && ((s[i] >= '0' && s[i] <= '9') || s[i] === '.')) { num += s[i++]; }
560+
let dots = 0;
561+
while (i < s.length && ((s[i] >= '0' && s[i] <= '9') || s[i] === '.')) {
562+
if (s[i] === '.') dots++;
563+
if (dots > 1) return null; // e.g. "1.2.3" — invalid number
564+
num += s[i++];
565+
}
546566
out.push({ type: 'num', value: parseFloat(num) });
547567
} else if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) {
548568
let id = '';
@@ -562,7 +582,38 @@ function tokenize(expr) {
562582
return null;
563583
}
564584
}
565-
return out;
585+
// Insert implicit multiplication tokens between adjacent value-producing tokens
586+
return insertImplicitMul(out);
587+
}
588+
589+
// Detect adjacencies that imply multiplication and insert * tokens
590+
function insertImplicitMul(tokens) {
591+
if (!tokens || tokens.length < 2) return tokens;
592+
const result = [tokens[0]];
593+
for (let i = 1; i < tokens.length; i++) {
594+
const prev = tokens[i - 1];
595+
const cur = tokens[i];
596+
const needsMul =
597+
// num followed by func: 2sin(...)
598+
(prev.type === 'num' && cur.type === 'func') ||
599+
// num followed by (: 2(3+4)
600+
(prev.type === 'num' && cur.type === 'op' && cur.value === '(') ||
601+
// num followed by num (constant): 2pi
602+
(prev.type === 'num' && cur.type === 'num') ||
603+
// ) followed by (: (2+3)(4+5)
604+
(prev.type === 'op' && prev.value === ')' && cur.type === 'op' && cur.value === '(') ||
605+
// ) followed by func: (2)sin(pi)
606+
(prev.type === 'op' && prev.value === ')' && cur.type === 'func') ||
607+
// ) followed by num: (2+3)4
608+
(prev.type === 'op' && prev.value === ')' && cur.type === 'num') ||
609+
// % followed by num/func/(: 50%2 → 0.5*2
610+
(prev.type === 'op' && prev.value === '%' && (cur.type === 'num' || cur.type === 'func' || (cur.type === 'op' && cur.value === '(')));
611+
if (needsMul) {
612+
result.push({ type: 'op', value: '*' });
613+
}
614+
result.push(cur);
615+
}
616+
return result;
566617
}
567618

568619
function peek(ctx) { return ctx.pos < ctx.tokens.length ? ctx.tokens[ctx.pos] : null; }
@@ -658,11 +709,50 @@ function parsePrimary(ctx) {
658709
throw new Error('unexpected token');
659710
}
660711

712+
// Return token info for syntax highlighting in the input overlay.
713+
// Each token: { text, type: 'func'|'const'|'number'|'op'|'unknown', start, end, name? }
714+
function getCalcTokens(expr) {
715+
const tokens = [];
716+
const s = expr;
717+
let i = 0;
718+
while (i < s.length) {
719+
const ch = s[i];
720+
if (ch === ' ' || ch === '\t') {
721+
i++;
722+
continue;
723+
}
724+
if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < s.length && s[i + 1] >= '0' && s[i + 1] <= '9')) {
725+
const start = i;
726+
while (i < s.length && ((s[i] >= '0' && s[i] <= '9') || s[i] === '.')) i++;
727+
tokens.push({ text: s.slice(start, i), type: 'number', start, end: i });
728+
} else if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) {
729+
const start = i;
730+
while (i < s.length && ((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z'))) i++;
731+
const word = s.slice(start, i);
732+
const lower = word.toLowerCase();
733+
if (MATH_CONSTS[lower] !== undefined) {
734+
tokens.push({ text: word, type: 'const', start, end: i, name: lower });
735+
} else if (MATH_FUNCS[lower] || MATH_FUNCS_2[lower]) {
736+
tokens.push({ text: word, type: 'func', start, end: i, name: lower });
737+
} else {
738+
tokens.push({ text: word, type: 'unknown', start, end: i });
739+
}
740+
} else if ('+-*/^%(),'.includes(ch)) {
741+
tokens.push({ text: ch, type: 'op', start: i, end: i + 1 });
742+
i++;
743+
} else {
744+
tokens.push({ text: ch, type: 'unknown', start: i, end: i + 1 });
745+
i++;
746+
}
747+
}
748+
return tokens;
749+
}
750+
661751
if (typeof window !== 'undefined') {
662-
window._calculator = { search };
752+
window._calculator = { search, getCalcTokens };
663753
}
664754

665755
// Allow tests to import internals directly
666756
if (typeof module !== 'undefined' && module.exports) {
667-
module.exports = { search, evaluate, prepareForNerdamer, findClosingParen, hasVariables, getVariables, detectMathMode };
757+
module.exports = { search, evaluate, prepareForNerdamer, findClosingParen, hasVariables, getVariables, detectMathMode, tokenize };
668758
}

src/renderer/modules/settings.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ async function render() {
128128
const transparency = settings.transparency ?? APPEARANCE_DEFAULTS.transparency;
129129
const transparencyType = settings.transparencyType || APPEARANCE_DEFAULTS.transparencyType;
130130
const showHints = Boolean(settings.showHints);
131+
const calcSyntax = settings.calcSyntax !== false;
131132
const showRevert = isAppearanceDirty(settings);
132133

133134
panel.innerHTML = `
@@ -265,6 +266,15 @@ async function render() {
265266
<div class="settings-description">If disabled, you'll need to reopen TRIM manually to re-enable this.</div>
266267
</div>
267268
269+
<div class="settings-group settings-toggle-group">
270+
<label class="settings-toggle-label" for="settings-calc-syntax">
271+
<span>Calculator Syntax</span>
272+
<input type="checkbox" id="settings-calc-syntax" class="settings-toggle" ${calcSyntax ? 'checked' : ''}>
273+
<span class="settings-toggle-slider"></span>
274+
</label>
275+
<div class="settings-description">Highlight functions and constants in calculator expressions. Mistyped names stay unformatted.</div>
276+
</div>
277+
268278
<!-- ─── Save ─── -->
269279
<div class="settings-save-bar">
270280
<button class="settings-save" id="settings-save-btn">
@@ -597,6 +607,7 @@ async function save() {
597607
const rawTypes = document.getElementById('settings-cached-file-types').value.trim();
598608
const autoStart = document.getElementById('settings-autostart').checked;
599609
const showHints = document.getElementById('settings-show-hints').checked;
610+
const calcSyntax = document.getElementById('settings-calc-syntax').checked;
600611

601612
const accentColor = getSelectedAccent();
602613
const appColor = getSelectedAppColor();
@@ -612,7 +623,7 @@ async function save() {
612623

613624
const settingsData = {
614625
apiKey, model, modelPro, cachedFileTypes, autoStart,
615-
showHints, accentColor, appColor, transparency, transparencyType,
626+
showHints, calcSyntax, accentColor, appColor, transparency, transparencyType,
616627
};
617628

618629
// Include shortcut if it was changed
@@ -632,6 +643,11 @@ async function save() {
632643
// Apply appearance immediately
633644
applyAppearance({ accentColor, appColor, transparency, transparencyType });
634645

646+
// Update calc syntax overlay setting
647+
if (window._inputRouter && window._inputRouter.setCalcSyntax) {
648+
window._inputRouter.setCalcSyntax(calcSyntax);
649+
}
650+
635651
// Apply transparency type (acrylic/mica/none) via main process
636652
if (window.trim.setBackgroundMaterial) {
637653
window.trim.setBackgroundMaterial(transparencyType, appColor);

src/renderer/styles/search.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,3 +937,23 @@ pre:hover .code-copy-btn,
937937
color: var(--text-muted);
938938
font-size: 13px;
939939
}
940+
941+
/* ─── Calculator syntax highlighting ─── */
942+
943+
.calc-func {
944+
color: #4ad4c6;
945+
font-style: italic;
946+
}
947+
948+
.calc-const {
949+
color: #f0c850;
950+
font-style: italic;
951+
}
952+
953+
.calc-op-text {
954+
color: var(--text-secondary);
955+
}
956+
957+
.calc-num-text {
958+
color: var(--text-primary);
959+
}

0 commit comments

Comments
 (0)