diff --git a/js/transpiler/editor/monaco_loader.js b/js/transpiler/editor/monaco_loader.js index d366d253b..301c7a3fb 100644 --- a/js/transpiler/editor/monaco_loader.js +++ b/js/transpiler/editor/monaco_loader.js @@ -38,6 +38,7 @@ function initializeMonacoEditor(monaco, containerId, options = {}) { renderWhitespace: 'selection', tabSize: 2, insertSpaces: true, + glyphMargin: true, // Enable gutter for active LC highlighting decorations wordBasedSuggestions: 'off', // Disable word-based suggestions (use string "off", not boolean) suggest: { showWords: false, diff --git a/js/transpiler/lc_highlighting.js b/js/transpiler/lc_highlighting.js new file mode 100644 index 000000000..806ed1edb --- /dev/null +++ b/js/transpiler/lc_highlighting.js @@ -0,0 +1,138 @@ +/** + * Logic Condition Active Highlighting Module + * + * Provides visual feedback in the Monaco editor showing which Logic Conditions + * are currently TRUE (green checkmarks) or FALSE (gray circles). + */ + +'use strict'; + +/** + * Categorize Logic Conditions by their current status (TRUE/FALSE) + * + * @param {Array} lcStatus - Array of LC status values (0=FALSE, non-zero=TRUE) + * @param {Array} lcConditions - Array of LC condition objects + * @param {Object} lcToLineMapping - Map of LC index to editor line number + * @returns {Object} { trueLCs: number[], falseLCs: number[] } + */ +export function categorizeLCsByStatus(lcStatus, lcConditions, lcToLineMapping) { + const trueLCs = []; + const falseLCs = []; + + for (let lcIndex = 0; lcIndex < lcStatus.length; lcIndex++) { + const status = lcStatus[lcIndex]; + const condition = lcConditions[lcIndex]; + + // Only process enabled LCs that are in our mapping (i.e., visible in the editor) + if (condition && condition.getEnabled && condition.getEnabled() !== 0 && lcToLineMapping[lcIndex] !== undefined) { + if (status !== 0) { + trueLCs.push(lcIndex); + } else { + falseLCs.push(lcIndex); + } + } + } + + return { trueLCs, falseLCs }; +} + +/** + * Map LC indices to editor line numbers with their combined status + * + * Handles cases where multiple LCs map to the same line (shows "mixed" if both TRUE and FALSE exist) + * + * @param {number[]} trueLCs - Array of TRUE LC indices + * @param {number[]} falseLCs - Array of FALSE LC indices + * @param {Object} lcToLineMapping - Map of LC index to editor line number + * @returns {Object} Map of line number to status ('true'|'false'|'mixed') + */ +export function mapLCsToLines(trueLCs, falseLCs, lcToLineMapping) { + const lineStatus = {}; // { lineNum: 'true'|'false'|'mixed' } + + // Process TRUE LCs + for (const lcIndex of trueLCs) { + const line = lcToLineMapping[lcIndex]; + if (line !== undefined) { + if (lineStatus[line] === 'false') { + lineStatus[line] = 'mixed'; // Both true and false LCs on same line + } else if (lineStatus[line] !== 'mixed') { + lineStatus[line] = 'true'; + } + } + } + + // Process FALSE LCs + for (const lcIndex of falseLCs) { + const line = lcToLineMapping[lcIndex]; + if (line !== undefined) { + if (lineStatus[line] === 'true') { + lineStatus[line] = 'mixed'; // Both true and false LCs on same line + } else if (lineStatus[line] !== 'mixed') { + lineStatus[line] = 'false'; + } + } + } + + return lineStatus; +} + +/** + * Create Monaco editor decorations from line status + * + * @param {Object} lineStatus - Map of line number to status ('true'|'false'|'mixed') + * @param {Object} monaco - Monaco editor instance (passed from caller) + * @returns {Array} Array of Monaco decoration objects + */ +export function createMonacoDecorations(lineStatus, monaco) { + return Object.entries(lineStatus).map(([lineNum, status]) => { + // For mixed status, show green checkmark (at least one condition is true) + const className = (status === 'true' || status === 'mixed') ? 'lc-active-true' : 'lc-active-false'; + const message = status === 'mixed' + ? 'Multiple logic conditions: at least one is TRUE' + : (status === 'true' ? 'Logic condition is TRUE' : 'Logic condition is FALSE'); + + return { + range: new monaco.Range(parseInt(lineNum), 1, parseInt(lineNum), 1), + options: { + glyphMarginClassName: className, + glyphMarginHoverMessage: { + value: message + } + } + }; + }); +} + +/** + * Apply decorations to Monaco editor + * + * @param {Object} editor - Monaco editor instance + * @param {Array} currentDecorations - Current decoration IDs + * @param {Array} newDecorations - New decorations to apply + * @returns {Array} Updated decoration IDs + */ +export function applyDecorations(editor, currentDecorations, newDecorations) { + if (!editor || !editor.deltaDecorations) { + return currentDecorations || []; + } + + return editor.deltaDecorations( + currentDecorations || [], + newDecorations + ); +} + +/** + * Clear all decorations from Monaco editor + * + * @param {Object} editor - Monaco editor instance + * @param {Array} currentDecorations - Current decoration IDs to clear + * @returns {Array} Empty array (no decorations) + */ +export function clearDecorations(editor, currentDecorations) { + if (!editor || !editor.deltaDecorations || !currentDecorations) { + return []; + } + + return editor.deltaDecorations(currentDecorations, []); +} diff --git a/js/transpiler/transpiler/codegen.js b/js/transpiler/transpiler/codegen.js index f9df21f06..27859f974 100644 --- a/js/transpiler/transpiler/codegen.js +++ b/js/transpiler/transpiler/codegen.js @@ -68,6 +68,9 @@ class INAVCodeGenerator { constructor(variableHandler = null) { this.lcIndex = 0; // Current logic condition index this.commands = []; + this.lcToLineMapping = {}; // Map LC index -> source line number for highlighting + this.currentSourceLine = null; // Current source line being processed (for line tracking) + this.lineOffset = 0; // Line offset from auto-added imports (set by transpiler) this.errorHandler = new ErrorHandler(); // Error and warning collection this.operandMapping = buildForwardMapping(apiDefinitions); this.arrowHelper = new ArrowFunctionHelper(this); @@ -150,28 +153,42 @@ class INAVCodeGenerator { */ generateStatement(stmt) { if (!stmt) return; - switch (stmt.type) { - case 'EventHandler': - this.generateEventHandler(stmt); - break; - case 'Assignment': - // Top-level assignment (e.g., gvar[0] = value) - runs unconditionally - this.generateTopLevelAssignment(stmt); - break; - case 'StickyAssignment': - // latch1 = sticky({on: ..., off: ...}) - this.generateStickyAssignment(stmt); - break; - case 'LetDeclaration': - case 'VarDeclaration': - // Skip - declarations handled separately - break; - default: - this.errorHandler.addError( - `Unsupported statement type: ${stmt.type}. Only assignments and event handlers are supported`, - stmt, - 'unsupported_statement' - ); + + // Set current source line for LC-to-line tracking + const previousSourceLine = this.currentSourceLine; + if (stmt.loc && stmt.loc.start) { + // Acorn line numbers include auto-added import lines at the top + // Subtract lineOffset to match Monaco editor line numbers + this.currentSourceLine = stmt.loc.start.line - this.lineOffset; + } + + try { + switch (stmt.type) { + case 'EventHandler': + this.generateEventHandler(stmt); + break; + case 'Assignment': + // Top-level assignment (e.g., gvar[0] = value) - runs unconditionally + this.generateTopLevelAssignment(stmt); + break; + case 'StickyAssignment': + // latch1 = sticky({on: ..., off: ...}) + this.generateStickyAssignment(stmt); + break; + case 'LetDeclaration': + case 'VarDeclaration': + // Skip - declarations handled separately + break; + default: + this.errorHandler.addError( + `Unsupported statement type: ${stmt.type}. Only assignments and event handlers are supported`, + stmt, + 'unsupported_statement' + ); + } + } finally { + // Restore previous source line context + this.currentSourceLine = previousSourceLine; } } @@ -248,6 +265,12 @@ class INAVCodeGenerator { this.commands.push( `logic ${lcIndex} 1 ${activatorId} ${operation} ${operandA.type} ${operandA.value} ${operandB.type} ${operandB.value} ${flags}` ); + + // Track source line mapping for transpiler-side highlighting + if (this.currentSourceLine !== null) { + this.lcToLineMapping[lcIndex] = this.currentSourceLine; + } + this.lcIndex++; return lcIndex; } diff --git a/js/transpiler/transpiler/decompiler.js b/js/transpiler/transpiler/decompiler.js index 6bf6f6fff..94c85c4fb 100644 --- a/js/transpiler/transpiler/decompiler.js +++ b/js/transpiler/transpiler/decompiler.js @@ -195,6 +195,8 @@ class Decompiler { // Note: All INAV objects are always imported for user convenience this.inlineDeclaredVars = new Set(); // Track let variables declared inline in body this.hoistedVarCounters = new Map(); // Track counters for hoisted variable names (e.g., min, min2, min3) + this.lcToLineMapping = {}; // Track which LC maps to which line in final JavaScript (for active LC highlighting) + this._tempLcLines = []; // Temporary storage: [{lcIndex, relativeLineOffset}] during generation // Create hoisting manager for activator-wrapped LCs this.hoistingManager = new ActivatorHoistingManager({ @@ -235,6 +237,7 @@ class Decompiler { return { success: true, code: this.generateBoilerplate('// No logic conditions found'), + lcToLineMapping: {}, warnings: this.warnings, stats: { total: logicConditions.length, enabled: 0, groups: 0 } }; @@ -248,9 +251,14 @@ class Decompiler { // Apply custom variable names from variable map as a final rename pass code = this.applyCustomVariableNames(code, enabled); + // Build LC-to-line mapping for active highlighting feature + // Convert tracked lines to actual line numbers in the final code + this.finalizeLcLineMapping(code, enabled); + return { success: true, code, + lcToLineMapping: this.lcToLineMapping, warnings: this.warnings, stats: { total: logicConditions.length, @@ -282,8 +290,8 @@ class Decompiler { // Build mapping: LC index -> generated name (cond1, cond2, etc.) const lcIndexToGeneratedName = new Map(); - for (const [lcIndex, generatedName] of this.hoistingManager.hoistedActivatorVars.entries()) { - lcIndexToGeneratedName.set(lcIndex, generatedName); + for (const [lcIndex, entry] of this.hoistingManager.hoistedActivatorVars.entries()) { + lcIndexToGeneratedName.set(lcIndex, entry.varName); } // For each LC that has both a custom name and a generated name, rename @@ -642,7 +650,11 @@ class Decompiler { } // Build if statement - lines.push(indentStr + `if (${condition}) {`); + const ifLine = indentStr + `if (${condition}) {`; + lines.push(ifLine); + + // Track this LC for line mapping (we'll calculate actual line numbers after boilerplate is added) + this._tempLcLines.push({ lcIndex: node.lc.index, lineContent: ifLine }); // First, output any actions at this level for (const actionNode of actions) { @@ -1168,6 +1180,123 @@ class Decompiler { return declarations; } + /** + * Build mapping from LC indices to line numbers in the generated code + * This enables real-time highlighting of active logic conditions in JavaScript Programming tab + * + * For compound conditions like "if (a && b)", multiple LCs map to the same line: + * - LC#0 (a) → line N + * - LC#1 (b) → line N + * - LC#2 (AND(0,1)) → line N + * This is correct: when any of them is true, we highlight line N. + * + * @param {string} code - Final generated JavaScript code + * @param {Array} conditions - Enabled logic conditions + */ + /** + * Finalize LC-to-line mapping after code generation + * Converts tracked LC lines to actual line numbers in final code + * @param {string} code - Final generated code with boilerplate + * @param {Array} conditions - All logic conditions + */ + finalizeLcLineMapping(code, conditions) { + const lines = code.split('\n'); + + console.log('[Decompiler] finalizeLcLineMapping - tracked lines:', this._tempLcLines); + console.log('[Decompiler] finalizeLcLineMapping - total code lines:', lines.length); + + // First pass: Find tracked if-statements in the final code + for (const tracked of this._tempLcLines) { + const { lcIndex, lineContent } = tracked; + + console.log(`[Decompiler] Looking for LC${lcIndex}:`, lineContent.trim()); + + // Find this line content in the final code + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + if (lines[lineIdx].trim() === lineContent.trim()) { + this.lcToLineMapping[lcIndex] = lineIdx + 1; // Monaco uses 1-based line numbers + console.log(`[Decompiler] Found LC${lcIndex} at line ${lineIdx + 1}`); + break; + } + } + + if (!this.lcToLineMapping[lcIndex]) { + console.warn(`[Decompiler] Could not find LC${lcIndex} in final code`); + } + } + + console.log('[Decompiler] Final mapping:', this.lcToLineMapping); + + // Second pass: Handle hoisted variables (const declarations) + for (const lc of conditions) { + if (lc._gap) continue; + + const hoistedVarName = this.hoistingManager?.getHoistedVarName(lc.index); + if (hoistedVarName && !this.lcToLineMapping[lc.index]) { + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + if (line.match(new RegExp(`const\\s+${hoistedVarName}\\s*=`))) { + this.lcToLineMapping[lc.index] = lineIdx + 1; + break; + } + } + } + } + + // Third pass: Handle sticky/timer variables + for (const lc of conditions) { + if (lc._gap) continue; + + if ((lc.operation === OPERATION.STICKY || lc.operation === OPERATION.TIMER) && !this.lcToLineMapping[lc.index]) { + const varName = this.stickyVarNames.get(lc.index); + if (varName) { + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + if (line.match(new RegExp(`${varName}\\s*=\\s*(?:sticky|timer)`))) { + this.lcToLineMapping[lc.index] = lineIdx + 1; + break; + } + } + } + } + } + + // Fourth pass: Map LCs based on activator relationships + // For LCs with activators (child conditions/actions), inherit the activator's line + for (const lc of conditions) { + if (lc._gap) continue; + + if (lc.activatorId !== -1 && !this.lcToLineMapping[lc.index]) { + const activatorLine = this.lcToLineMapping[lc.activatorId]; + if (activatorLine) { + this.lcToLineMapping[lc.index] = activatorLine; + } + } + } + + // Fifth pass: Handle LCs that are operands of other LCs (compound conditions) + // For example, in "if (a && b)", LC#0 (a) and LC#1 (b) are operands of parent LC + for (const lc of conditions) { + if (lc._gap) continue; + + const operandLCs = []; + if (lc.operandAType === OPERAND_TYPE.LC) { + operandLCs.push(lc.operandAValue); + } + if (lc.operandBType === OPERAND_TYPE.LC) { + operandLCs.push(lc.operandBValue); + } + + if (this.lcToLineMapping[lc.index]) { + for (const operandLcIndex of operandLCs) { + if (!this.lcToLineMapping[operandLcIndex]) { + this.lcToLineMapping[operandLcIndex] = this.lcToLineMapping[lc.index]; + } + } + } + } + } + /** * Generate boilerplate code with proper formatting * @param {string} body - Main code body diff --git a/js/transpiler/transpiler/index.js b/js/transpiler/transpiler/index.js index cacdf4631..84ad741fc 100644 --- a/js/transpiler/transpiler/index.js +++ b/js/transpiler/transpiler/index.js @@ -115,8 +115,9 @@ class Transpiler { const optimized = this.optimize(analyzed.ast); // Step 4: Generate INAV CLI commands - // Pass the analyzer's variableHandler to codegen + // Pass the analyzer's variableHandler and lineOffset to codegen this.codegen.variableHandler = this.analyzer.variableHandler; + this.codegen.lineOffset = lineOffset; const commands = this.codegen.generate(optimized); // Combine all warnings @@ -153,6 +154,7 @@ class Transpiler { success: true, commands, logicConditionCount: this.codegen.lcIndex, + lcToLineMapping: this.codegen.lcToLineMapping, warnings: categorized, optimizations: this.optimizer.getStats(), gvarUsage: gvarSummary, diff --git a/tabs/javascript_programming.html b/tabs/javascript_programming.html index 60f9c5fbe..7377dcce5 100644 --- a/tabs/javascript_programming.html +++ b/tabs/javascript_programming.html @@ -217,5 +217,33 @@
.note_spacer p { margin-bottom: 10px; } + +/* Active LC gutter marker - green checkmark for true conditions */ +.lc-active-true { + background: url('data:image/svg+xml;utf8,') no-repeat center; + background-size: 16px 16px; + width: 16px; + margin-left: 3px; + cursor: pointer; +} + +/* Inactive LC gutter marker - gray hollow circle for false conditions */ +.lc-active-false { + background: url('data:image/svg+xml;utf8,') no-repeat center; + background-size: 16px 16px; + width: 16px; + margin-left: 3px; + cursor: pointer; +} + +/* Monaco gutter margin styling */ +.monaco-editor .margin { + background-color: #f5f5f5; +} + +.monaco-editor .margin-view-overlays .lc-active-true, +.monaco-editor .margin-view-overlays .lc-active-false { + cursor: pointer; +} diff --git a/tabs/javascript_programming.js b/tabs/javascript_programming.js index 3cfcacf0a..1e04543cc 100644 --- a/tabs/javascript_programming.js +++ b/tabs/javascript_programming.js @@ -11,9 +11,11 @@ import mspHelper from './../js/msp/MSPHelper.js'; import { GUI, TABS } from './../js/gui.js'; import FC from './../js/fc.js'; import i18n from './../js/localization.js'; +import interval from './../js/intervals.js'; import { Transpiler } from './../js/transpiler/index.js'; import { Decompiler } from './../js/transpiler/transpiler/decompiler.js'; import * as MonacoLoader from './../js/transpiler/editor/monaco_loader.js'; +import * as LCHighlighting from './../js/transpiler/lc_highlighting.js'; import examples from './../js/transpiler/examples/index.js'; import settingsCache from './../js/settingsCache.js'; import * as monaco from 'monaco-editor'; @@ -38,6 +40,11 @@ TABS.javascript_programming = { decompiler: null, currentCode: '', + // Active LC highlighting state + lcToLineMapping: {}, + activeDecorations: [], + statusChainer: null, + analyticsChanges: {}, initialize: function (callback) { @@ -52,6 +59,9 @@ TABS.javascript_programming = { try { self.initTranspiler(); + // Store monaco reference for use in highlighting + self.monaco = monaco; + // Initialize editor with INAV configuration self.editor = MonacoLoader.initializeMonacoEditor(monaco, 'monaco-editor'); @@ -80,6 +90,9 @@ TABS.javascript_programming = { self.isDirty = true; }); + // Set up LC status polling for active highlighting + self.setupActiveHighlighting(); + // Localize i18n strings i18n.localize(); @@ -185,8 +198,7 @@ if (inav.flight.homeDistance > 100) { }); }, - /* - ** + /** * Load a specific example into the editor * @param {string} exampleId - The ID of the example to load */ @@ -337,6 +349,11 @@ if (inav.flight.homeDistance > 100) { GUI.log(`Transpiled successfully: ${result.logicConditionCount}/64 logic conditions`); + // Store LC-to-line mapping for transpiler-side highlighting + if (result.lcToLineMapping) { + self.lcToLineMapping = result.lcToLineMapping; + } + } else { // Show error this.showError(result.error); @@ -482,7 +499,13 @@ if (inav.flight.homeDistance > 100) { if (!logicConditions || logicConditions.length === 0) { GUI.log(i18n.getMessage('noLogicConditions') || 'No logic conditions found on FC'); self.editor.setValue(self.getDefaultCode()); - self.isDirty = false; + // Clear isDirty flag AFTER setValue completes + setTimeout(() => { + self.isDirty = false; + }, 0); + // Clear stale mapping and decorations + self.lcToLineMapping = {}; + self.clearActiveHighlighting(); if (callback) callback(); return; } @@ -514,6 +537,12 @@ if (inav.flight.homeDistance > 100) { // Set the decompiled code self.editor.setValue(result.code); + // Clear old decorations before setting new mapping + self.clearActiveHighlighting(); + + // Store LC-to-line mapping for active highlighting + self.lcToLineMapping = result.lcToLineMapping || {}; + // Show stats if (result.stats) { GUI.log( @@ -529,7 +558,11 @@ if (inav.flight.homeDistance > 100) { $('#transpiler-warnings').hide(); } - self.isDirty = false; + // Clear isDirty flag AFTER setValue completes (setValue triggers onChange asynchronously) + setTimeout(() => { + self.isDirty = false; + console.log('[JavaScript Programming] isDirty cleared after load'); + }, 0); } else { // Decompilation failed GUI.log('Decompilation failed: ' + result.error); @@ -612,6 +645,11 @@ if (inav.flight.homeDistance > 100) { return; } + // Store LC-to-line mapping for transpiler-side highlighting + if (result.lcToLineMapping) { + self.lcToLineMapping = result.lcToLineMapping; + } + // Confirm save const confirmMsg = i18n.getMessage('confirmSaveLogicConditions') || `Save ${result.logicConditionCount} logic conditions to flight controller?`; @@ -735,9 +773,114 @@ if (inav.flight.homeDistance > 100) { }); }, + /** + * Set up active LC highlighting with status polling + */ + setupActiveHighlighting: function() { + const self = this; + + // Prevent duplicate polling loops if initialize/setup runs multiple times + interval.remove('js_programming_lc_highlight'); + + // In-flight guard to prevent overlapping MSP requests + self._lcPollInFlight = false; + + // Create MSP chainer for polling LC status + self.statusChainer = new MSPChainerClass(); + self.statusChainer.setChain([ + mspHelper.loadLogicConditionsStatus + ]); + self.statusChainer.setExitPoint(function() { + self.updateActiveHighlighting(); + self._lcPollInFlight = false; + }); + + // Start 500ms polling interval (2Hz - sufficient for debugging without saturating MSP) + interval.add('js_programming_lc_highlight', function() { + if (!self.statusChainer || self._lcPollInFlight) return; + self._lcPollInFlight = true; + self.statusChainer.execute(); + }, 500); + }, + + /** + * Update active LC highlighting based on current status + */ + updateActiveHighlighting: function() { + const self = this; + + // Check if FC data is available first (short-circuit if disconnected) + if (!FC.LOGIC_CONDITIONS_STATUS || !FC.LOGIC_CONDITIONS) { + return; + } + + const lcStatus = FC.LOGIC_CONDITIONS_STATUS.getAll(); + const lcConditions = FC.LOGIC_CONDITIONS.get(); + + // Verify data is loaded and has expected types + if (!Array.isArray(lcStatus) || !Array.isArray(lcConditions)) { + return; + } + + // Don't highlight if code has been modified + if (self.isDirty) { + self.clearActiveHighlighting(); + return; + } + + // Don't highlight if no mapping available + if (!self.lcToLineMapping || Object.keys(self.lcToLineMapping).length === 0) { + return; + } + + // Categorize LCs by status (TRUE/FALSE) + const { trueLCs, falseLCs } = LCHighlighting.categorizeLCsByStatus( + lcStatus, + lcConditions, + self.lcToLineMapping + ); + + // Map LCs to editor line numbers with combined status + const lineStatus = LCHighlighting.mapLCsToLines( + trueLCs, + falseLCs, + self.lcToLineMapping + ); + + // Create Monaco decorations from line status + const decorations = LCHighlighting.createMonacoDecorations(lineStatus, self.monaco); + + // Apply decorations to editor + self.activeDecorations = LCHighlighting.applyDecorations( + self.editor, + self.activeDecorations, + decorations + ); + }, + + /** + * Clear all active LC highlighting + */ + clearActiveHighlighting: function() { + const self = this; + self.activeDecorations = LCHighlighting.clearDecorations( + self.editor, + self.activeDecorations + ); + }, + cleanup: function (callback) { console.log('[JavaScript Programming] cleanup() - disposing editor'); + // Stop LC status polling + interval.remove('js_programming_lc_highlight'); + + // Clear active highlighting + this.clearActiveHighlighting(); + + // Clear status chainer + this.statusChainer = null; + // Dispose Monaco editor // Note: Unsaved changes are checked BEFORE cleanup() is called: // - For disconnect: in serial_backend.js