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/.gitignore b/.gitignore index 5a2ea14..3f7a7a1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ out/ docs/ node_modules/ -README.md examples/ \ No newline at end of file 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/README.md b/README.md new file mode 100644 index 0000000..567c173 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +## Tkinter Visual Designer (VS Code Extension) + +Tkinter Visual Designer — это расширение для Visual Studio Code, которое превращает +разработку Tkinter‑интерфейсов в наглядный процесс: вместо ручного написания +координат и вызовов `.place()` / `.grid()` вы работаете с визуальным конструктором. + +Основная идея — **разводить логику и разметку**: + +- разметка окна (виджеты, их положение и свойства) проектируется в визуальном редакторе; +- логика приложения (обработчики событий, бизнес‑код) остаётся в Python‑файле. + +### Что умеет приложение + +- Визуальное редактирование формы: + - перетаскивание виджетов (Label, Button, Entry, Text, Checkbutton, Radiobutton) на холст; + - изменение размера и положения виджетов мышью; + - настройка свойств (текст, размеры, позиция) через панель свойств. +- Работа с событиями: + - привязка обработчиков к виджетам (например, `command` у кнопки); + - просмотр уже существующих привязок. +- Генерация кода: + - создание Python‑кода по текущему дизайну; + - экспорт/импорт проекта в JSON. +- Разбор существующего кода: + - парсер анализирует ваш Tkinter‑код и восстанавливает дизайн формы; + - поддерживаются классические приложения с классом‑обёрткой и методами `__init__`, `create_widgets` и т.п. + +### Типичный сценарий работы + +1. Открыть или создать Python‑файл с Tkinter‑приложением. +2. Запустить визуальный дизайнер (см. раздел «Запуск приложения» ниже). +3. В дизайнере: + - перетаскивать виджеты из палитры на холст; + - настраивать свойства и события в боковой панели; + - при необходимости импортировать существующий дизайн из кода. +4. Сгенерировать обновлённый Python‑код и сохранить его. +5. Запустить приложение Python уже с новым интерфейсом. + +### Требования + +- Visual Studio Code ≥ 1.74 +- Node.js и npm +- Python 3 (доступный в системе как `python`, `python3`, `py` или указанный в настройках VS Code) + +### Установка зависимостей + +```bash +npm install +``` + +### Сборка расширения и веб‑интерфейса + +```bash +npm run compile +``` + +### Запуск приложения (расширения) + +1. Откройте папку проекта в VS Code. +2. Выполните `npm install` и `npm run compile`, если ещё не делали это. +3. Нажмите `F5` или выберите в меню: + - Run → Start Debugging (Запуск → Начать отладку). +4. Откроется новое окно VS Code с установленным в нём расширением. + +В этом окне вы можете: + +- Открыть любой `.py` файл с Tkinter‑кодом. +- Открыть визуальный дизайнер: + - через командную палитру (`Ctrl+Shift+P` → `Tkinter: Open Tkinter Designer`), или + - через контекстное меню в проводнике: правый клик по `.py` → `Open Tkinter Designer`. + +### Основные команды + +Расширение регистрирует следующие команды: + +- `Tkinter: Open Tkinter Designer` — открыть визуальный дизайнер для текущего Python‑файла. +- `Tkinter: Generate Python Code` — сгенерировать Python‑код из текущего дизайна. +- `Tkinter: Parse Tkinter Code` — разобрать существующий Tkinter‑код и загрузить его в дизайнер. + +### Примечания + +- Для корректной работы парсера обязательно наличие установленного Python 3. +- Генерация и разбор кода основаны на стандартной структуре Tkinter‑приложений (классы, методы `__init__`, `create_widgets`, и т.п.). 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/constants.ts b/src/constants.ts new file mode 100644 index 0000000..3b7ddae --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,50 @@ +export enum WidgetType { + Label = 'Label', + Button = 'Button', + Entry = 'Entry', + Text = 'Text', + Checkbutton = 'Checkbutton', + Radiobutton = 'Radiobutton', + Frame = 'Frame', + Widget = 'Widget', +} + +export const DEFAULT_FORM_CONFIG = { + TITLE: 'Parsed App', + WIDTH: 800, + HEIGHT: 600, + CLASS_NAME: 'Application', + FORM_NAME: 'Form', +}; + +export const WIDGET_DIMENSIONS = { + [WidgetType.Label]: { width: 100, height: 25 }, + [WidgetType.Button]: { width: 80, height: 30 }, + [WidgetType.Entry]: { width: 150, height: 25 }, + [WidgetType.Text]: { width: 200, height: 100 }, + [WidgetType.Checkbutton]: { width: 100, height: 25 }, + [WidgetType.Radiobutton]: { width: 100, height: 25 }, + [WidgetType.Frame]: { width: 200, height: 200 }, + [WidgetType.Widget]: { width: 100, height: 25 }, + DEFAULT: { width: 100, height: 25 }, +}; + +export const EVENT_TYPES = { + COMMAND: 'command', +}; + +export const PYTHON_CODE = { + IMPORTS: { + TKINTER: 'import tkinter as tk', + TTK: 'from tkinter import ttk', + }, + METHODS: { + INIT: '__init__', + CREATE_WIDGETS: 'create_widgets', + RUN: 'run', + }, + VARIABLES: { + ROOT: 'self.root', + APP: 'app', + } +}; diff --git a/src/extension.ts b/src/extension.ts index 0519eeb..0e4bf79 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,130 +1,59 @@ import * as vscode from 'vscode'; import * as path from 'path'; -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; +import { CodeParser } from './parser/codeParser'; +import { TkinterDesignerProvider } from './webview/tkinterDesignerProvider'; +export function activate(context: vscode.ExtensionContext) {3 const openDesignerCommand = vscode.commands.registerCommand( 'tkinter-designer.openDesigner', () => { - TkinterDesignerProvider.createOrShow(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'); + TkinterDesignerProvider.createNew(context.extensionUri); } ); const parseCodeCommand = vscode.commands.registerCommand( 'tkinter-designer.parseCode', async () => { - console.log('[ParseCode] Command invoked'); const activeEditor = vscode.window.activeTextEditor; if (activeEditor && activeEditor.document.languageId === 'python') { const parser = new CodeParser(); const code = activeEditor.document.getText(); - console.log('[ParseCode] Code length:', code.length); + const fileName = path.basename( + activeEditor.document.fileName, + '.py' + ); 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( + const designerInstance = TkinterDesignerProvider.createNew( context.extensionUri - ); - if (designerInstance) { - designerInstance.loadDesignData(designData); - } else { - } + ); + + designerInstance.loadDesignData(designData); vscode.window.showInformationMessage( `Code parsed successfully! Found ${designData.widgets.length} widgets.` ); } else { - console.log('[ParseCode] No widgets found'); vscode.window.showWarningMessage( 'No tkinter widgets found in the code. Make sure your code contains tkinter widget creation statements like tk.Label(), tk.Button(), etc.' ); } } catch (error) { - console.error('[ParseCode] Error:', error); vscode.window.showErrorMessage( `Error parsing code: ${error}` ); } } else { - console.log('[ParseCode] No active Python editor'); vscode.window.showErrorMessage( 'Please open a Python file with tkinter code' ); @@ -134,7 +63,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..9a51dfc 100644 --- a/src/generator/CodeGenerator.ts +++ b/src/generator/CodeGenerator.ts @@ -1,9 +1,10 @@ -import { DesignData, WidgetData } from './types'; +import { DesignData, WidgetData, DesignEvent } from './types'; import { getVariableName, generateVariableNames, getWidgetTypeForGeneration, indentText, + escapeString, } from './utils'; import { getWidgetParameters, @@ -11,6 +12,7 @@ import { generateWidgetContent, } from './widgetHelpers'; import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers'; +import { DEFAULT_FORM_CONFIG, PYTHON_CODE } from '../constants'; export class CodeGenerator { private indentLevel = 0; @@ -18,48 +20,46 @@ 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 || DEFAULT_FORM_CONFIG.CLASS_NAME; const lines: string[] = []; const nameMap = generateVariableNames(designData.widgets); - lines.push('import tkinter as tk'); + lines.push(PYTHON_CODE.IMPORTS.TKINTER); + lines.push(PYTHON_CODE.IMPORTS.TTK); lines.push(''); - lines.push('class Application:'); + lines.push(`class ${className}:`); this.indentLevel = 1; - lines.push(this.indent('def __init__(self):')); + lines.push(this.indent(`def ${PYTHON_CODE.METHODS.INIT}(self):`)); this.indentLevel = 2; - lines.push(this.indent('self.root = tk.Tk()')); - lines.push(this.indent(`self.root.title("${designData.form.title}")`)); + lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT} = tk.Tk()`)); + lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.title("${escapeString(designData.form.title)}")`)); lines.push( this.indent( - `self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")` + `${PYTHON_CODE.VARIABLES.ROOT}.geometry("${designData.form.size.width}x${designData.form.size.height}")` ) ); - lines.push(this.indent('self.create_widgets()')); + lines.push(this.indent(`self.${PYTHON_CODE.METHODS.CREATE_WIDGETS}()`)); lines.push(''); this.indentLevel = 1; - lines.push(this.indent('def create_widgets(self):')); + lines.push(this.indent(`def ${PYTHON_CODE.METHODS.CREATE_WIDGETS}(self):`)); this.indentLevel = 2; designData.widgets.forEach((widget) => { - console.log('[Generator] Widget:', widget.id, widget.type); lines.push(...this.generateWidgetCode(widget, nameMap)); - lines.push(''); }); this.indentLevel = 1; - lines.push(this.indent('def run(self):')); + lines.push(this.indent(`def ${PYTHON_CODE.METHODS.RUN}(self):`)); this.indentLevel = 2; - lines.push(this.indent('self.root.mainloop()')); + lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.mainloop()`)); lines.push(''); const hasEvents = designData.events && designData.events.length > 0; if (hasEvents) { - console.log('[Generator] Generating event handlers'); lines.push( ...generateEventHandlers( designData, @@ -72,12 +72,58 @@ export class CodeGenerator { this.indentLevel = 0; lines.push('if __name__ == "__main__":'); this.indentLevel = 1; - lines.push(this.indent('app = Application()')); - lines.push(this.indent('app.run()')); + lines.push(this.indent('try:')); + this.indentLevel = 2; + lines.push(this.indent(`${PYTHON_CODE.VARIABLES.APP} = ${className}()`)); + lines.push(this.indent(`${PYTHON_CODE.VARIABLES.APP}.${PYTHON_CODE.METHODS.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; + + designData.widgets.forEach((widget) => { + lines.push(...this.generateWidgetCode(widget, nameMap)); + }); + + return lines.join('\n'); + } + + public generateEventHandler(event: DesignEvent): string { + const lines: string[] = []; + + 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) => indentText(2, l)) + .join('\n'); + lines.push(body); + } else { + lines.push(' pass'); + } + return lines.join('\n'); + } + private generateWidgetCode( widget: WidgetData, nameMap: Map @@ -96,7 +142,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..be2aa11 100644 --- a/src/generator/eventHelpers.ts +++ b/src/generator/eventHelpers.ts @@ -1,4 +1,4 @@ -import { DesignData, Event, WidgetData } from './types'; +import { DesignData, DesignEvent, WidgetData } from './types'; export function generateEventHandlers( designData: DesignData, @@ -8,22 +8,39 @@ export function generateEventHandlers( const lines: string[] = []; if (designData.events && designData.events.length > 0) { - designData.events.forEach((event: Event) => { - const handlerName = event.name; - const isBindEvent = - event.type.startsWith('<') && event.type.endsWith('>'); + const handledNames = new Set(); - let hasCode = false; - const widget = (designData.widgets || []).find( - (w) => w.id === event.widget - ); - if (isBindEvent) { - lines.push(indentDef(`def ${handlerName}(self, event):`)); + designData.events.forEach((event: DesignEvent) => { + const handlerName = event.name; + + if (handledNames.has(handlerName)) { + return; + } + handledNames.add(handlerName); + + const signature = event.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'); + let hasCode = false; + 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..e1b4815 100644 --- a/src/generator/types.ts +++ b/src/generator/types.ts @@ -1,8 +1,21 @@ -export interface Event { +export interface DesignEvent { widget: string; type: string; name: string; code: string; + signature?: string; +} + +export interface WidgetProperties { + text?: string; + command?: string | { name?: string; lambda_body?: string }; + variable?: string; + orient?: string; + from_?: number; + to?: number; + width?: number; + height?: number; + [key: string]: any; } export interface WidgetData { @@ -12,17 +25,19 @@ export interface WidgetData { y: number; width: number; height: number; - properties: { [key: string]: any }; + properties: WidgetProperties; } export interface DesignData { form: { + name: string; title: string; size: { width: number; height: number; }; + className?: string; }; widgets: WidgetData[]; - events?: Event[]; + events?: DesignEvent[]; } diff --git a/src/generator/utils.ts b/src/generator/utils.ts index 7d8796e..01b222a 100644 --- a/src/generator/utils.ts +++ b/src/generator/utils.ts @@ -25,12 +25,11 @@ export function generateVariableNames( widgets.forEach((widget) => { 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..c7dd98f 100644 --- a/src/parser/CodeParser.ts +++ b/src/parser/CodeParser.ts @@ -1,137 +1,33 @@ -import { DesignData, WidgetData, Event } from '../generator/types'; +import * as vscode from 'vscode'; +import { DesignData, WidgetData, DesignEvent } from '../generator/types'; import { runPythonAst } from './pythonRunner'; import { convertASTResultToDesignData } from './astConverter'; +import { ASTResult } from './astTypes'; 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); - const astDesign = convertASTResultToDesignData(astRaw); - if (astDesign && astDesign.widgets && astDesign.widgets.length > 0) { - console.log( - '[Parser] AST parsed widgets:', - astDesign.widgets.length - ); - return astDesign; - } - console.log('[Parser] AST returned no widgets, using regex fallback'); - const regexDesign = this.parseWithRegexInline(pythonCode); - console.log( - '[Parser] Regex parsed widgets:', - regexDesign?.widgets?.length || 0 - ); - return regexDesign; - } - 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'); + if (astRaw && astRaw.error) { + vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`); return null; } - return { - form: { - title: formTitle, - size: { width: formWidth, height: formHeight }, - }, - widgets, - events, - }; + + const astDesign = convertASTResultToDesignData(astRaw as ASTResult); + + if (astDesign) { + if (filename) { + astDesign.form.name = filename; + } + return astDesign; + } + + 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; } } diff --git a/src/parser/astConverter.ts b/src/parser/astConverter.ts index 5eb1da9..4420212 100644 --- a/src/parser/astConverter.ts +++ b/src/parser/astConverter.ts @@ -1,10 +1,15 @@ -import { DesignData, WidgetData, Event } from '../generator/types'; +import { DesignData, WidgetData, DesignEvent, WidgetProperties } from '../generator/types'; +import { ASTResult, ASTWidget, ASTMethodData } from './astTypes'; import { getDefaultWidth, getDefaultHeight } from './utils'; +import { WidgetType, DEFAULT_FORM_CONFIG, EVENT_TYPES } from '../constants'; -export function convertASTResultToDesignData( - astResult: any -): DesignData | null { - if (!astResult || !astResult.widgets || astResult.widgets.length === 0) { +export function convertASTResultToDesignData(astResult: ASTResult): DesignData | null { + if (!astResult) { + return null; + } + + + if (!astResult.window && !astResult.widgets) { return null; } @@ -12,48 +17,44 @@ export function convertASTResultToDesignData( let formTitle = (astResult.window && astResult.window.title) || astResult.title || - 'Parsed App'; + DEFAULT_FORM_CONFIG.TITLE; let formWidth = - (astResult.window && astResult.window.width) || astResult.width || 800; + (astResult.window && astResult.window.width) || astResult.width || DEFAULT_FORM_CONFIG.WIDTH; let formHeight = (astResult.window && astResult.window.height) || astResult.height || - 600; + DEFAULT_FORM_CONFIG.HEIGHT; + let className = + (astResult.window && astResult.window.className) || DEFAULT_FORM_CONFIG.CLASS_NAME; let counter = 0; const allowedTypes = new Set([ - 'Label', - 'Button', - 'Text', - 'Checkbutton', - 'Radiobutton', + WidgetType.Label, + WidgetType.Button, + WidgetType.Entry, + WidgetType.Text, + WidgetType.Checkbutton, + WidgetType.Radiobutton, ]); - for (const w of astResult.widgets) { + + + const inputWidgets = astResult.widgets || []; + + for (const w of inputWidgets) { counter++; - const type = w.type || 'Widget'; - if (!allowedTypes.has(type)) { + const type = w.type || WidgetType.Widget; + if (!allowedTypes.has(type as WidgetType)) { continue; } const place = w.placement || {}; const x = place.x !== undefined ? place.x : w.x !== undefined ? w.x : 0; const y = place.y !== undefined ? place.y : w.y !== undefined ? w.y : 0; - const p = (w.properties || w.params || {}) as any; - const width = - place.width !== undefined - ? place.width - : w.width !== undefined - ? w.width - : p.width !== undefined - ? p.width - : getDefaultWidth(type); - const height = - place.height !== undefined - ? place.height - : w.height !== undefined - ? w.height - : p.height !== undefined - ? p.height - : getDefaultHeight(type); + + + const p: Record = w.properties || w.params || {}; + + const width = [place.width, w.width, p.width].find(v => v !== undefined) ?? getDefaultWidth(type); + const height = [place.height, w.height, p.height].find(v => v !== undefined) ?? getDefaultHeight(type); const id = w.variable_name || `ast_widget_${counter}`; const widget: WidgetData = { id, @@ -67,33 +68,58 @@ export function convertASTResultToDesignData( widgets.push(widget); } - const events: Event[] = []; + const events: DesignEvent[] = []; + 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`; - events.push({ - widget: callback.widget, - type: 'command', - name: cleanName, - code: callback.command?.lambda_body || '', - }); + addDesignEvent( + events, + methods, + callback.widget, + EVENT_TYPES.COMMAND, + callback.command?.name, + () => `on_${callback.widget}_command`, + callback.command?.lambda_body + ); } } + if (astResult.bind_events) { for (const bindEvent of astResult.bind_events) { - const rawName = bindEvent.callback?.name; - const cleanName = rawName - ? String(rawName).replace(/^self\./, '') - : `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`; - events.push({ - widget: bindEvent.widget, - type: bindEvent.event, - name: cleanName, - code: bindEvent.callback?.lambda_body || '', - }); + addDesignEvent( + events, + methods, + bindEvent.widget, + bindEvent.event, + bindEvent.callback?.name, + () => `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`, + bindEvent.callback?.lambda_body + ); + } + } + + if (astResult.widgets) { + for (const w of astResult.widgets) { + const p: Record = w.properties || w.params || {}; + if (p.command) { + const widgetId = w.variable_name; + if (!widgetId) continue; + + const cmd = p.command; + const rawName = cmd.name || cmd; + + addDesignEvent( + events, + methods, + widgetId, + EVENT_TYPES.COMMAND, + rawName, + () => `on_${widgetId}_command`, + undefined, + true + ); + } } } @@ -102,8 +128,10 @@ export function convertASTResultToDesignData( const result: DesignData = { form: { + name: DEFAULT_FORM_CONFIG.FORM_NAME, title: formTitle, size: { width: formWidth, height: formHeight }, + className: className, }, widgets, events: filteredEvents.length ? filteredEvents : [], @@ -111,8 +139,8 @@ export function convertASTResultToDesignData( return result; } -function extractWidgetPropertiesFromAST(w: any): any { - const props: any = {}; +function extractWidgetPropertiesFromAST(w: ASTWidget): WidgetProperties { + const props: WidgetProperties = {}; if (!w) return props; const p = w.properties || w.params || {}; if (p.text) props.text = p.text; @@ -125,3 +153,43 @@ function extractWidgetPropertiesFromAST(w: any): any { if (p.height !== undefined) props.height = p.height; return props; } + +function addDesignEvent( + events: DesignEvent[], + methods: Record, + widgetId: string, + eventType: string, + rawName: string | undefined | null, + defaultNameGen: () => string, + lambdaBody: string | undefined, + checkExists: boolean = false +) { + const cleanName = rawName + ? String(rawName).replace(/^self\./, '') + : defaultNameGen(); + + if (checkExists) { + const exists = events.find( + (e) => e.widget === widgetId && e.type === eventType + ); + if (exists) return; + } + + const methodData = methods[cleanName]; + const codeBody = + typeof methodData === 'object' && methodData !== null + ? (methodData as ASTMethodData).body + : (methodData as string); + const signature = + typeof methodData === 'object' && methodData !== null + ? (methodData as ASTMethodData).signature + : undefined; + + events.push({ + widget: widgetId, + type: eventType, + name: cleanName, + code: codeBody || lambdaBody || '', + signature: signature, + }); +} diff --git a/src/parser/astTypes.ts b/src/parser/astTypes.ts new file mode 100644 index 0000000..bcc99db --- /dev/null +++ b/src/parser/astTypes.ts @@ -0,0 +1,60 @@ +import { WidgetProperties } from '../generator/types'; + +export interface ASTPlacement { + x?: number; + y?: number; + width?: number; + height?: number; +} + +export interface ASTWidget { + type?: string; + variable_name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + placement?: ASTPlacement; + properties?: Record; + params?: Record; +} + +export interface ASTCommandCallback { + widget: string; + command?: { + name?: string; + lambda_body?: string; + }; +} + +export interface ASTBindEvent { + widget: string; + event: string; + callback?: { + name?: string; + lambda_body?: string; + }; +} + +export interface ASTMethodData { + body: string; + signature?: string; +} + +export interface ASTResult { + window?: { + title?: string; + width?: number; + height?: number; + className?: string; + }; + title?: string; + width?: number; + height?: number; + widgets: ASTWidget[]; + command_callbacks?: ASTCommandCallback[]; + bind_events?: ASTBindEvent[]; + methods?: { [key: string]: string | ASTMethodData }; + success?: boolean; + error?: string; +} diff --git a/src/parser/pythonRunner.ts b/src/parser/pythonRunner.ts index c9160a3..2df7d4c 100644 --- a/src/parser/pythonRunner.ts +++ b/src/parser/pythonRunner.ts @@ -2,79 +2,178 @@ 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'; +import { ASTResult } from './astTypes'; -async function executePythonScript( - pythonScriptPath: string, - pythonFilePath: 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 = ''; +class PythonExecutor { + private static instance: PythonExecutor; + private cachedPythonPath: string | null = null; - process.stdout.on('data', (data) => { - result += data.toString(); + private constructor() {} + + public static getInstance(): PythonExecutor { + if (!PythonExecutor.instance) { + PythonExecutor.instance = new PythonExecutor(); + } + return PythonExecutor.instance; + } + + public async getPythonPath(): Promise { + if (this.cachedPythonPath) { + return this.cachedPythonPath; + } + + const commands = await this.findPythonCommands(); + + for (const cmd of commands) { + try { + await this.verifyPython(cmd); + this.cachedPythonPath = cmd; + return cmd; + } catch (e) { + + } + } + + throw new Error('No suitable Python interpreter found. Please install Python or configure "python.defaultInterpreterPath" in VS Code.'); + } + + private async verifyPython(cmd: string): Promise { + return new Promise((resolve, reject) => { + + const process = spawn(cmd, ['--version'], { shell: false }); + + process.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Python check failed with code ${code}`)); + } + }); + + process.on('error', (err) => { + reject(err); + }); }); + } - process.stderr.on('data', (data) => { - errorOutput += data.toString(); - }); + private async findPythonCommands(): Promise { + const commands: string[] = []; - 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}` - ) - ); + + const extension = vscode.extensions.getExtension('ms-python.python'); + if (extension) { + if (!extension.isActive) { + await extension.activate(); + } + } + + const config = vscode.workspace.getConfiguration('python'); + let pythonPath = + config.get('defaultInterpreterPath') || + config.get('pythonPath'); + + if (pythonPath && pythonPath !== 'python') { + 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); + } + + + if (process.platform === 'win32') { + commands.push('py'); + commands.push('python'); + } else { + commands.push('python3'); + commands.push('python'); + } + + return Array.from(new Set(commands)); + } + + public async executeScript(scriptPath: string, args: string[] = [], stdinInput?: string): Promise { + const pythonPath = await this.getPythonPath(); + const allArgs = [scriptPath, ...args]; + + return new Promise((resolve, reject) => { + const process = spawn(pythonPath, allArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + process.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + process.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + process.on('close', (code) => { + if (code !== 0) { + reject(new Error(stderr || `Process exited with code ${code}`)); + } else { + resolve(stdout); + } + }); + + process.on('error', (err) => { + reject(new Error(`Failed to spawn python process: ${err.message}`)); + }); + + if (stdinInput) { + try { + process.stdin.write(stdinInput); + process.stdin.end(); + } catch (e: any) { + reject(new Error(`Failed to write to stdin: ${e.message}`)); + } } }); - }); -} - -function getPythonCommand(): string { - return process.platform === 'win32' ? 'python' : 'python3'; -} - -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 {} -} - -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 - ); - console.log('[PythonRunner] Received AST JSON length:', output.length); - const parsed = JSON.parse(output); - return parsed; - } catch (err) { - console.error('[PythonRunner] Error running Python AST:', err); - return null; - } finally { - cleanupTempFile(tempFilePath); - console.log('[PythonRunner] Temp file cleaned:', tempFilePath); + } +} + + +export async function executePythonScript( + pythonScriptPath: string, + pythonCode: string +): Promise { + try { + const executor = PythonExecutor.getInstance(); + return await executor.executeScript(pythonScriptPath, [], pythonCode); + } catch (error: any) { + throw error; + } +} + +export async function runPythonAst(code: string): Promise { + try { + const executor = PythonExecutor.getInstance(); + const scriptPath = path.join( + __dirname, + '..', + '..', + 'src', + 'parser', + 'tkinter_ast_parser.py' + ); + + const output = await executor.executeScript(scriptPath, [], code); + try { + return JSON.parse(output) as ASTResult; + } catch (e) { + return { error: 'Failed to parse JSON output: ' + e }; + } + } catch (error: any) { + return { error: error.message }; } } 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..729f25f 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..bef5318 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..3d1598e 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..22fcf4e 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..5f275db 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..7755d4b 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..0f43f82 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..9c3e0cc 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..d3cce1a 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..fd1ad40 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..c69ceee 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..adaa8e8 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..8c00abf 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..771c1f8 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..16a3544 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,119 @@ class TkinterAnalyzer(ast.NodeVisitor): def visit_ClassDef(self, node: ast.ClassDef): prev = self.current_class enter_class(self, node) + + 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: + + + + + start_line = node.lineno + end_line = getattr(node, 'end_lineno', None) + + if end_line is None: + + end_line = node.body[-1].lineno + + lines = self.source_code.splitlines(keepends=True) + + func_lines = lines[start_line-1 : end_line] + func_text = "".join(func_lines) + + + + + + body_start_idx = -1 + + try: + tokens = list(tokenize.tokenize(io.BytesIO(func_text.encode('utf-8')).readline)) + + nesting = 0 + colon_token = None + + for tok in tokens: + if tok.type == tokenize.OP: + if tok.string in '([{': + nesting += 1 + elif tok.string in ')]}': + nesting -= 1 + elif tok.string == ':' and nesting == 0: + colon_token = tok + + break + + if colon_token: + + + + + colon_line_idx = colon_token.end[0] - 1 + + + + + + + + + + body_lines = func_lines[colon_line_idx + 1:] + + + colon_line = func_lines[colon_line_idx] + after_colon = colon_line[colon_token.end[1]:] + + if after_colon.strip(): + body_lines.insert(0, after_colon) + + body_text = "".join(body_lines) + + + dedented_body = textwrap.dedent(body_text) + + + + + sig_lines = func_lines[:colon_line_idx] + sig_lines.append(colon_line[:colon_token.end[1]]) + + sig_raw = "".join(sig_lines).strip() + if sig_raw.endswith(':'): + sig_raw = sig_raw[:-1].strip() + + self.methods[node.name] = { + 'body': dedented_body.strip(), + 'signature': sig_raw + } + + except tokenize.TokenError: + pass + + except Exception as e: + pass + self.generic_visit(node) exit_function(self, prev) @@ -101,9 +220,3 @@ class TkinterAnalyzer(ast.NodeVisitor): def analyze_widget_creation_commands(self, node: ast.Assign): return analyze_widget_creation_commands(self, node) - - def create_widget_handler_connections(self, widgets: List[Dict[str, Any]]) -> Dict[str, Any]: - return create_widget_handler_connections(self, widgets) - - def is_interactive_widget(self, widget_type: str) -> bool: - return is_interactive_widget(widget_type) \ No newline at end of file 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..c323ace 100644 --- a/src/parser/tk_ast/analyzer/widget_creation.py +++ b/src/parser/tk_ast/analyzer/widget_creation.py @@ -19,10 +19,13 @@ 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'] + + 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 +34,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 +52,6 @@ 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 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/parser/tkinter_ast_parser.py b/src/parser/tkinter_ast_parser.py index e14db7f..4acdf10 100644 --- a/src/parser/tkinter_ast_parser.py +++ b/src/parser/tkinter_ast_parser.py @@ -1,12 +1,16 @@ -#!/usr/bin/env python3 + import sys import json +import os + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) try: from tk_ast.parser import parse_tkinter_code, parse_file -except Exception as e: +except ImportError as e: + raise RuntimeError( f"Failed to import tk_ast package: {e}. Ensure 'tk_ast' exists next to this script." ) @@ -14,8 +18,8 @@ except Exception as e: if __name__ == '__main__': if len(sys.argv) > 1: - print(parse_file(sys.argv[1])) + sys.stdout.write(str(parse_file(sys.argv[1])) + '\n') else: code = sys.stdin.read() result = parse_tkinter_code(code) - print(json.dumps(result, indent=2, ensure_ascii=False)) \ No newline at end of file + sys.stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + '\n') \ No newline at end of file diff --git a/src/parser/utils.ts b/src/parser/utils.ts index 912f731..fea8c6e 100644 --- a/src/parser/utils.ts +++ b/src/parser/utils.ts @@ -1,21 +1,9 @@ +import { WidgetType, WIDGET_DIMENSIONS } from '../constants'; + export function getDefaultWidth(type: string): number { - const defaults: { [key: string]: number } = { - Label: 100, - Button: 80, - Text: 200, - Checkbutton: 100, - Radiobutton: 100, - }; - return defaults[type] || 100; + return WIDGET_DIMENSIONS[type as WidgetType]?.width || WIDGET_DIMENSIONS.DEFAULT.width; } export function getDefaultHeight(type: string): number { - const defaults: { [key: string]: number } = { - Label: 25, - Button: 30, - Text: 100, - Checkbutton: 25, - Radiobutton: 25, - }; - return defaults[type] || 25; + return WIDGET_DIMENSIONS[type as WidgetType]?.height || WIDGET_DIMENSIONS.DEFAULT.height; } diff --git a/src/webview/TkinterDesignerProvider.ts b/src/webview/TkinterDesignerProvider.ts index 1118f89..46500c4 100644 --- a/src/webview/TkinterDesignerProvider.ts +++ b/src/webview/TkinterDesignerProvider.ts @@ -1,34 +1,26 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import { Uri } from 'vscode'; +import { runPythonAst } from '../parser/pythonRunner'; +import { DesignData } from '../generator/types'; +import { WebviewMessage } from './react/types'; +import { ProjectIO } from './projectIO'; export class TkinterDesignerProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'tkinter-designer'; - public static _instance: TkinterDesignerProvider | undefined; - private _view?: vscode.WebviewPanel; - private _designData: any = { + private _view?: vscode.WebviewPanel | vscode.WebviewView; + private _designData: DesignData = { widgets: [], events: [], - form: { title: 'My App', size: { width: 800, height: 600 } }, + form: { name: 'App', title: 'My App', size: { width: 800, height: 600 } }, }; 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; - } - - console.log('[Webview] Creating new panel'); + public static createNew(extensionUri: vscode.Uri): TkinterDesignerProvider { const panel = vscode.window.createWebviewPanel( TkinterDesignerProvider.viewType, 'Tkinter Designer', - column || vscode.ViewColumn.One, + vscode.ViewColumn.Beside, { enableScripts: true, retainContextWhenHidden: true, @@ -40,24 +32,17 @@ 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( @@ -65,7 +50,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken ) { - this._view = webviewView as any; + this._view = webviewView; webviewView.webview.options = { enableScripts: true, localResourceRoots: [ @@ -85,8 +70,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { } private _setupMessageHandling(webview: vscode.Webview) { - webview.onDidReceiveMessage((message) => { - console.log('[Webview] Message:', message.type); + webview.onDidReceiveMessage(async (message) => { switch (message.type) { case 'designUpdated': this._designData = message.data; @@ -107,12 +91,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { type: 'loadDesign', data: this._designData, }); - console.log('[Webview] Sent loadDesign'); } break; case 'generateCode': - console.log('[Webview] Generating code from webview'); - this.handleGenerateCode(message.data); + await this.handleGenerateCode(message.data); + break; + case 'applyChanges': + await this.handleApplyChanges(message.data); break; case 'showInfo': vscode.window.showInformationMessage(message.text); @@ -120,6 +105,15 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { case 'showError': vscode.window.showErrorMessage(message.text); break; + case 'exportProject': + await ProjectIO.exportProject(message.data); + break; + case 'importProject': + const data = await ProjectIO.importProject(); + if (data) { + this.loadDesignData(data); + } + break; } }, undefined); } @@ -136,67 +130,260 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider { type: 'loadDesign', data: this._designData, }); - console.log('[Webview] loadDesign posted'); - } else { } } private async handleGenerateCode(designData: any): Promise { 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' + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage( + 'No workspace folder is open. Please open a folder first.' ); - } 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; + 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; } - const fileName = `app_${Date.now()}.py`; - const filePath = path.join( - workspaceFolder.uri.fsPath, - fileName + }; + + 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}` + ); + + const newFormName = path.basename(fileName, '.py'); + if (newFormName !== formName) { + if (this._view) { + const webview = (this._view as any).webview || this._view; + webview.postMessage({ + type: 'updateFormName', + name: newFormName, + }); + } + } + } catch (error) { + vscode.window.showErrorMessage(`Error generating code: ${error}`); + } + } + + private async handleApplyChanges(designData: DesignData): Promise { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage( + 'No workspace folder is open. Please open a folder first.' ); - const fileUri = Uri.file(filePath); - const encoder = new TextEncoder(); - const fileBytes = encoder.encode(pythonCode); - await vscode.workspace.fs.writeFile(fileUri, fileBytes); + return; + } + + const formName = designData.form.name || 'Form1'; + const fileName = `${formName}.py`; + const fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName); + + let existingMethods: any = {}; + let oldClassName = ''; + let fileContent = ''; + let fileExists = false; + + try { + await vscode.workspace.fs.stat(fileUri); + fileExists = true; const doc = await vscode.workspace.openTextDocument(fileUri); - await vscode.window.showTextDocument(doc, { - preview: false, - }); + fileContent = doc.getText(); + const astResult = await runPythonAst(fileContent); + + if (astResult && !('error' in astResult)) { + if (astResult.methods) { + existingMethods = astResult.methods as Record; + if (designData.events) { + for (const event of designData.events) { + if (existingMethods[event.name]) { + const methodData = existingMethods[event.name]; + if (typeof methodData === 'object' && methodData !== null) { + event.code = methodData.body; + event.signature = methodData.signature; + } else if (typeof methodData === 'string') { + event.code = methodData; + } + } + } + } + } + if (astResult.window && astResult.window.className) { + oldClassName = astResult.window.className; + } + } + } catch (e) { + } + + const { CodeGenerator } = await import( + '../generator/codeGenerator' + ); + const generator = new CodeGenerator(); + + if (!fileExists) { + const pythonCode = generator.generateTkinterCode(designData); + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile( + fileUri, + encoder.encode(pythonCode) + ); vscode.window.showInformationMessage( - `Python file created: ${fileName}` + `Created new file: ${fileName}` + ); + } else { + const newCreateWidgets = + generator.generateCreateWidgetsBody(designData); + + 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 { + vscode.window.showWarningMessage( + 'Could not find create_widgets method to update. Regenerating full file might be needed if structure is broken.' + ); + } + + 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) { + 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 { + 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')}`; + } + } + } + } + + const titleRegex = /self\.root\.title\s*\(\s*["'].*?["']\s*\)/; + newFileContent = newFileContent.replace( + titleRegex, + `self.root.title("${designData.form.title}")` + ); + + const geoRegex = /self\.root\.geometry\s*\(\s*["'].*?["']\s*\)/; + newFileContent = newFileContent.replace( + geoRegex, + `self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")` + ); + + if ( + oldClassName && + designData.form.className && + oldClassName !== designData.form.className + ) { + const classDefRegex = new RegExp( + `class\\s+${oldClassName}\\s*:` + ); + newFileContent = newFileContent.replace( + classDefRegex, + `class ${designData.form.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) { + 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('[GenerateCode] Done'); } catch (error) { - console.error('[GenerateCode] Error:', error); - vscode.window.showErrorMessage(`Error generating code: ${error}`); + vscode.window.showErrorMessage(`Error applying changes: ${error}`); } } diff --git a/src/webview/projectIO.ts b/src/webview/projectIO.ts new file mode 100644 index 0000000..999ffb0 --- /dev/null +++ b/src/webview/projectIO.ts @@ -0,0 +1,95 @@ +import * as vscode from 'vscode'; +import { DesignData } from '../generator/types'; + +export class ProjectIO { + public static async exportProject(data: DesignData): 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: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Export failed: ${errorMessage}`); + } + } + + public static async importProject(): 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); + + + const designData = data.data || data; + + if (ProjectIO.validateDesignData(designData)) { + vscode.window.showInformationMessage('Project imported successfully!'); + return designData; + } else { + vscode.window.showErrorMessage('Invalid project file format.'); + return null; + } + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Import failed: ${errorMessage}`); + } + return null; + } + + private static validateDesignData(data: any): data is DesignData { + if (!data || typeof data !== 'object') { + return false; + } + + + if (!data.form || typeof data.form !== 'object') { + return false; + } + if (typeof data.form.name !== 'string' || typeof data.form.title !== 'string') { + return false; + } + if (!data.form.size || typeof data.form.size.width !== 'number' || typeof data.form.size.height !== 'number') { + return false; + } + + + if (!Array.isArray(data.widgets)) { + return false; + } + for (const widget of data.widgets) { + if (!widget.id || typeof widget.id !== 'string') { + return false; + } + if (!widget.type || typeof widget.type !== 'string') { + return false; + } + if (typeof widget.x !== 'number' || typeof widget.y !== 'number') { + return false; + } + } + + return true; + } +} 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..d1577e8 100644 --- a/src/webview/react/components/Canvas.tsx +++ b/src/webview/react/components/Canvas.tsx @@ -2,6 +2,69 @@ 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 ( +
+ {w.properties?.text || 'Button'} +
+ ); + case 'Entry': + return ( + + ); + case 'Text': + return ( + + ); + case 'Checkbutton': + return ( +
+ + {w.properties?.text || 'Check'} +
+ ); + case 'Radiobutton': + return ( +
+ + {w.properties?.text || 'Radio'} +
+ ); + default: + return w.properties?.text || w.type; + } +} + export function Canvas() { const dispatch = useAppDispatch(); const { design, selectedWidgetId } = useAppState(); @@ -17,12 +80,10 @@ export function Canvas() { const rect = containerRef.current?.getBoundingClientRect(); const x = e.clientX - (rect?.left || 0); const y = e.clientY - (rect?.top || 0); - console.log('[Canvas] Drop widget', type, 'at', x, y); if (type) dispatch({ type: 'addWidget', payload: { type, x, y } }); }; const onSelect = (id: string | null) => { - console.log('[Canvas] Select widget', id); dispatch({ type: 'selectWidget', payload: { id } }); }; @@ -34,19 +95,126 @@ export function Canvas() { if (!w) return; const initX = w.x; const initY = w.y; - console.log('[Canvas] Drag start', id, 'at', initX, initY); 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 } }, }); }; const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); - console.log('[Canvas] Drag end', id); + dispatch({ type: 'pushHistory' }); + }; + window.addEventListener('mousemove', onMove); + 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; + + const onMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + const patch: Partial = {}; + + 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); + dispatch({ type: 'pushHistory' }); + }; + 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; + + 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); + } + + 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); + dispatch({ type: 'pushHistory' }); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); @@ -54,41 +222,123 @@ export function Canvas() { 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') + } + /> + + )} +
+ ))} +
+
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..51aec3b --- /dev/null +++ b/src/webview/react/components/ErrorBoundary.tsx @@ -0,0 +1,45 @@ +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) { + } + + 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..d2b7d0c 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', }, }); 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,41 @@ export function EventsPanel() { - setEventType(e.target.value)} - /> +
+ + {!commonEvents.includes(eventType) && ( + + setEventType(e.target.value) + } + /> + )} +
@@ -75,14 +128,6 @@ export function EventsPanel() { value={eventName} onChange={(e) => setEventName(e.target.value)} /> - -