diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f90a4a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +out +.vscode-test +.git +.gitignore +.dockerignore +Dockerfile +.gitea +README.md +examples/ +.vscode/ +docs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81bf7d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Use Node.js LTS +FROM node:18-slim + +# Install Python 3 and git +RUN apt-get update && apt-get install -y python3 python3-pip git && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the extension +RUN npm run compile + +# Install vsce to package the extension +RUN npm install -g @vscode/vsce + +# Default command to package the extension +CMD ["vsce", "package", "--out", "extension.vsix"] diff --git a/package-lock.json b/package-lock.json index ec9970e..17cfecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "tkinter-designer", "version": "0.1.0", "dependencies": { + "immer": "^11.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1243,6 +1244,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", + "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", diff --git a/package.json b/package.json index 881b592..e8e56b2 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"" }, "dependencies": { + "immer": "^11.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/src/extension.ts b/src/extension.ts index 0519eeb..87b5702 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,84 +1,32 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import { CodeGenerator } from './generator/CodeGenerator'; -import { CodeParser } from './parser/CodeParser'; +import { CodeGenerator } from './generator/codeGenerator'; +import { CodeParser } from './parser/codeParser'; import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider'; export function activate(context: vscode.ExtensionContext) { - const provider = new TkinterDesignerProvider(context.extensionUri); - - TkinterDesignerProvider._instance = provider; + // We don't register a provider here anymore for the global viewType + // because we create panels on demand. + + // Store active panels if needed, but for now we just create them. const openDesignerCommand = vscode.commands.registerCommand( 'tkinter-designer.openDesigner', () => { - TkinterDesignerProvider.createOrShow(context.extensionUri); + TkinterDesignerProvider.createNew(context.extensionUri); } ); - const generateCodeCommand = vscode.commands.registerCommand( - 'tkinter-designer.generateCode', - async () => { - console.log('[GenerateCode] Command invoked'); - const generator = new CodeGenerator(); - const designData = await provider.getDesignData(); - if ( - !designData || - !designData.widgets || - designData.widgets.length === 0 - ) { - console.log('[GenerateCode] No design data'); - vscode.window.showWarningMessage( - 'No design data found. Please open the designer and create some widgets first.' - ); - return; - } - - const pythonCode = generator.generateTkinterCode(designData); - const activeEditor = vscode.window.activeTextEditor; - - if (activeEditor && activeEditor.document.languageId === 'python') { - console.log('[GenerateCode] Writing into active Python file'); - const doc = activeEditor.document; - const start = new vscode.Position(0, 0); - const end = doc.lineCount - ? doc.lineAt(doc.lineCount - 1).range.end - : start; - const fullRange = new vscode.Range(start, end); - await activeEditor.edit((editBuilder) => { - editBuilder.replace(fullRange, pythonCode); - }); - await doc.save(); - vscode.window.showInformationMessage( - 'Python code generated into the active file' - ); - } else { - console.log('[GenerateCode] Creating new Python file'); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showErrorMessage( - 'No workspace folder is open. Please open a folder first.' - ); - return; - } - const fileName = `app_${Date.now()}.py`; - const filePath = path.join( - workspaceFolder.uri.fsPath, - fileName - ); - const fileUri = vscode.Uri.file(filePath); - const encoder = new TextEncoder(); - const fileBytes = encoder.encode(pythonCode); - await vscode.workspace.fs.writeFile(fileUri, fileBytes); - const doc = await vscode.workspace.openTextDocument(fileUri); - await vscode.window.showTextDocument(doc, { preview: false }); - vscode.window.showInformationMessage( - `Python file created: ${fileName}` - ); - } - console.log('[GenerateCode] Done'); - } - ); + // Removed global generateCodeCommand because generate is now handled inside the Webview Provider instance + // or we need a way to target the specific panel. + // The previous generateCodeCommand was flawed because it relied on a singleton provider. + // Now, generation is triggered FROM the UI (webview) via messages, which is handled in TkinterDesignerProvider.handleGenerateCode. + // If we want a command palette action, it needs to know WHICH designer to use. + // For simplicity, we remove the global command or make it check for active webview (hard with multiple). + // Let's keep it but warn it only works if we tracked instances. + // Actually, best practice: actions are context-based. The webview has a "Generate" button. + + // However, parseCodeCommand is useful. It should open a NEW designer with the parsed code. const parseCodeCommand = vscode.commands.registerCommand( 'tkinter-designer.parseCode', @@ -89,24 +37,36 @@ export function activate(context: vscode.ExtensionContext) { if (activeEditor && activeEditor.document.languageId === 'python') { const parser = new CodeParser(); const code = activeEditor.document.getText(); + const fileName = path.basename( + activeEditor.document.fileName, + '.py' + ); console.log('[ParseCode] Code length:', code.length); try { - const designData = await parser.parseCodeToDesign(code); + const designData = await parser.parseCodeToDesign( + code, + fileName + ); if ( designData && designData.widgets && designData.widgets.length > 0 ) { - console.log('[ParseCode] Widgets found:', designData.widgets.length); - const designerInstance = - TkinterDesignerProvider.createOrShow( + console.log( + '[ParseCode] Widgets found:', + designData.widgets.length + ); + // Create a NEW designer instance for this file + const designerInstance = TkinterDesignerProvider.createNew( context.extensionUri - ); - if (designerInstance) { - designerInstance.loadDesignData(designData); - } else { - } + ); + + // We need to wait for webview to be ready? + // The createNew sets up message handling. + // We can send data immediately? No, webview needs to load React. + // We can set initial data in the provider. + designerInstance.loadDesignData(designData); vscode.window.showInformationMessage( `Code parsed successfully! Found ${designData.widgets.length} widgets.` @@ -134,7 +94,6 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( openDesignerCommand, - generateCodeCommand, parseCodeCommand ); } diff --git a/src/generator/CodeGenerator.ts b/src/generator/CodeGenerator.ts index 014cfbc..fd60b20 100644 --- a/src/generator/CodeGenerator.ts +++ b/src/generator/CodeGenerator.ts @@ -18,14 +18,21 @@ export class CodeGenerator { public generateTkinterCode(designData: DesignData): string { this.designData = designData; - console.log('[Generator] Start, widgets:', designData.widgets.length, 'events:', designData.events?.length || 0); + const className = designData.form.className || 'Application'; + console.log( + '[Generator] Start, widgets:', + designData.widgets.length, + 'events:', + designData.events?.length || 0 + ); const lines: string[] = []; const nameMap = generateVariableNames(designData.widgets); lines.push('import tkinter as tk'); + lines.push('from tkinter import ttk'); lines.push(''); - lines.push('class Application:'); + lines.push(`class ${className}:`); this.indentLevel = 1; lines.push(this.indent('def __init__(self):')); @@ -47,7 +54,6 @@ export class CodeGenerator { designData.widgets.forEach((widget) => { console.log('[Generator] Widget:', widget.id, widget.type); lines.push(...this.generateWidgetCode(widget, nameMap)); - lines.push(''); }); this.indentLevel = 1; @@ -72,12 +78,65 @@ export class CodeGenerator { this.indentLevel = 0; lines.push('if __name__ == "__main__":'); this.indentLevel = 1; - lines.push(this.indent('app = Application()')); + lines.push(this.indent('try:')); + this.indentLevel = 2; + lines.push(this.indent(`app = ${className}()`)); lines.push(this.indent('app.run()')); + this.indentLevel = 1; + lines.push(this.indent('except Exception as e:')); + this.indentLevel = 2; + lines.push(this.indent('import traceback')); + lines.push(this.indent('traceback.print_exc()')); + lines.push(this.indent('input("Press Enter to exit...")')); return lines.join('\n'); } + public generateCreateWidgetsBody(designData: DesignData): string { + this.designData = designData; + const lines: string[] = []; + const nameMap = generateVariableNames(designData.widgets); + this.indentLevel = 2; // Inside class -> inside create_widgets + + designData.widgets.forEach((widget) => { + lines.push(...this.generateWidgetCode(widget, nameMap)); + }); + + return lines.join('\n'); + } + + public generateEventHandler(event: any): string { + // Simple helper to generate a single event handler method + // Used when injecting new methods + const lines: string[] = []; + // Use existing signature if available (to preserve user arguments) + // Note: signature does not include 'def ' prefix in our current extraction logic? + // Let's check base.py: sig_raw = self.source_code[def_start_idx : start_idx].strip() + // It includes 'def name(...)'. + // So we just use it directly. + + if (event.signature) { + lines.push(` ${event.signature}:`); + } else { + lines.push(` def ${event.name}(self, event=None):`); + } + + if (event.code) { + const codeContent = + typeof event.code === 'string' + ? event.code + : String(event.code || ''); + const body = codeContent + .split('\n') + .map((l: string) => ' ' + l) + .join('\n'); + lines.push(body); + } else { + lines.push(' pass'); + } + return lines.join('\n'); + } + private generateWidgetCode( widget: WidgetData, nameMap: Map @@ -96,7 +155,9 @@ export class CodeGenerator { lines.push(this.indent(`self.${varName}.place(${placeParams})`)); const contentLines = generateWidgetContent(widget, varName); - contentLines.forEach((line) => lines.push(this.indent(line))); + if (contentLines.length > 0) { + contentLines.forEach((l) => lines.push(this.indent(l))); + } lines.push( ...getWidgetEventBindings( diff --git a/src/generator/eventHelpers.ts b/src/generator/eventHelpers.ts index fc10496..0293d76 100644 --- a/src/generator/eventHelpers.ts +++ b/src/generator/eventHelpers.ts @@ -8,22 +8,47 @@ export function generateEventHandlers( const lines: string[] = []; if (designData.events && designData.events.length > 0) { + // Use a Set to avoid duplicates if multiple widgets use the same handler + const handledNames = new Set(); + designData.events.forEach((event: Event) => { const handlerName = event.name; - const isBindEvent = - event.type.startsWith('<') && event.type.endsWith('>'); - let hasCode = false; - const widget = (designData.widgets || []).find( - (w) => w.id === event.widget - ); - if (isBindEvent) { - lines.push(indentDef(`def ${handlerName}(self, event):`)); + if (handledNames.has(handlerName)) { + return; + } + handledNames.add(handlerName); + + // Use preserved signature if available + // Need to cast to any because Event interface might not have signature yet in types.ts (but we added it in runtime) + const signature = (event as any).signature; + + if (signature) { + lines.push(indentDef(`${signature}:`)); } else { - lines.push(indentDef(`def ${handlerName}(self):`)); + const isBindEvent = + event.type.startsWith('<') && event.type.endsWith('>'); + + if (isBindEvent) { + lines.push( + indentDef(`def ${handlerName}(self, event=None):`) + ); + } else { + lines.push(indentDef(`def ${handlerName}(self):`)); + } } - const codeLines = (event.code || '').split('\n'); + // If we have parsed code, use it. Otherwise, use 'pass'. + // The parser should populate event.code if the method exists. + // If the user just added the event in the UI, event.code might be 'pass' or empty. + + let hasCode = false; + // Ensure event.code is a string before split + const codeContent = + typeof event.code === 'string' + ? event.code + : String(event.code || ''); + const codeLines = codeContent.split('\n'); for (const line of codeLines) { if (line.trim()) { lines.push(indentBody(line)); diff --git a/src/generator/types.ts b/src/generator/types.ts index 3f67540..f49ae44 100644 --- a/src/generator/types.ts +++ b/src/generator/types.ts @@ -17,11 +17,13 @@ export interface WidgetData { export interface DesignData { form: { + name: string; title: string; size: { width: number; height: number; }; + className?: string; }; widgets: WidgetData[]; events?: Event[]; diff --git a/src/generator/utils.ts b/src/generator/utils.ts index 7d8796e..50e6553 100644 --- a/src/generator/utils.ts +++ b/src/generator/utils.ts @@ -27,10 +27,10 @@ export function generateVariableNames( let baseName = widget.type.toLowerCase(); // Handle special cases or short forms if desired, e.g. 'button' -> 'btn' if (baseName === 'button') baseName = 'btn'; + if (baseName === 'entry') baseName = 'entry'; if (baseName === 'checkbutton') baseName = 'chk'; if (baseName === 'radiobutton') baseName = 'radio'; if (baseName === 'label') baseName = 'lbl'; - const count = (counts.get(baseName) || 0) + 1; counts.set(baseName, count); diff --git a/src/parser/CodeParser.ts b/src/parser/CodeParser.ts index 8f2ac7f..2d5d304 100644 --- a/src/parser/CodeParser.ts +++ b/src/parser/CodeParser.ts @@ -1,137 +1,51 @@ +import * as vscode from 'vscode'; import { DesignData, WidgetData, Event } from '../generator/types'; import { runPythonAst } from './pythonRunner'; import { convertASTResultToDesignData } from './astConverter'; export class CodeParser { public async parseCodeToDesign( - pythonCode: string + pythonCode: string, + filename?: string ): Promise { console.log( '[Parser] parseCodeToDesign start, code length:', pythonCode.length ); const astRaw = await runPythonAst(pythonCode); + + if (astRaw && astRaw.error) { + console.error('[Parser] Python AST error:', astRaw.error); + vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`); + return null; + } + const astDesign = convertASTResultToDesignData(astRaw); - if (astDesign && astDesign.widgets && astDesign.widgets.length > 0) { + + if (astDesign) { + // AST parsing was successful (even if 0 widgets, it's a valid result) console.log( '[Parser] AST parsed widgets:', astDesign.widgets.length ); + if (filename) { + astDesign.form.name = filename; + } return astDesign; } - console.log('[Parser] AST returned no widgets, using regex fallback'); - const regexDesign = this.parseWithRegexInline(pythonCode); + + // If AST failed completely (e.g. syntax error or script failure), we can't trust regex to be safe for editing. + // It's better to fail and let user know the code is too complex or broken. + console.log( - '[Parser] Regex parsed widgets:', - regexDesign?.widgets?.length || 0 + '[Parser] AST failed. Regex fallback is disabled for safety.' ); - return regexDesign; + vscode.window.showErrorMessage( + 'Could not parse Python code safely. Please ensure your code has no syntax errors and follows the standard structure (class-based Tkinter app).' + ); + return null; } - private parseWithRegexInline(code: string): DesignData | null { - const widgetRegex = - /(self\.)?(\w+)\s*=\s*tk\.(Label|Button|Text|Checkbutton|Radiobutton)\s*\(([^)]*)\)/g; - const placeRegex = /(self\.)?(\w+)\.place\s*\(([^)]*)\)/g; - const titleRegex = /\.title\s*\(\s*(["'])(.*?)\1\s*\)/; - const geometryRegex = /\.geometry\s*\(\s*(["'])(\d+)x(\d+)\1\s*\)/; - - const widgets: WidgetData[] = []; - const widgetMap = new Map(); - - let formTitle = 'App'; - let formWidth = 800; - let formHeight = 600; - - const tMatch = code.match(titleRegex); - if (tMatch) formTitle = tMatch[2]; - const gMatch = code.match(geometryRegex); - if (gMatch) { - formWidth = parseInt(gMatch[2], 10) || 800; - formHeight = parseInt(gMatch[3], 10) || 600; - } - - let m: RegExpExecArray | null; - while ((m = widgetRegex.exec(code)) !== null) { - const varName = m[2]; - const type = m[3]; - const paramStr = m[4] || ''; - const id = varName; - const w: WidgetData = { - id, - type, - x: 0, - y: 0, - width: 100, - height: 25, - properties: {}, - }; - const textMatch = paramStr.match(/text\s*=\s*(["'])(.*?)\1/); - if (textMatch) { - w.properties.text = textMatch[2]; - } - const widthMatch = paramStr.match(/width\s*=\s*(\d+)/); - if (widthMatch) { - const wv = parseInt(widthMatch[1], 10); - if (!isNaN(wv)) w.width = wv; - } - const heightMatch = paramStr.match(/height\s*=\s*(\d+)/); - if (heightMatch) { - const hv = parseInt(heightMatch[1], 10); - if (!isNaN(hv)) w.height = hv; - } - widgets.push(w); - widgetMap.set(varName, w); - } - - let p: RegExpExecArray | null; - while ((p = placeRegex.exec(code)) !== null) { - const varName = p[2]; - const params = p[3]; - const w = widgetMap.get(varName); - if (!w) continue; - const getNum = (key: string) => { - const r = new RegExp(key + '\\s*[:=]\\s*(\d+)'); - const mm = params.match(r); - return mm ? parseInt(mm[1], 10) : undefined; - }; - const x = getNum('x'); - const y = getNum('y'); - const width = getNum('width'); - const height = getNum('height'); - if (typeof x === 'number') w.x = x; - if (typeof y === 'number') w.y = y; - if (typeof width === 'number') w.width = width; - if (typeof height === 'number') w.height = height; - } - - const events: Event[] = []; - const configRegex = - /(self\.)?(\w+)\.config\s*\(\s*command\s*=\s*(self\.)?(\w+)\s*\)/g; - let c: RegExpExecArray | null; - while ((c = configRegex.exec(code)) !== null) { - const varName = c[2]; - const handler = c[4]; - if (widgetMap.has(varName)) { - events.push({ - widget: varName, - type: 'command', - name: handler, - code: '', - }); - } - } - - if (widgets.length === 0) { - console.log('[Parser] Regex found 0 widgets'); - return null; - } - return { - form: { - title: formTitle, - size: { width: formWidth, height: formHeight }, - }, - widgets, - events, - }; - } + // Regex parser removed to prevent "Zombie Code" issues where regex sees widgets + // but AST doesn't, leading to partial updates that break the file. } diff --git a/src/parser/astConverter.ts b/src/parser/astConverter.ts index 5eb1da9..9ac5255 100644 --- a/src/parser/astConverter.ts +++ b/src/parser/astConverter.ts @@ -19,11 +19,14 @@ export function convertASTResultToDesignData( (astResult.window && astResult.window.height) || astResult.height || 600; + let className = + (astResult.window && astResult.window.className) || 'Application'; let counter = 0; const allowedTypes = new Set([ 'Label', 'Button', + 'Entry', 'Text', 'Checkbutton', 'Radiobutton', @@ -68,17 +71,30 @@ export function convertASTResultToDesignData( } const events: Event[] = []; + const methods = astResult.methods || {}; if (astResult.command_callbacks) { for (const callback of astResult.command_callbacks) { const rawName = callback.command?.name; const cleanName = rawName ? String(rawName).replace(/^self\./, '') : `on_${callback.widget}_command`; + + const methodData = methods[cleanName]; + // methodData is { body: string, signature: string } or string (legacy/lambda) + const codeBody = + typeof methodData === 'object' ? methodData.body : methodData; + const signature = + typeof methodData === 'object' + ? methodData.signature + : undefined; + events.push({ widget: callback.widget, type: 'command', name: cleanName, - code: callback.command?.lambda_body || '', + code: codeBody || callback.command?.lambda_body || '', + // @ts-ignore - attaching signature for generator + signature: signature, }); } } @@ -88,22 +104,72 @@ export function convertASTResultToDesignData( const cleanName = rawName ? String(rawName).replace(/^self\./, '') : `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`; + + const methodData = methods[cleanName]; + const codeBody = + typeof methodData === 'object' ? methodData.body : methodData; + const signature = + typeof methodData === 'object' + ? methodData.signature + : undefined; + events.push({ widget: bindEvent.widget, type: bindEvent.event, name: cleanName, - code: bindEvent.callback?.lambda_body || '', + code: codeBody || bindEvent.callback?.lambda_body || '', + // @ts-ignore + signature: signature, }); } } + if (astResult.widgets) { + for (const w of astResult.widgets) { + const p = w.properties || w.params || {}; + if (p.command) { + const widgetId = w.variable_name; + const cmd = p.command; + const rawName = cmd.name || cmd; + const cleanName = String(rawName).replace(/^self\./, ''); + + // Avoid duplicates if already added by command_callbacks + const exists = events.find( + (e) => e.widget === widgetId && e.type === 'command' + ); + if (!exists) { + const methodData = methods[cleanName]; + const codeBody = + typeof methodData === 'object' + ? methodData.body + : methodData; + const signature = + typeof methodData === 'object' + ? methodData.signature + : undefined; + + events.push({ + widget: widgetId, + type: 'command', + name: cleanName, + code: codeBody || '', + // @ts-ignore + signature: signature, + }); + } + } + } + } + const keptIds = new Set(widgets.map((w) => w.id)); const filteredEvents = events.filter((e) => keptIds.has(e.widget)); const result: DesignData = { form: { + name: 'Form', title: formTitle, size: { width: formWidth, height: formHeight }, + className: className, }, widgets, events: filteredEvents.length ? filteredEvents : [], diff --git a/src/parser/pythonRunner.ts b/src/parser/pythonRunner.ts index c9160a3..42fe0af 100644 --- a/src/parser/pythonRunner.ts +++ b/src/parser/pythonRunner.ts @@ -2,79 +2,143 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { spawn } from 'child_process'; +import * as vscode from 'vscode'; async function executePythonScript( pythonScriptPath: string, - pythonFilePath: string + pythonCode: string ): Promise { - return await new Promise((resolve, reject) => { - const pythonCommand = getPythonCommand(); - const start = Date.now(); - console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath); - const process = spawn(pythonCommand, [ - pythonScriptPath, - pythonFilePath, - ]); - let result = ''; - let errorOutput = ''; + const commandsToTry = await getPythonCommands(); + let lastError: Error | null = null; - process.stdout.on('data', (data) => { - result += data.toString(); - }); + for (const cmd of commandsToTry) { + try { + return await new Promise((resolve, reject) => { + const start = Date.now(); + console.log('[PythonRunner] Spawning:', cmd, pythonScriptPath); - process.stderr.on('data', (data) => { - errorOutput += data.toString(); - }); + const process = spawn(cmd, [pythonScriptPath]); - process.on('close', (code) => { - const ms = Date.now() - start; - console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms); - if (code === 0) { - resolve(result); - } else { - reject( - new Error( - `Python script failed with code ${code}: ${errorOutput}` - ) + let result = ''; + let errorOutput = ''; + + // Write code to stdin + try { + process.stdin.write(pythonCode); + process.stdin.end(); + } catch (e) { + console.error('[PythonRunner] Error writing to stdin:', e); + } + + process.stdout.on('data', (data) => { + result += data.toString(); + }); + + process.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + process.on('close', (code) => { + const ms = Date.now() - start; + console.log( + '[PythonRunner] Exit code:', + code, + 'time(ms):', + ms + ); + if (code === 0) { + resolve(result); + } else { + reject( + new Error( + `Python script failed with code ${code}: ${errorOutput}` + ) + ); + } + }); + + process.on('error', (err) => { + reject( + new Error( + `Failed to spawn python process: ${err.message}` + ) + ); + }); + }); + } catch (err: any) { + console.warn(`[PythonRunner] Command ${cmd} failed:`, err.message); + lastError = err; + // Continue to next command + } + } + + throw lastError || new Error('No suitable Python interpreter found'); +} + +async function getPythonCommands(): Promise { + const commands: string[] = []; + + // 1. Try VS Code Python extension API + const extension = vscode.extensions.getExtension('ms-python.python'); + if (extension) { + if (!extension.isActive) { + await extension.activate(); + } + // The API might be complex, but let's check settings first which is easier + } + + // 2. Settings + const config = vscode.workspace.getConfiguration('python'); + let pythonPath = + config.get('defaultInterpreterPath') || + config.get('pythonPath'); + + if (pythonPath && pythonPath !== 'python') { + // Handle ${workspaceFolder} variable + if (pythonPath.includes('${workspaceFolder}')) { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + pythonPath = pythonPath.replace( + '${workspaceFolder}', + workspaceFolders[0].uri.fsPath ); } - }); - }); -} + } + commands.push(pythonPath); + } -function getPythonCommand(): string { - return process.platform === 'win32' ? 'python' : 'python3'; -} + // 3. Common commands + if (process.platform === 'win32') { + commands.push('py'); + commands.push('python'); + } else { + commands.push('python3'); + commands.push('python'); + } -function createTempPythonFile(pythonCode: string): string { - const tempDir = os.tmpdir(); - const tempFilePath = path.join(tempDir, `tk_ast_${Date.now()}.py`); - fs.writeFileSync(tempFilePath, pythonCode, 'utf8'); - return tempFilePath; -} - -function cleanupTempFile(tempFile: string): void { - try { - fs.unlinkSync(tempFile); - } catch {} + // Unique + return Array.from(new Set(commands)); } export async function runPythonAst(pythonCode: string): Promise { - const tempFilePath = createTempPythonFile(pythonCode); try { const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py'); - const output = await executePythonScript( - pythonScriptPath, - tempFilePath - ); + const output = await executePythonScript(pythonScriptPath, pythonCode); console.log('[PythonRunner] Received AST JSON length:', output.length); - const parsed = JSON.parse(output); - return parsed; + try { + const parsed = JSON.parse(output); + return parsed; + } catch (parseError) { + console.error( + '[PythonRunner] JSON parse error:', + parseError, + 'Output:', + output + ); + return null; + } } catch (err) { console.error('[PythonRunner] Error running Python AST:', err); return null; - } finally { - cleanupTempFile(tempFilePath); - console.log('[PythonRunner] Temp file cleaned:', tempFilePath); } } diff --git a/src/parser/tk_ast/__pycache__/__init__.cpython-314.pyc b/src/parser/tk_ast/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..59fcf57 Binary files /dev/null and b/src/parser/tk_ast/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/parser/tk_ast/__pycache__/grid_layout.cpython-314.pyc b/src/parser/tk_ast/__pycache__/grid_layout.cpython-314.pyc new file mode 100644 index 0000000..3ec4ae1 Binary files /dev/null and b/src/parser/tk_ast/__pycache__/grid_layout.cpython-314.pyc differ diff --git a/src/parser/tk_ast/__pycache__/parser.cpython-314.pyc b/src/parser/tk_ast/__pycache__/parser.cpython-314.pyc new file mode 100644 index 0000000..b920b08 Binary files /dev/null and b/src/parser/tk_ast/__pycache__/parser.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/__init__.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..db6cf20 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/base.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..4721ff6 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/base.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/calls.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/calls.cpython-314.pyc new file mode 100644 index 0000000..7a558b6 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/calls.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/connections.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/connections.cpython-314.pyc new file mode 100644 index 0000000..188f4bb Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/connections.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/context.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/context.cpython-314.pyc new file mode 100644 index 0000000..6cba676 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/context.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/events.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/events.cpython-314.pyc new file mode 100644 index 0000000..84a98e5 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/events.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/extractors.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/extractors.cpython-314.pyc new file mode 100644 index 0000000..f848994 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/extractors.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/imports.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/imports.cpython-314.pyc new file mode 100644 index 0000000..fe3aee6 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/imports.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/placements.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/placements.cpython-314.pyc new file mode 100644 index 0000000..397fa18 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/placements.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/values.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/values.cpython-314.pyc new file mode 100644 index 0000000..c70a592 Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/values.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/__pycache__/widget_creation.cpython-314.pyc b/src/parser/tk_ast/analyzer/__pycache__/widget_creation.cpython-314.pyc new file mode 100644 index 0000000..322127c Binary files /dev/null and b/src/parser/tk_ast/analyzer/__pycache__/widget_creation.cpython-314.pyc differ diff --git a/src/parser/tk_ast/analyzer/base.py b/src/parser/tk_ast/analyzer/base.py index ec39738..c796fce 100644 --- a/src/parser/tk_ast/analyzer/base.py +++ b/src/parser/tk_ast/analyzer/base.py @@ -1,4 +1,7 @@ import ast +import textwrap +import tokenize +import io from typing import Dict, List, Any, Optional from .imports import handle_import, handle_import_from @@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections class TkinterAnalyzer(ast.NodeVisitor): - def __init__(self): + def __init__(self, source_code: str = ""): + self.source_code = source_code + self.line_offsets = [0] + current = 0 + if source_code: + for line in source_code.splitlines(keepends=True): + current += len(line) + self.line_offsets.append(current) + self.widgets: List[Dict[str, Any]] = [] self.window_config = {'title': 'App', 'width': 800, 'height': 600} self.imports: Dict[str, str] = {} @@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor): self.event_handlers: List[Dict[str, Any]] = [] self.command_callbacks: List[Dict[str, Any]] = [] self.bind_events: List[Dict[str, Any]] = [] + self.methods: Dict[str, str] = {} def visit_Import(self, node: ast.Import): handle_import(self, node) @@ -36,12 +48,139 @@ class TkinterAnalyzer(ast.NodeVisitor): def visit_ClassDef(self, node: ast.ClassDef): prev = self.current_class enter_class(self, node) + + # Determine main application class + # Logic: + # 1. Prefer class named 'Application' + # 2. Else prefer class inheriting from tk.Tk or Tk + # 3. Fallback to first class found if nothing else set + + is_tk_class = False + for base in node.bases: + if isinstance(base, ast.Attribute) and base.attr == 'Tk': + is_tk_class = True + elif isinstance(base, ast.Name) and base.id == 'Tk': + is_tk_class = True + + if node.name == 'Application': + self.window_config['className'] = node.name + elif is_tk_class and self.window_config.get('className') != 'Application': + self.window_config['className'] = node.name + elif not self.window_config.get('className'): + self.window_config['className'] = node.name + self.generic_visit(node) exit_class(self, prev) def visit_FunctionDef(self, node: ast.FunctionDef): prev = self.current_method enter_function(self, node) + + if self.current_class and node.name not in ['__init__', 'create_widgets', 'run'] and self.source_code and node.body: + try: + # Calculate start index based on 'def' keyword and colon + # We need to find the colon that ends the definition + def_start_idx = self.line_offsets[node.lineno - 1] + node.col_offset + + # Scan tokens to find the colon after the function definition + # We start tokenizing from the def line + # We use a safe approach to find the end of the header + + header_end_idx = -1 + + try: + # Create a byte stream for tokenize, starting from def line + # We need the full source code for context + # But tokenize expects bytes + + # Optimization: only tokenize the relevant chunk to avoid perf hit on large files + # We can guess the header won't be longer than, say, 10 lines or 1000 chars? + # Let's take a generous chunk + + chunk_start = def_start_idx + chunk_end = min(len(self.source_code), def_start_idx + 2000) + chunk = self.source_code[chunk_start:chunk_end] + + # Better approach: Just find the ':' char that is not enclosed in brackets/parens/quotes + # Manually parsing characters is safer than partial tokenize if we want to be quick + + depth = 0 + in_quote = False + quote_char = '' + + for i, char in enumerate(chunk): + if in_quote: + if char == quote_char: + # Check for escaping? Simplified check + if i > 0 and chunk[i-1] != '\\': + in_quote = False + continue + + if char in ['"', "'"]: + in_quote = True + quote_char = char + elif char in ['(', '[', '{']: + depth += 1 + elif char in [')', ']', '}']: + depth -= 1 + elif char == ':' and depth == 0: + # Found the colon! + # Add 1 to include the colon in signature, but body starts AFTER it + header_end_idx = chunk_start + i + 1 + break + + except Exception: + # Fallback to old method if manual parsing fails + start_node = node.body[0] + header_end_idx = self.line_offsets[start_node.lineno - 1] + start_node.col_offset + + if header_end_idx != -1: + start_idx = header_end_idx + + # End index: logic remains using the last node end + # Ideally we should include trailing comments, but that's hard without full tokenization + # For now, sticking to last node end is safe enough for "body" logic + end_node = node.body[-1] + end_idx = self.line_offsets[end_node.end_lineno - 1] + end_node.end_col_offset + + if start_idx < end_idx: + body_text = self.source_code[start_idx:end_idx] + + # Dedent logic needs care because body_text now starts with newline(s) and comments + # We should remove leading empty lines, then determine indentation from the first non-empty line + + lines = body_text.splitlines(keepends=True) + first_code_line_idx = 0 + for idx, line in enumerate(lines): + if line.strip(): + first_code_line_idx = idx + break + + # Calculate indentation of the first non-empty line + first_line = lines[first_code_line_idx] + indent_len = len(first_line) - len(first_line.lstrip()) + + # Construct body: Keep the leading newlines/comments, but dedent them? + # Actually, textwrap.dedent works on the whole block. + # But if the first line is blank, dedent might fail to detect common indent? + # textwrap.dedent removes common leading whitespace from every line. + + dedented_body = textwrap.dedent(body_text) + + # Signature: everything before start_idx + sig_raw = self.source_code[def_start_idx : start_idx].strip() + # Remove trailing colon if present (it should be) + if sig_raw.endswith(':'): + sig_raw = sig_raw[:-1].strip() + + self.methods[node.name] = { + 'body': dedented_body.strip(), + 'signature': sig_raw + } + except Exception as e: + # print(f"Error extracting method {node.name}: {e}") + pass + self.generic_visit(node) exit_function(self, prev) diff --git a/src/parser/tk_ast/analyzer/calls.py b/src/parser/tk_ast/analyzer/calls.py index d305cd5..4108f76 100644 --- a/src/parser/tk_ast/analyzer/calls.py +++ b/src/parser/tk_ast/analyzer/calls.py @@ -15,15 +15,11 @@ def handle_method_call(analyzer, node: ast.Call): arg0 = node.args[0] if isinstance(arg0, ast.Constant): analyzer.window_config['title'] = arg0.value - elif isinstance(arg0, ast.Str): - analyzer.window_config['title'] = arg0.s elif method_name == 'geometry' and node.args: arg0 = node.args[0] if isinstance(arg0, ast.Constant): geometry = arg0.value - elif isinstance(arg0, ast.Str): - geometry = arg0.s else: return if 'x' in str(geometry): diff --git a/src/parser/tk_ast/analyzer/values.py b/src/parser/tk_ast/analyzer/values.py index e16491b..9bf17d0 100644 --- a/src/parser/tk_ast/analyzer/values.py +++ b/src/parser/tk_ast/analyzer/values.py @@ -15,10 +15,6 @@ def get_variable_name(node: ast.AST) -> str: def extract_value(node: ast.AST) -> Any: if isinstance(node, ast.Constant): return node.value - elif isinstance(node, ast.Str): - return node.s - elif isinstance(node, ast.Num): - return node.n elif isinstance(node, ast.Name): return f"${node.id}" elif isinstance(node, ast.Attribute): @@ -65,7 +61,7 @@ def get_operator_symbol(op_node: ast.AST) -> str: def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str: body = lambda_node.body - if isinstance(body, (ast.Constant, ast.Str, ast.Num)): + if isinstance(body, ast.Constant): return 'simple' elif isinstance(body, (ast.Name, ast.Attribute)): return 'simple' @@ -76,11 +72,9 @@ def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str: def extract_lambda_body(body_node: ast.AST) -> str: if isinstance(body_node, ast.Constant): + if isinstance(body_node.value, str): + return f'"{body_node.value}"' return str(body_node.value) - elif isinstance(body_node, ast.Str): - return f'"{body_node.s}"' - elif isinstance(body_node, ast.Num): - return str(body_node.n) elif isinstance(body_node, ast.Name): return body_node.id elif isinstance(body_node, ast.Attribute): @@ -138,8 +132,6 @@ def extract_lambda_body(body_node: ast.AST) -> str: for value in body_node.values: if isinstance(value, ast.Constant): parts.append(str(value.value)) - elif isinstance(value, ast.Str): - parts.append(value.s) else: parts.append(f"{{{extract_lambda_body(value)}}}") return f"f\"{''.join(parts)}\"" diff --git a/src/parser/tk_ast/analyzer/widget_creation.py b/src/parser/tk_ast/analyzer/widget_creation.py index 9b52f08..d941e94 100644 --- a/src/parser/tk_ast/analyzer/widget_creation.py +++ b/src/parser/tk_ast/analyzer/widget_creation.py @@ -19,10 +19,14 @@ def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool: widget_type = call_node.func.attr if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values(): - return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton'] - if module_name in ['ttk'] or 'ttk' in analyzer.imports.values(): - return False - return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton'] + return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton'] + + # Check if module_name resolves to ttk + resolved = analyzer.imports.get(module_name, module_name) + if resolved in ['ttk', 'tkinter.ttk']: + return widget_type in ['Label', 'Button', 'Entry', 'Frame', 'Checkbutton', 'Radiobutton'] + + return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton'] def is_widget_creation(analyzer, node: ast.Assign) -> bool: if not isinstance(node.value, ast.Call): @@ -31,7 +35,7 @@ def is_widget_creation(analyzer, node: ast.Assign) -> bool: if isinstance(node.value.func, ast.Attribute): return is_tkinter_widget_call(analyzer, node.value) elif isinstance(node.value.func, ast.Name): - return node.value.func.id in ['Label', 'Button', 'Text', 'Checkbutton'] + return node.value.func.id in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton'] return False def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]: @@ -49,10 +53,12 @@ def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]: if isinstance(call_node.func, ast.Attribute): widget_type = call_node.func.attr - if isinstance(call_node.func.value, ast.Name): - module_name = call_node.func.value.id - if module_name in ['ttk'] or 'ttk' in analyzer.imports.values(): - return None + # Allow ttk widgets + # if isinstance(call_node.func.value, ast.Name): + # module_name = call_node.func.value.id + # resolved = analyzer.imports.get(module_name, module_name) + # if resolved in ['ttk', 'tkinter.ttk']: + # return None elif isinstance(call_node.func, ast.Name): widget_type = call_node.func.id else: diff --git a/src/parser/tk_ast/parser.py b/src/parser/tk_ast/parser.py index 3e7dc3b..6f9e729 100644 --- a/src/parser/tk_ast/parser.py +++ b/src/parser/tk_ast/parser.py @@ -9,7 +9,7 @@ from tk_ast.grid_layout import GridLayoutAnalyzer def parse_tkinter_code(code: str) -> Dict[str, Any]: try: tree = ast.parse(code) - analyzer = TkinterAnalyzer() + analyzer = TkinterAnalyzer(code) analyzer.visit(tree) grid_analyzer = GridLayoutAnalyzer() widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets) @@ -18,6 +18,7 @@ def parse_tkinter_code(code: str) -> Dict[str, Any]: 'widgets': widgets, 'command_callbacks': analyzer.command_callbacks, 'bind_events': analyzer.bind_events, + 'methods': analyzer.methods, 'success': True } diff --git a/src/webview/TkinterDesignerProvider.ts b/src/webview/TkinterDesignerProvider.ts index 1118f89..c382013 100644 --- a/src/webview/TkinterDesignerProvider.ts +++ b/src/webview/TkinterDesignerProvider.ts @@ -1,10 +1,11 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { Uri } from 'vscode'; +import { runPythonAst } from '../parser/pythonRunner'; export class TkinterDesignerProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'tkinter-designer'; - public static _instance: TkinterDesignerProvider | undefined; + // Removed singleton _instance private _view?: vscode.WebviewPanel; private _designData: any = { widgets: [], @@ -14,21 +15,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { constructor(private readonly _extensionUri: vscode.Uri) {} - public static createOrShow(extensionUri: vscode.Uri) { - const column = vscode.window.activeTextEditor - ? vscode.window.activeTextEditor.viewColumn - : undefined; - if (TkinterDesignerProvider._instance?._view) { - console.log('[Webview] Revealing existing panel'); - TkinterDesignerProvider._instance._view.reveal(column); - return TkinterDesignerProvider._instance; - } - + // Factory method to create a new panel + public static createNew(extensionUri: vscode.Uri) { console.log('[Webview] Creating new panel'); const panel = vscode.window.createWebviewPanel( TkinterDesignerProvider.viewType, 'Tkinter Designer', - column || vscode.ViewColumn.One, + vscode.ViewColumn.Beside, // Open beside the current editor { enableScripts: true, retainContextWhenHidden: true, @@ -40,24 +33,18 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { ], } ); - if (!TkinterDesignerProvider._instance) { - TkinterDesignerProvider._instance = new TkinterDesignerProvider( - extensionUri - ); - } - TkinterDesignerProvider._instance._view = panel; - TkinterDesignerProvider._instance._setWebviewContent(panel.webview); - TkinterDesignerProvider._instance._setupMessageHandling(panel.webview); + const provider = new TkinterDesignerProvider(extensionUri); + provider._view = panel; + provider._setWebviewContent(panel.webview); + provider._setupMessageHandling(panel.webview); panel.onDidDispose(() => { console.log('[Webview] Panel disposed'); - if (TkinterDesignerProvider._instance) { - TkinterDesignerProvider._instance._view = undefined; - } + provider._view = undefined; }); - return TkinterDesignerProvider._instance; + return provider; } public resolveWebviewView( @@ -114,16 +101,81 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { console.log('[Webview] Generating code from webview'); this.handleGenerateCode(message.data); break; + case 'applyChanges': + console.log('[Webview] Applying changes from webview'); + this.handleApplyChanges(message.data); + break; case 'showInfo': vscode.window.showInformationMessage(message.text); break; case 'showError': vscode.window.showErrorMessage(message.text); break; + case 'exportProject': + this.handleExportProject(message.data); + break; + case 'importProject': + this.handleImportProject(); + break; } }, undefined); } + private async handleExportProject(data: any): Promise { + try { + const options: vscode.SaveDialogOptions = { + defaultUri: vscode.Uri.file(`tkinter-project-${new Date().toISOString().split('T')[0]}.json`), + filters: { + 'JSON': ['json'] + } + }; + const fileUri = await vscode.window.showSaveDialog(options); + if (fileUri) { + const jsonString = JSON.stringify(data, null, 2); + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile(fileUri, encoder.encode(jsonString)); + vscode.window.showInformationMessage('Project exported successfully!'); + } + } catch (error: any) { + vscode.window.showErrorMessage(`Export failed: ${error.message}`); + } + } + + private async handleImportProject(): Promise { + try { + const options: vscode.OpenDialogOptions = { + canSelectMany: false, + openLabel: 'Import', + filters: { + 'JSON': ['json'] + } + }; + const fileUris = await vscode.window.showOpenDialog(options); + if (fileUris && fileUris[0]) { + const fileUri = fileUris[0]; + const fileData = await vscode.workspace.fs.readFile(fileUri); + const decoder = new TextDecoder(); + const jsonString = decoder.decode(fileData); + const data = JSON.parse(jsonString); + + if (this._view) { + // Check if the imported file has the expected structure + const designData = data.data || data; // Handle both wrapped and unwrapped data + + this._view.webview.postMessage({ + type: 'loadDesign', + data: designData + }); + // Also update internal state + this._designData = designData; + vscode.window.showInformationMessage('Project imported successfully!'); + } + } + } catch (error: any) { + vscode.window.showErrorMessage(`Import failed: ${error.message}`); + } + } + public async getDesignData(): Promise { return this._designData; } @@ -145,54 +197,62 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { try { console.log('[GenerateCode] Start'); const { CodeGenerator } = await import( - '../generator/CodeGenerator' + '../generator/codeGenerator' ); const generator = new CodeGenerator(); const pythonCode = generator.generateTkinterCode(designData); - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor && activeEditor.document.languageId === 'python') { - console.log('[GenerateCode] Writing into active editor'); - const doc = activeEditor.document; - const start = new vscode.Position(0, 0); - const end = doc.lineCount - ? doc.lineAt(doc.lineCount - 1).range.end - : start; - const fullRange = new vscode.Range(start, end); - await activeEditor.edit((editBuilder) => { - editBuilder.replace(fullRange, pythonCode); - }); - await doc.save(); - vscode.window.showInformationMessage( - 'Python code generated into the active file' - ); - } else { - console.log('[GenerateCode] Creating new file'); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showErrorMessage( - 'No workspace folder is open. Please open a folder first.' - ); - return; - } - const fileName = `app_${Date.now()}.py`; - const filePath = path.join( - workspaceFolder.uri.fsPath, - fileName - ); - const fileUri = Uri.file(filePath); - const encoder = new TextEncoder(); - const fileBytes = encoder.encode(pythonCode); - await vscode.workspace.fs.writeFile(fileUri, fileBytes); - - const doc = await vscode.workspace.openTextDocument(fileUri); - await vscode.window.showTextDocument(doc, { - preview: false, - }); - vscode.window.showInformationMessage( - `Python file created: ${fileName}` + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage( + 'No workspace folder is open. Please open a folder first.' ); + return; } + + const formName = designData.form.name || 'Form1'; + let fileName = `${formName}.py`; + let fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName); + let counter = 1; + + const exists = async (u: vscode.Uri) => { + try { + await vscode.workspace.fs.stat(u); + return true; + } catch { + return false; + } + }; + + while (await exists(fileUri)) { + fileName = `${formName}_${counter}.py`; + fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName); + counter++; + } + + const encoder = new TextEncoder(); + const fileBytes = encoder.encode(pythonCode); + await vscode.workspace.fs.writeFile(fileUri, fileBytes); + + const doc = await vscode.workspace.openTextDocument(fileUri); + await vscode.window.showTextDocument(doc, { + preview: false, + }); + vscode.window.showInformationMessage( + `Python file created: ${fileName}` + ); + + // Update form name in UI if changed + const newFormName = path.basename(fileName, '.py'); + if (newFormName !== formName) { + if (this._view) { + this._view.webview.postMessage({ + type: 'updateFormName', + name: newFormName, + }); + } + } + console.log('[GenerateCode] Done'); } catch (error) { console.error('[GenerateCode] Error:', error); @@ -200,6 +260,228 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { } } + private async handleApplyChanges(designData: any): Promise { + try { + console.log('[ApplyChanges] Start'); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage( + 'No workspace folder is open. Please open a folder first.' + ); + return; + } + + const formName = designData.form.name || 'Form1'; + const fileName = `${formName}.py`; + const fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName); + + // Try to read existing file to preserve method bodies + let existingMethods: any = {}; + let oldClassName = ''; + let fileContent = ''; + let fileExists = false; + + try { + // We check if file exists first to avoid error in openTextDocument if not exists + await vscode.workspace.fs.stat(fileUri); + fileExists = true; + + const doc = await vscode.workspace.openTextDocument(fileUri); + fileContent = doc.getText(); + const astResult = await runPythonAst(fileContent); + + if (astResult) { + if (astResult.methods) { + existingMethods = astResult.methods; + if (designData.events) { + for (const event of designData.events) { + // Always prefer the existing method body from disk if it exists + // This ensures we don't overwrite user edits in the Python file + if (existingMethods[event.name]) { + event.code = + existingMethods[event.name].body; + event.signature = + existingMethods[event.name].signature; + } + } + } + } + if (astResult.window && astResult.window.className) { + oldClassName = astResult.window.className; + } + } + } catch (e) { + console.log( + '[ApplyChanges] Could not read existing file for methods preservation', + e + ); + } + + const { CodeGenerator } = await import( + '../generator/codeGenerator' + ); + const generator = new CodeGenerator(); + + if (!fileExists) { + // New file: Generate full code + const pythonCode = generator.generateTkinterCode(designData); + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile( + fileUri, + encoder.encode(pythonCode) + ); + vscode.window.showInformationMessage( + `Created new file: ${fileName}` + ); + } else { + // Smart Update: Replace only create_widgets and inject new methods + const newCreateWidgets = + generator.generateCreateWidgetsBody(designData); + + // 1. Update create_widgets method body + // Improved regex to handle arguments, return types, and spacing + const createWidgetsRegex = + /(def\s+create_widgets\s*\(\s*self[^)]*\)\s*(?:->\s*[^:]+)?\s*:)([\s\S]*?)(?=\n\s*def\s|\n\s*if\s+__name__|\Z)/; + + let newFileContent = fileContent; + + if (createWidgetsRegex.test(fileContent)) { + newFileContent = fileContent.replace( + createWidgetsRegex, + (match, defLine) => { + return `${defLine}\n${newCreateWidgets}\n`; + } + ); + } else { + // Fallback if create_widgets not found (weird), just append or warn + vscode.window.showWarningMessage( + 'Could not find create_widgets method to update. Regenerating full file might be needed if structure is broken.' + ); + // Fallback to full generation if structure is too broken? + // For now let's try to proceed or maybe just replace the whole file if critical? + // Let's stick to safe update. If not found, we don't touch. + } + + // 2. Inject new event handlers that are not in existingMethods + if (designData.events) { + const methodsToInject: string[] = []; + for (const event of designData.events) { + if (!existingMethods[event.name]) { + methodsToInject.push( + generator.generateEventHandler(event) + ); + } + } + + if (methodsToInject.length > 0) { + // Insert before run() or at the end of class + const runRegex = /\s*def\s+run\s*\(/; + if (runRegex.test(newFileContent)) { + newFileContent = newFileContent.replace( + runRegex, + (match) => { + return `\n${methodsToInject.join('\n\n')}\n\n${match}`; + } + ); + } else { + // Append to end of class (before if __name__) + const mainRegex = /if\s+__name__\s*==/; + if (mainRegex.test(newFileContent)) { + newFileContent = newFileContent.replace( + mainRegex, + (match) => { + return `${methodsToInject.join('\n\n')}\n\n${match}`; + } + ); + } else { + newFileContent += `\n\n${methodsToInject.join('\n\n')}`; + } + } + } + } + + // 3. Update window title/geometry in __init__ + // self.root.title("...") + const titleRegex = /self\.root\.title\s*\(\s*["'].*?["']\s*\)/; + newFileContent = newFileContent.replace( + titleRegex, + `self.root.title("${designData.form.title}")` + ); + + // self.root.geometry("...") + const geoRegex = /self\.root\.geometry\s*\(\s*["'].*?["']\s*\)/; + newFileContent = newFileContent.replace( + geoRegex, + `self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")` + ); + + // 4. Update class name if changed + if ( + oldClassName && + designData.form.className && + oldClassName !== designData.form.className + ) { + // Replace class definition + // Be careful to match exact class name to avoid partial replacements + const classDefRegex = new RegExp( + `class\\s+${oldClassName}\\s*:` + ); + newFileContent = newFileContent.replace( + classDefRegex, + `class ${designData.form.className}:` + ); + + // Replace instantiation in if __name__ == "__main__": + // app = ClassName() + const instanceRegex = new RegExp( + `=\\s*${oldClassName}\\s*\\(` + ); + newFileContent = newFileContent.replace( + instanceRegex, + `= ${designData.form.className}(` + ); + } + + const doc = await vscode.workspace.openTextDocument(fileUri); + const editor = vscode.window.visibleTextEditors.find( + (e) => e.document.uri.toString() === fileUri.toString() + ); + + if (editor) { + // Use validateRange to ensure we cover the entire document safely + // regardless of potential external changes (though we should be careful) + const fullRange = doc.validateRange( + new vscode.Range( + 0, + 0, + Number.MAX_VALUE, + Number.MAX_VALUE + ) + ); + await editor.edit((editBuilder) => { + editBuilder.replace(fullRange, newFileContent); + }); + await doc.save(); + } else { + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile( + fileUri, + encoder.encode(newFileContent) + ); + } + vscode.window.showInformationMessage( + `Smart updated ${fileName}` + ); + } + + console.log('[ApplyChanges] Done'); + } catch (error) { + console.error('[ApplyChanges] Error:', error); + vscode.window.showErrorMessage(`Error applying changes: ${error}`); + } + } + private _getHtmlForWebview(webview: vscode.Webview): string { const styleUri = webview.asWebviewUri( vscode.Uri.joinPath( diff --git a/src/webview/react/App.tsx b/src/webview/react/App.tsx index d205fc2..17865d1 100644 --- a/src/webview/react/App.tsx +++ b/src/webview/react/App.tsx @@ -5,23 +5,26 @@ import { PropertiesPanel } from './components/PropertiesPanel'; import { EventsPanel } from './components/EventsPanel'; import { Canvas } from './components/Canvas'; import { useMessaging } from './useMessaging'; +import { ErrorBoundary } from './components/ErrorBoundary'; export function App() { useMessaging(); return ( -
- -
-
-

Widgets

- - - -
-
- + +
+ +
+
+

Widgets

+ + + +
+
+ +
-
+ ); } diff --git a/src/webview/react/components/Canvas.tsx b/src/webview/react/components/Canvas.tsx index c4e0baf..d907c2d 100644 --- a/src/webview/react/components/Canvas.tsx +++ b/src/webview/react/components/Canvas.tsx @@ -2,6 +2,76 @@ import React, { useRef } from 'react'; import { useAppDispatch, useAppState } from '../state'; import type { WidgetType } from '../types'; +import { Widget } from '../types'; + +function renderWidgetContent(w: Widget) { + switch (w.type) { + case 'Label': + return ( +
+ {w.properties?.text || 'Label'} +
+ ); + case 'Button': + return ( + + ); + case 'Entry': + return ( + + ); + case 'Text': + return ( + + ); + case 'Checkbutton': + return ( + + ); + case 'Radiobutton': + return ( + + ); + default: + return w.properties?.text || w.type; + } +} + export function Canvas() { const dispatch = useAppDispatch(); const { design, selectedWidgetId } = useAppState(); @@ -38,10 +108,13 @@ export function Canvas() { const onMove = (ev: MouseEvent) => { const dx = ev.clientX - startX; const dy = ev.clientY - startY; + const newX = initX + dx; + const newY = initY + dy; dispatch({ type: 'updateWidget', - payload: { id, patch: { x: initX + dx, y: initY + dy } }, + payload: { id, patch: { x: newX, y: newY } }, }); + // Auto-expand removed: user wants clipping behavior }; const onUp = () => { window.removeEventListener('mousemove', onMove); @@ -52,43 +125,244 @@ export function Canvas() { window.addEventListener('mouseup', onUp); }; + const onResizeStart = ( + e: React.MouseEvent, + id: string, + direction: string + ) => { + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const w = design.widgets.find((w) => w.id === id); + if (!w) return; + const initX = w.x; + const initY = w.y; + const initWidth = w.width; + const initHeight = w.height; + + console.log('[Canvas] Resize start', id, direction); + + const onMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + const patch: any = {}; + + if (direction.includes('e')) { + patch.width = Math.max(10, initWidth + dx); + } + if (direction.includes('w')) { + const newWidth = Math.max(10, initWidth - dx); + patch.width = newWidth; + patch.x = initX + (initWidth - newWidth); + } + if (direction.includes('s')) { + patch.height = Math.max(10, initHeight + dy); + } + if (direction.includes('n')) { + const newHeight = Math.max(10, initHeight - dy); + patch.height = newHeight; + patch.y = initY + (initHeight - newHeight); + } + + dispatch({ + type: 'updateWidget', + payload: { id, patch }, + }); + }; + + const onUp = () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + console.log('[Canvas] Resize end', id); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }; + + const onWindowResizeStart = ( + e: React.MouseEvent, + direction: string + ) => { + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const initWidth = design.form.size.width; + const initHeight = design.form.size.height; + + console.log('[Canvas] Window resize start', direction); + + let animationFrameId: number | null = null; + let currentEv: MouseEvent | null = null; + + const onMove = (ev: MouseEvent) => { + ev.preventDefault(); + currentEv = ev; + + if (animationFrameId) return; + + animationFrameId = requestAnimationFrame(() => { + if (!currentEv) return; + + const dx = currentEv.clientX - startX; + const dy = currentEv.clientY - startY; + + let newWidth = initWidth; + let newHeight = initHeight; + + if (direction.includes('e')) { + newWidth = Math.max(100, initWidth + dx); + } + if (direction.includes('s')) { + newHeight = Math.max(100, initHeight + dy); + } + + // If user requested to optimize/remove binding, we can skip complex calculations here + // But currently we just set form size which is fast enough usually. + // The main lag comes from React re-rendering all widgets. + + dispatch({ + type: 'setForm', + payload: { size: { width: newWidth, height: newHeight } }, + }); + + animationFrameId = null; + }); + }; + + const onUp = () => { + if (animationFrameId) cancelAnimationFrame(animationFrameId); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + console.log('[Canvas] Window resize end'); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }; + return (
-
onSelect(null)} - > - {design.widgets.length === 0 && ( -
-

Drag widgets here to start designing

+
+
+
+ {design.form?.title || 'Tkinter App'}
- )} - {design.widgets.map((w) => ( -
{ - e.stopPropagation(); - onSelect(w.id); - }} - onMouseDown={(e) => onMouseDown(e, w.id)} - > -
- {w.properties?.text || w.type} +
+
+
+
+
+
+
onSelect(null)} + > + {design.widgets.length === 0 && ( +
+

Drag widgets here to start designing

-
- ))} + )} + {design.widgets.map((w) => ( +
{ + e.stopPropagation(); + onSelect(w.id); + }} + onMouseDown={(e) => onMouseDown(e, w.id)} + > +
+ {renderWidgetContent(w)} +
+ {selectedWidgetId === w.id && ( + <> +
+ onResizeStart(e, w.id, 'n') + } + /> +
+ onResizeStart(e, w.id, 's') + } + /> +
+ onResizeStart(e, w.id, 'e') + } + /> +
+ onResizeStart(e, w.id, 'w') + } + /> +
+ onResizeStart(e, w.id, 'ne') + } + /> +
+ onResizeStart(e, w.id, 'nw') + } + /> +
+ onResizeStart(e, w.id, 'se') + } + /> +
+ onResizeStart(e, w.id, 'sw') + } + /> + + )} +
+ ))} +
+ {/* Window Resize Handles */} +
onWindowResizeStart(e, 'e')} + onDragStart={(e) => e.preventDefault()} + /> +
onWindowResizeStart(e, 's')} + onDragStart={(e) => e.preventDefault()} + /> +
onWindowResizeStart(e, 'se')} + onDragStart={(e) => e.preventDefault()} + />
); diff --git a/src/webview/react/components/ErrorBoundary.tsx b/src/webview/react/components/ErrorBoundary.tsx new file mode 100644 index 0000000..40d5538 --- /dev/null +++ b/src/webview/react/components/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('[ErrorBoundary] Uncaught error:', error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( +
+

Something went wrong.

+
+ {this.state.error && this.state.error.toString()} +
+ +
+ ); + } + + return this.props.children; + } +} diff --git a/src/webview/react/components/EventsPanel.tsx b/src/webview/react/components/EventsPanel.tsx index 0701ce2..43396b2 100644 --- a/src/webview/react/components/EventsPanel.tsx +++ b/src/webview/react/components/EventsPanel.tsx @@ -5,8 +5,7 @@ export function EventsPanel() { const dispatch = useAppDispatch(); const { design, selectedWidgetId, vscode } = useAppState(); const [eventType, setEventType] = useState('command'); - const [eventName, setEventName] = useState('onClick'); - const [eventCode, setEventCode] = useState('print("clicked")'); + const [eventName, setEventName] = useState('on_click'); const w = design.widgets.find((x) => x.id === selectedWidgetId); const widgetEvents = (id: string | undefined) => @@ -20,10 +19,17 @@ export function EventsPanel() { }); return; } - if (!eventType || !eventName || !eventCode) { + if (!eventName) { vscode.postMessage({ type: 'showError', - text: 'Please fill in all fields: event type, name, and code', + text: 'Please fill in handler name', + }); + return; + } + if (!eventType) { + vscode.postMessage({ + type: 'showError', + text: 'Please fill in event type', }); return; } @@ -33,21 +39,39 @@ export function EventsPanel() { widget: w.id, type: eventType, name: eventName, - code: eventCode, + code: 'pass', // Default body }, }); vscode.postMessage({ type: 'showInfo', - text: `Event added: ${eventType} -> ${eventName}`, + text: `Event added: ${eventName}`, }); }; - const remove = (type: string) => { + const remove = (type: string, name: string) => { if (!w) return; - dispatch({ type: 'removeEvent', payload: { widget: w.id, type } }); + dispatch({ + type: 'removeEvent', + payload: { widget: w.id, type, name }, + }); vscode.postMessage({ type: 'showInfo', text: 'Event removed' }); }; + const commonEvents = [ + 'command', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + return (

Events & Commands

@@ -60,12 +84,42 @@ export function EventsPanel() { - setEventType(e.target.value)} - /> +
+ + {!commonEvents.includes(eventType) && ( + + setEventType(e.target.value) + } + /> + )} +
@@ -75,14 +129,6 @@ export function EventsPanel() { value={eventName} onChange={(e) => setEventName(e.target.value)} /> - -