This commit is contained in:
IDK 2025-12-26 19:28:22 +03:00
parent c87e51053c
commit d8b0c738c9
51 changed files with 2270 additions and 837 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
node_modules
out
.vscode-test
.git
.gitignore
.dockerignore
Dockerfile
.gitea
README.md
examples/
.vscode/
docs/

1
.gitignore vendored
View File

@ -2,5 +2,4 @@
out/ out/
docs/ docs/
node_modules/ node_modules/
README.md
examples/ examples/

25
Dockerfile Normal file
View File

@ -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"]

83
README.md Normal file
View File

@ -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`, и т.п.).

11
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "tkinter-designer", "name": "tkinter-designer",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"immer": "^11.0.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@ -1243,6 +1244,16 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View File

@ -50,6 +50,7 @@
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"" "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
}, },
"dependencies": { "dependencies": {
"immer": "^11.0.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

50
src/constants.ts Normal file
View File

@ -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',
}
};

View File

@ -1,130 +1,59 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as path from 'path'; import * as path from 'path';
import { CodeGenerator } from './generator/CodeGenerator'; import { CodeParser } from './parser/codeParser';
import { CodeParser } from './parser/CodeParser'; import { TkinterDesignerProvider } from './webview/tkinterDesignerProvider';
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
export function activate(context: vscode.ExtensionContext) {
const provider = new TkinterDesignerProvider(context.extensionUri);
TkinterDesignerProvider._instance = provider;
export function activate(context: vscode.ExtensionContext) {3
const openDesignerCommand = vscode.commands.registerCommand( const openDesignerCommand = vscode.commands.registerCommand(
'tkinter-designer.openDesigner', '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');
} }
); );
const parseCodeCommand = vscode.commands.registerCommand( const parseCodeCommand = vscode.commands.registerCommand(
'tkinter-designer.parseCode', 'tkinter-designer.parseCode',
async () => { async () => {
console.log('[ParseCode] Command invoked');
const activeEditor = vscode.window.activeTextEditor; const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') { if (activeEditor && activeEditor.document.languageId === 'python') {
const parser = new CodeParser(); const parser = new CodeParser();
const code = activeEditor.document.getText(); const code = activeEditor.document.getText();
console.log('[ParseCode] Code length:', code.length); const fileName = path.basename(
activeEditor.document.fileName,
'.py'
);
try { try {
const designData = await parser.parseCodeToDesign(code); const designData = await parser.parseCodeToDesign(
code,
fileName
);
if ( if (
designData && designData &&
designData.widgets && designData.widgets &&
designData.widgets.length > 0 designData.widgets.length > 0
) { ) {
console.log('[ParseCode] Widgets found:', designData.widgets.length); const designerInstance = TkinterDesignerProvider.createNew(
const designerInstance =
TkinterDesignerProvider.createOrShow(
context.extensionUri context.extensionUri
); );
if (designerInstance) {
designerInstance.loadDesignData(designData); designerInstance.loadDesignData(designData);
} else {
}
vscode.window.showInformationMessage( vscode.window.showInformationMessage(
`Code parsed successfully! Found ${designData.widgets.length} widgets.` `Code parsed successfully! Found ${designData.widgets.length} widgets.`
); );
} else { } else {
console.log('[ParseCode] No widgets found');
vscode.window.showWarningMessage( 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.' 'No tkinter widgets found in the code. Make sure your code contains tkinter widget creation statements like tk.Label(), tk.Button(), etc.'
); );
} }
} catch (error) { } catch (error) {
console.error('[ParseCode] Error:', error);
vscode.window.showErrorMessage( vscode.window.showErrorMessage(
`Error parsing code: ${error}` `Error parsing code: ${error}`
); );
} }
} else { } else {
console.log('[ParseCode] No active Python editor');
vscode.window.showErrorMessage( vscode.window.showErrorMessage(
'Please open a Python file with tkinter code' 'Please open a Python file with tkinter code'
); );
@ -134,7 +63,6 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push( context.subscriptions.push(
openDesignerCommand, openDesignerCommand,
generateCodeCommand,
parseCodeCommand parseCodeCommand
); );
} }

View File

@ -1,9 +1,10 @@
import { DesignData, WidgetData } from './types'; import { DesignData, WidgetData, DesignEvent } from './types';
import { import {
getVariableName, getVariableName,
generateVariableNames, generateVariableNames,
getWidgetTypeForGeneration, getWidgetTypeForGeneration,
indentText, indentText,
escapeString,
} from './utils'; } from './utils';
import { import {
getWidgetParameters, getWidgetParameters,
@ -11,6 +12,7 @@ import {
generateWidgetContent, generateWidgetContent,
} from './widgetHelpers'; } from './widgetHelpers';
import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers'; import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers';
import { DEFAULT_FORM_CONFIG, PYTHON_CODE } from '../constants';
export class CodeGenerator { export class CodeGenerator {
private indentLevel = 0; private indentLevel = 0;
@ -18,48 +20,46 @@ export class CodeGenerator {
public generateTkinterCode(designData: DesignData): string { public generateTkinterCode(designData: DesignData): string {
this.designData = designData; 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 lines: string[] = [];
const nameMap = generateVariableNames(designData.widgets); 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('');
lines.push('class Application:'); lines.push(`class ${className}:`);
this.indentLevel = 1; this.indentLevel = 1;
lines.push(this.indent('def __init__(self):')); lines.push(this.indent(`def ${PYTHON_CODE.METHODS.INIT}(self):`));
this.indentLevel = 2; this.indentLevel = 2;
lines.push(this.indent('self.root = tk.Tk()')); lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT} = tk.Tk()`));
lines.push(this.indent(`self.root.title("${designData.form.title}")`)); lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.title("${escapeString(designData.form.title)}")`));
lines.push( lines.push(
this.indent( 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(''); lines.push('');
this.indentLevel = 1; 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; this.indentLevel = 2;
designData.widgets.forEach((widget) => { designData.widgets.forEach((widget) => {
console.log('[Generator] Widget:', widget.id, widget.type);
lines.push(...this.generateWidgetCode(widget, nameMap)); lines.push(...this.generateWidgetCode(widget, nameMap));
lines.push('');
}); });
this.indentLevel = 1; this.indentLevel = 1;
lines.push(this.indent('def run(self):')); lines.push(this.indent(`def ${PYTHON_CODE.METHODS.RUN}(self):`));
this.indentLevel = 2; this.indentLevel = 2;
lines.push(this.indent('self.root.mainloop()')); lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.mainloop()`));
lines.push(''); lines.push('');
const hasEvents = designData.events && designData.events.length > 0; const hasEvents = designData.events && designData.events.length > 0;
if (hasEvents) { if (hasEvents) {
console.log('[Generator] Generating event handlers');
lines.push( lines.push(
...generateEventHandlers( ...generateEventHandlers(
designData, designData,
@ -72,12 +72,58 @@ export class CodeGenerator {
this.indentLevel = 0; this.indentLevel = 0;
lines.push('if __name__ == "__main__":'); lines.push('if __name__ == "__main__":');
this.indentLevel = 1; this.indentLevel = 1;
lines.push(this.indent('app = Application()')); lines.push(this.indent('try:'));
lines.push(this.indent('app.run()')); 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'); 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( private generateWidgetCode(
widget: WidgetData, widget: WidgetData,
nameMap: Map<string, string> nameMap: Map<string, string>
@ -96,7 +142,9 @@ export class CodeGenerator {
lines.push(this.indent(`self.${varName}.place(${placeParams})`)); lines.push(this.indent(`self.${varName}.place(${placeParams})`));
const contentLines = generateWidgetContent(widget, varName); 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( lines.push(
...getWidgetEventBindings( ...getWidgetEventBindings(

View File

@ -1,4 +1,4 @@
import { DesignData, Event, WidgetData } from './types'; import { DesignData, DesignEvent, WidgetData } from './types';
export function generateEventHandlers( export function generateEventHandlers(
designData: DesignData, designData: DesignData,
@ -8,22 +8,39 @@ export function generateEventHandlers(
const lines: string[] = []; const lines: string[] = [];
if (designData.events && designData.events.length > 0) { if (designData.events && designData.events.length > 0) {
designData.events.forEach((event: Event) => { const handledNames = new Set<string>();
designData.events.forEach((event: DesignEvent) => {
const handlerName = event.name; const handlerName = event.name;
if (handledNames.has(handlerName)) {
return;
}
handledNames.add(handlerName);
const signature = event.signature;
if (signature) {
lines.push(indentDef(`${signature}:`));
} else {
const isBindEvent = const isBindEvent =
event.type.startsWith('<') && event.type.endsWith('>'); event.type.startsWith('<') && event.type.endsWith('>');
let hasCode = false;
const widget = (designData.widgets || []).find(
(w) => w.id === event.widget
);
if (isBindEvent) { if (isBindEvent) {
lines.push(indentDef(`def ${handlerName}(self, event):`)); lines.push(
indentDef(`def ${handlerName}(self, event=None):`)
);
} else { } else {
lines.push(indentDef(`def ${handlerName}(self):`)); 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) { for (const line of codeLines) {
if (line.trim()) { if (line.trim()) {
lines.push(indentBody(line)); lines.push(indentBody(line));

View File

@ -1,8 +1,21 @@
export interface Event { export interface DesignEvent {
widget: string; widget: string;
type: string; type: string;
name: string; name: string;
code: 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 { export interface WidgetData {
@ -12,17 +25,19 @@ export interface WidgetData {
y: number; y: number;
width: number; width: number;
height: number; height: number;
properties: { [key: string]: any }; properties: WidgetProperties;
} }
export interface DesignData { export interface DesignData {
form: { form: {
name: string;
title: string; title: string;
size: { size: {
width: number; width: number;
height: number; height: number;
}; };
className?: string;
}; };
widgets: WidgetData[]; widgets: WidgetData[];
events?: Event[]; events?: DesignEvent[];
} }

View File

@ -25,13 +25,12 @@ export function generateVariableNames(
widgets.forEach((widget) => { widgets.forEach((widget) => {
let baseName = widget.type.toLowerCase(); let baseName = widget.type.toLowerCase();
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
if (baseName === 'button') baseName = 'btn'; if (baseName === 'button') baseName = 'btn';
if (baseName === 'entry') baseName = 'entry';
if (baseName === 'checkbutton') baseName = 'chk'; if (baseName === 'checkbutton') baseName = 'chk';
if (baseName === 'radiobutton') baseName = 'radio'; if (baseName === 'radiobutton') baseName = 'radio';
if (baseName === 'label') baseName = 'lbl'; if (baseName === 'label') baseName = 'lbl';
const count = (counts.get(baseName) || 0) + 1; const count = (counts.get(baseName) || 0) + 1;
counts.set(baseName, count); counts.set(baseName, count);
names.set(widget.id, `${baseName}${count}`); names.set(widget.id, `${baseName}${count}`);

View File

@ -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 { runPythonAst } from './pythonRunner';
import { convertASTResultToDesignData } from './astConverter'; import { convertASTResultToDesignData } from './astConverter';
import { ASTResult } from './astTypes';
export class CodeParser { export class CodeParser {
public async parseCodeToDesign( public async parseCodeToDesign(
pythonCode: string pythonCode: string,
filename?: string
): Promise<DesignData | null> { ): Promise<DesignData | null> {
console.log(
'[Parser] parseCodeToDesign start, code length:',
pythonCode.length
);
const astRaw = await runPythonAst(pythonCode); 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 { if (astRaw && astRaw.error) {
const widgetRegex = vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`);
/(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<string, WidgetData>();
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 null;
} }
return {
form: { const astDesign = convertASTResultToDesignData(astRaw as ASTResult);
title: formTitle,
size: { width: formWidth, height: formHeight }, if (astDesign) {
}, if (filename) {
widgets, astDesign.form.name = filename;
events, }
}; 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;
} }
} }

View File

@ -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 { getDefaultWidth, getDefaultHeight } from './utils';
import { WidgetType, DEFAULT_FORM_CONFIG, EVENT_TYPES } from '../constants';
export function convertASTResultToDesignData( export function convertASTResultToDesignData(astResult: ASTResult): DesignData | null {
astResult: any if (!astResult) {
): DesignData | null { return null;
if (!astResult || !astResult.widgets || astResult.widgets.length === 0) { }
if (!astResult.window && !astResult.widgets) {
return null; return null;
} }
@ -12,48 +17,44 @@ export function convertASTResultToDesignData(
let formTitle = let formTitle =
(astResult.window && astResult.window.title) || (astResult.window && astResult.window.title) ||
astResult.title || astResult.title ||
'Parsed App'; DEFAULT_FORM_CONFIG.TITLE;
let formWidth = let formWidth =
(astResult.window && astResult.window.width) || astResult.width || 800; (astResult.window && astResult.window.width) || astResult.width || DEFAULT_FORM_CONFIG.WIDTH;
let formHeight = let formHeight =
(astResult.window && astResult.window.height) || (astResult.window && astResult.window.height) ||
astResult.height || astResult.height ||
600; DEFAULT_FORM_CONFIG.HEIGHT;
let className =
(astResult.window && astResult.window.className) || DEFAULT_FORM_CONFIG.CLASS_NAME;
let counter = 0; let counter = 0;
const allowedTypes = new Set([ const allowedTypes = new Set([
'Label', WidgetType.Label,
'Button', WidgetType.Button,
'Text', WidgetType.Entry,
'Checkbutton', WidgetType.Text,
'Radiobutton', WidgetType.Checkbutton,
WidgetType.Radiobutton,
]); ]);
for (const w of astResult.widgets) {
const inputWidgets = astResult.widgets || [];
for (const w of inputWidgets) {
counter++; counter++;
const type = w.type || 'Widget'; const type = w.type || WidgetType.Widget;
if (!allowedTypes.has(type)) { if (!allowedTypes.has(type as WidgetType)) {
continue; continue;
} }
const place = w.placement || {}; const place = w.placement || {};
const x = place.x !== undefined ? place.x : w.x !== undefined ? w.x : 0; 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 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 const p: Record<string, any> = w.properties || w.params || {};
? place.width
: w.width !== undefined const width = [place.width, w.width, p.width].find(v => v !== undefined) ?? getDefaultWidth(type);
? w.width const height = [place.height, w.height, p.height].find(v => v !== undefined) ?? getDefaultHeight(type);
: 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 id = w.variable_name || `ast_widget_${counter}`; const id = w.variable_name || `ast_widget_${counter}`;
const widget: WidgetData = { const widget: WidgetData = {
id, id,
@ -67,33 +68,58 @@ export function convertASTResultToDesignData(
widgets.push(widget); widgets.push(widget);
} }
const events: Event[] = []; const events: DesignEvent[] = [];
const methods = astResult.methods || {};
if (astResult.command_callbacks) { if (astResult.command_callbacks) {
for (const callback of astResult.command_callbacks) { for (const callback of astResult.command_callbacks) {
const rawName = callback.command?.name; addDesignEvent(
const cleanName = rawName events,
? String(rawName).replace(/^self\./, '') methods,
: `on_${callback.widget}_command`; callback.widget,
events.push({ EVENT_TYPES.COMMAND,
widget: callback.widget, callback.command?.name,
type: 'command', () => `on_${callback.widget}_command`,
name: cleanName, callback.command?.lambda_body
code: callback.command?.lambda_body || '', );
});
} }
} }
if (astResult.bind_events) { if (astResult.bind_events) {
for (const bindEvent of astResult.bind_events) { for (const bindEvent of astResult.bind_events) {
const rawName = bindEvent.callback?.name; addDesignEvent(
const cleanName = rawName events,
? String(rawName).replace(/^self\./, '') methods,
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`; bindEvent.widget,
events.push({ bindEvent.event,
widget: bindEvent.widget, bindEvent.callback?.name,
type: bindEvent.event, () => `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`,
name: cleanName, bindEvent.callback?.lambda_body
code: bindEvent.callback?.lambda_body || '', );
}); }
}
if (astResult.widgets) {
for (const w of astResult.widgets) {
const p: Record<string, any> = 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 = { const result: DesignData = {
form: { form: {
name: DEFAULT_FORM_CONFIG.FORM_NAME,
title: formTitle, title: formTitle,
size: { width: formWidth, height: formHeight }, size: { width: formWidth, height: formHeight },
className: className,
}, },
widgets, widgets,
events: filteredEvents.length ? filteredEvents : [], events: filteredEvents.length ? filteredEvents : [],
@ -111,8 +139,8 @@ export function convertASTResultToDesignData(
return result; return result;
} }
function extractWidgetPropertiesFromAST(w: any): any { function extractWidgetPropertiesFromAST(w: ASTWidget): WidgetProperties {
const props: any = {}; const props: WidgetProperties = {};
if (!w) return props; if (!w) return props;
const p = w.properties || w.params || {}; const p = w.properties || w.params || {};
if (p.text) props.text = p.text; if (p.text) props.text = p.text;
@ -125,3 +153,43 @@ function extractWidgetPropertiesFromAST(w: any): any {
if (p.height !== undefined) props.height = p.height; if (p.height !== undefined) props.height = p.height;
return props; return props;
} }
function addDesignEvent(
events: DesignEvent[],
methods: Record<string, any>,
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,
});
}

60
src/parser/astTypes.ts Normal file
View File

@ -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<string, any>;
params?: Record<string, any>;
}
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;
}

View File

@ -2,79 +2,178 @@ import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import * as vscode from 'vscode';
import { ASTResult } from './astTypes';
async function executePythonScript( class PythonExecutor {
pythonScriptPath: string, private static instance: PythonExecutor;
pythonFilePath: string private cachedPythonPath: string | null = null;
): Promise<string> {
return await new Promise((resolve, reject) => { private constructor() {}
const pythonCommand = getPythonCommand();
const start = Date.now(); public static getInstance(): PythonExecutor {
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath); if (!PythonExecutor.instance) {
const process = spawn(pythonCommand, [ PythonExecutor.instance = new PythonExecutor();
pythonScriptPath, }
pythonFilePath, return PythonExecutor.instance;
]); }
let result = '';
let errorOutput = ''; public async getPythonPath(): Promise<string> {
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<void> {
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);
});
});
}
private async findPythonCommands(): Promise<string[]> {
const commands: string[] = [];
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<string>('defaultInterpreterPath') ||
config.get<string>('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<string> {
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) => { process.stdout.on('data', (data) => {
result += data.toString(); stdout += data.toString();
}); });
process.stderr.on('data', (data) => { process.stderr.on('data', (data) => {
errorOutput += data.toString(); stderr += data.toString();
}); });
process.on('close', (code) => { process.on('close', (code) => {
const ms = Date.now() - start; if (code !== 0) {
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms); reject(new Error(stderr || `Process exited with code ${code}`));
if (code === 0) {
resolve(result);
} else { } else {
reject( resolve(stdout);
new Error(
`Python script failed with code ${code}: ${errorOutput}`
)
);
} }
}); });
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 { export async function executePythonScript(
pythonScriptPath: string,
pythonCode: string
): Promise<string> {
try { try {
fs.unlinkSync(tempFile); const executor = PythonExecutor.getInstance();
} catch {} return await executor.executeScript(pythonScriptPath, [], pythonCode);
} catch (error: any) {
throw error;
}
} }
export async function runPythonAst(pythonCode: string): Promise<any | null> { export async function runPythonAst(code: string): Promise<ASTResult | { error: string }> {
const tempFilePath = createTempPythonFile(pythonCode);
try { try {
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py'); const executor = PythonExecutor.getInstance();
const output = await executePythonScript( const scriptPath = path.join(
pythonScriptPath, __dirname,
tempFilePath '..',
'..',
'src',
'parser',
'tkinter_ast_parser.py'
); );
console.log('[PythonRunner] Received AST JSON length:', output.length);
const parsed = JSON.parse(output); const output = await executor.executeScript(scriptPath, [], code);
return parsed; try {
} catch (err) { return JSON.parse(output) as ASTResult;
console.error('[PythonRunner] Error running Python AST:', err); } catch (e) {
return null; return { error: 'Failed to parse JSON output: ' + e };
} finally { }
cleanupTempFile(tempFilePath); } catch (error: any) {
console.log('[PythonRunner] Temp file cleaned:', tempFilePath); return { error: error.message };
} }
} }

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,7 @@
import ast import ast
import textwrap
import tokenize
import io
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
from .imports import handle_import, handle_import_from from .imports import handle_import, handle_import_from
@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
class TkinterAnalyzer(ast.NodeVisitor): 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.widgets: List[Dict[str, Any]] = []
self.window_config = {'title': 'App', 'width': 800, 'height': 600} self.window_config = {'title': 'App', 'width': 800, 'height': 600}
self.imports: Dict[str, str] = {} self.imports: Dict[str, str] = {}
@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
self.event_handlers: List[Dict[str, Any]] = [] self.event_handlers: List[Dict[str, Any]] = []
self.command_callbacks: List[Dict[str, Any]] = [] self.command_callbacks: List[Dict[str, Any]] = []
self.bind_events: List[Dict[str, Any]] = [] self.bind_events: List[Dict[str, Any]] = []
self.methods: Dict[str, str] = {}
def visit_Import(self, node: ast.Import): def visit_Import(self, node: ast.Import):
handle_import(self, node) handle_import(self, node)
@ -36,12 +48,119 @@ class TkinterAnalyzer(ast.NodeVisitor):
def visit_ClassDef(self, node: ast.ClassDef): def visit_ClassDef(self, node: ast.ClassDef):
prev = self.current_class prev = self.current_class
enter_class(self, node) 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) self.generic_visit(node)
exit_class(self, prev) exit_class(self, prev)
def visit_FunctionDef(self, node: ast.FunctionDef): def visit_FunctionDef(self, node: ast.FunctionDef):
prev = self.current_method prev = self.current_method
enter_function(self, node) 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) self.generic_visit(node)
exit_function(self, prev) exit_function(self, prev)
@ -101,9 +220,3 @@ class TkinterAnalyzer(ast.NodeVisitor):
def analyze_widget_creation_commands(self, node: ast.Assign): def analyze_widget_creation_commands(self, node: ast.Assign):
return analyze_widget_creation_commands(self, node) 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)

View File

@ -15,15 +15,11 @@ def handle_method_call(analyzer, node: ast.Call):
arg0 = node.args[0] arg0 = node.args[0]
if isinstance(arg0, ast.Constant): if isinstance(arg0, ast.Constant):
analyzer.window_config['title'] = arg0.value analyzer.window_config['title'] = arg0.value
elif isinstance(arg0, ast.Str):
analyzer.window_config['title'] = arg0.s
elif method_name == 'geometry' and node.args: elif method_name == 'geometry' and node.args:
arg0 = node.args[0] arg0 = node.args[0]
if isinstance(arg0, ast.Constant): if isinstance(arg0, ast.Constant):
geometry = arg0.value geometry = arg0.value
elif isinstance(arg0, ast.Str):
geometry = arg0.s
else: else:
return return
if 'x' in str(geometry): if 'x' in str(geometry):

View File

@ -15,10 +15,6 @@ def get_variable_name(node: ast.AST) -> str:
def extract_value(node: ast.AST) -> Any: def extract_value(node: ast.AST) -> Any:
if isinstance(node, ast.Constant): if isinstance(node, ast.Constant):
return node.value return node.value
elif isinstance(node, ast.Str):
return node.s
elif isinstance(node, ast.Num):
return node.n
elif isinstance(node, ast.Name): elif isinstance(node, ast.Name):
return f"${node.id}" return f"${node.id}"
elif isinstance(node, ast.Attribute): 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: def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
body = lambda_node.body body = lambda_node.body
if isinstance(body, (ast.Constant, ast.Str, ast.Num)): if isinstance(body, ast.Constant):
return 'simple' return 'simple'
elif isinstance(body, (ast.Name, ast.Attribute)): elif isinstance(body, (ast.Name, ast.Attribute)):
return 'simple' return 'simple'
@ -76,11 +72,9 @@ def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
def extract_lambda_body(body_node: ast.AST) -> str: def extract_lambda_body(body_node: ast.AST) -> str:
if isinstance(body_node, ast.Constant): if isinstance(body_node, ast.Constant):
if isinstance(body_node.value, str):
return f'"{body_node.value}"'
return str(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): elif isinstance(body_node, ast.Name):
return body_node.id return body_node.id
elif isinstance(body_node, ast.Attribute): 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: for value in body_node.values:
if isinstance(value, ast.Constant): if isinstance(value, ast.Constant):
parts.append(str(value.value)) parts.append(str(value.value))
elif isinstance(value, ast.Str):
parts.append(value.s)
else: else:
parts.append(f"{{{extract_lambda_body(value)}}}") parts.append(f"{{{extract_lambda_body(value)}}}")
return f"f\"{''.join(parts)}\"" return f"f\"{''.join(parts)}\""

View File

@ -19,10 +19,13 @@ def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool:
widget_type = call_node.func.attr widget_type = call_node.func.attr
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values(): if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton'] return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
return False resolved = analyzer.imports.get(module_name, module_name)
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton'] 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: def is_widget_creation(analyzer, node: ast.Assign) -> bool:
if not isinstance(node.value, ast.Call): 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): if isinstance(node.value.func, ast.Attribute):
return is_tkinter_widget_call(analyzer, node.value) return is_tkinter_widget_call(analyzer, node.value)
elif isinstance(node.value.func, ast.Name): 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 return False
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]: 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): if isinstance(call_node.func, ast.Attribute):
widget_type = call_node.func.attr 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): elif isinstance(call_node.func, ast.Name):
widget_type = call_node.func.id widget_type = call_node.func.id
else: else:

View File

@ -9,7 +9,7 @@ from tk_ast.grid_layout import GridLayoutAnalyzer
def parse_tkinter_code(code: str) -> Dict[str, Any]: def parse_tkinter_code(code: str) -> Dict[str, Any]:
try: try:
tree = ast.parse(code) tree = ast.parse(code)
analyzer = TkinterAnalyzer() analyzer = TkinterAnalyzer(code)
analyzer.visit(tree) analyzer.visit(tree)
grid_analyzer = GridLayoutAnalyzer() grid_analyzer = GridLayoutAnalyzer()
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets) widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
@ -18,6 +18,7 @@ def parse_tkinter_code(code: str) -> Dict[str, Any]:
'widgets': widgets, 'widgets': widgets,
'command_callbacks': analyzer.command_callbacks, 'command_callbacks': analyzer.command_callbacks,
'bind_events': analyzer.bind_events, 'bind_events': analyzer.bind_events,
'methods': analyzer.methods,
'success': True 'success': True
} }

View File

@ -1,12 +1,16 @@
#!/usr/bin/env python3
import sys import sys
import json import json
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try: try:
from tk_ast.parser import parse_tkinter_code, parse_file from tk_ast.parser import parse_tkinter_code, parse_file
except Exception as e: except ImportError as e:
raise RuntimeError( raise RuntimeError(
f"Failed to import tk_ast package: {e}. Ensure 'tk_ast' exists next to this script." 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 __name__ == '__main__':
if len(sys.argv) > 1: if len(sys.argv) > 1:
print(parse_file(sys.argv[1])) sys.stdout.write(str(parse_file(sys.argv[1])) + '\n')
else: else:
code = sys.stdin.read() code = sys.stdin.read()
result = parse_tkinter_code(code) result = parse_tkinter_code(code)
print(json.dumps(result, indent=2, ensure_ascii=False)) sys.stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + '\n')

View File

@ -1,21 +1,9 @@
import { WidgetType, WIDGET_DIMENSIONS } from '../constants';
export function getDefaultWidth(type: string): number { export function getDefaultWidth(type: string): number {
const defaults: { [key: string]: number } = { return WIDGET_DIMENSIONS[type as WidgetType]?.width || WIDGET_DIMENSIONS.DEFAULT.width;
Label: 100,
Button: 80,
Text: 200,
Checkbutton: 100,
Radiobutton: 100,
};
return defaults[type] || 100;
} }
export function getDefaultHeight(type: string): number { export function getDefaultHeight(type: string): number {
const defaults: { [key: string]: number } = { return WIDGET_DIMENSIONS[type as WidgetType]?.height || WIDGET_DIMENSIONS.DEFAULT.height;
Label: 25,
Button: 30,
Text: 100,
Checkbutton: 25,
Radiobutton: 25,
};
return defaults[type] || 25;
} }

View File

@ -1,34 +1,26 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as path from 'path'; 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 { export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'tkinter-designer'; public static readonly viewType = 'tkinter-designer';
public static _instance: TkinterDesignerProvider | undefined; private _view?: vscode.WebviewPanel | vscode.WebviewView;
private _view?: vscode.WebviewPanel; private _designData: DesignData = {
private _designData: any = {
widgets: [], widgets: [],
events: [], 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) {} constructor(private readonly _extensionUri: vscode.Uri) {}
public static createOrShow(extensionUri: vscode.Uri) { public static createNew(extensionUri: vscode.Uri): TkinterDesignerProvider {
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');
const panel = vscode.window.createWebviewPanel( const panel = vscode.window.createWebviewPanel(
TkinterDesignerProvider.viewType, TkinterDesignerProvider.viewType,
'Tkinter Designer', 'Tkinter Designer',
column || vscode.ViewColumn.One, vscode.ViewColumn.Beside,
{ {
enableScripts: true, enableScripts: true,
retainContextWhenHidden: 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; const provider = new TkinterDesignerProvider(extensionUri);
TkinterDesignerProvider._instance._setWebviewContent(panel.webview); provider._view = panel;
TkinterDesignerProvider._instance._setupMessageHandling(panel.webview); provider._setWebviewContent(panel.webview);
provider._setupMessageHandling(panel.webview);
panel.onDidDispose(() => { panel.onDidDispose(() => {
console.log('[Webview] Panel disposed'); provider._view = undefined;
if (TkinterDesignerProvider._instance) {
TkinterDesignerProvider._instance._view = undefined;
}
}); });
return TkinterDesignerProvider._instance; return provider;
} }
public resolveWebviewView( public resolveWebviewView(
@ -65,7 +50,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
context: vscode.WebviewViewResolveContext, context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken _token: vscode.CancellationToken
) { ) {
this._view = webviewView as any; this._view = webviewView;
webviewView.webview.options = { webviewView.webview.options = {
enableScripts: true, enableScripts: true,
localResourceRoots: [ localResourceRoots: [
@ -85,8 +70,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
} }
private _setupMessageHandling(webview: vscode.Webview) { private _setupMessageHandling(webview: vscode.Webview) {
webview.onDidReceiveMessage((message) => { webview.onDidReceiveMessage(async (message) => {
console.log('[Webview] Message:', message.type);
switch (message.type) { switch (message.type) {
case 'designUpdated': case 'designUpdated':
this._designData = message.data; this._designData = message.data;
@ -107,12 +91,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
type: 'loadDesign', type: 'loadDesign',
data: this._designData, data: this._designData,
}); });
console.log('[Webview] Sent loadDesign');
} }
break; break;
case 'generateCode': case 'generateCode':
console.log('[Webview] Generating code from webview'); await this.handleGenerateCode(message.data);
this.handleGenerateCode(message.data); break;
case 'applyChanges':
await this.handleApplyChanges(message.data);
break; break;
case 'showInfo': case 'showInfo':
vscode.window.showInformationMessage(message.text); vscode.window.showInformationMessage(message.text);
@ -120,6 +105,15 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
case 'showError': case 'showError':
vscode.window.showErrorMessage(message.text); vscode.window.showErrorMessage(message.text);
break; break;
case 'exportProject':
await ProjectIO.exportProject(message.data);
break;
case 'importProject':
const data = await ProjectIO.importProject();
if (data) {
this.loadDesignData(data);
}
break;
} }
}, undefined); }, undefined);
} }
@ -136,38 +130,17 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
type: 'loadDesign', type: 'loadDesign',
data: this._designData, data: this._designData,
}); });
console.log('[Webview] loadDesign posted');
} else {
} }
} }
private async handleGenerateCode(designData: any): Promise<void> { private async handleGenerateCode(designData: any): Promise<void> {
try { try {
console.log('[GenerateCode] Start');
const { CodeGenerator } = await import( const { CodeGenerator } = await import(
'../generator/CodeGenerator' '../generator/codeGenerator'
); );
const generator = new CodeGenerator(); const generator = new CodeGenerator();
const pythonCode = generator.generateTkinterCode(designData); 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]; const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) { if (!workspaceFolder) {
vscode.window.showErrorMessage( vscode.window.showErrorMessage(
@ -175,12 +148,27 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
); );
return; return;
} }
const fileName = `app_${Date.now()}.py`;
const filePath = path.join( const formName = designData.form.name || 'Form1';
workspaceFolder.uri.fsPath, let fileName = `${formName}.py`;
fileName let fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
); let counter = 1;
const fileUri = Uri.file(filePath);
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 encoder = new TextEncoder();
const fileBytes = encoder.encode(pythonCode); const fileBytes = encoder.encode(pythonCode);
await vscode.workspace.fs.writeFile(fileUri, fileBytes); await vscode.workspace.fs.writeFile(fileUri, fileBytes);
@ -192,14 +180,213 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
vscode.window.showInformationMessage( vscode.window.showInformationMessage(
`Python file created: ${fileName}` `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,
});
}
} }
console.log('[GenerateCode] Done');
} catch (error) { } catch (error) {
console.error('[GenerateCode] Error:', error);
vscode.window.showErrorMessage(`Error generating code: ${error}`); vscode.window.showErrorMessage(`Error generating code: ${error}`);
} }
} }
private async handleApplyChanges(designData: DesignData): Promise<void> {
try {
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);
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);
fileContent = doc.getText();
const astResult = await runPythonAst(fileContent);
if (astResult && !('error' in astResult)) {
if (astResult.methods) {
existingMethods = astResult.methods as Record<string, any>;
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(
`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}`
);
}
} catch (error) {
vscode.window.showErrorMessage(`Error applying changes: ${error}`);
}
}
private _getHtmlForWebview(webview: vscode.Webview): string { private _getHtmlForWebview(webview: vscode.Webview): string {
const styleUri = webview.asWebviewUri( const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath( vscode.Uri.joinPath(

95
src/webview/projectIO.ts Normal file
View File

@ -0,0 +1,95 @@
import * as vscode from 'vscode';
import { DesignData } from '../generator/types';
export class ProjectIO {
public static async exportProject(data: DesignData): Promise<void> {
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<DesignData | null> {
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;
}
}

View File

@ -5,10 +5,12 @@ import { PropertiesPanel } from './components/PropertiesPanel';
import { EventsPanel } from './components/EventsPanel'; import { EventsPanel } from './components/EventsPanel';
import { Canvas } from './components/Canvas'; import { Canvas } from './components/Canvas';
import { useMessaging } from './useMessaging'; import { useMessaging } from './useMessaging';
import { ErrorBoundary } from './components/ErrorBoundary';
export function App() { export function App() {
useMessaging(); useMessaging();
return ( return (
<ErrorBoundary>
<div className="container"> <div className="container">
<Toolbar /> <Toolbar />
<div className="main-content"> <div className="main-content">
@ -23,5 +25,6 @@ export function App() {
</div> </div>
</div> </div>
</div> </div>
</ErrorBoundary>
); );
} }

View File

@ -2,6 +2,69 @@ import React, { useRef } from 'react';
import { useAppDispatch, useAppState } from '../state'; import { useAppDispatch, useAppState } from '../state';
import type { WidgetType } from '../types'; import type { WidgetType } from '../types';
import { Widget } from '../types';
function renderWidgetContent(w: Widget) {
switch (w.type) {
case 'Label':
return (
<div className="widget-label-content">
{w.properties?.text || 'Label'}
</div>
);
case 'Button':
return (
<div className="widget-button-content">
{w.properties?.text || 'Button'}
</div>
);
case 'Entry':
return (
<input
type="text"
disabled
placeholder="Entry"
className="widget-entry-content"
/>
);
case 'Text':
return (
<textarea
disabled
className="widget-text-content"
>
Text Area
</textarea>
);
case 'Checkbutton':
return (
<div className="widget-check-content">
<input
type="checkbox"
disabled
checked
className="widget-check-input"
/>
<span className="widget-check-text">{w.properties?.text || 'Check'}</span>
</div>
);
case 'Radiobutton':
return (
<div className="widget-radio-content">
<input
type="radio"
disabled
checked
className="widget-check-input"
/>
<span className="widget-check-text">{w.properties?.text || 'Radio'}</span>
</div>
);
default:
return w.properties?.text || w.type;
}
}
export function Canvas() { export function Canvas() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { design, selectedWidgetId } = useAppState(); const { design, selectedWidgetId } = useAppState();
@ -17,12 +80,10 @@ export function Canvas() {
const rect = containerRef.current?.getBoundingClientRect(); const rect = containerRef.current?.getBoundingClientRect();
const x = e.clientX - (rect?.left || 0); const x = e.clientX - (rect?.left || 0);
const y = e.clientY - (rect?.top || 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 } }); if (type) dispatch({ type: 'addWidget', payload: { type, x, y } });
}; };
const onSelect = (id: string | null) => { const onSelect = (id: string | null) => {
console.log('[Canvas] Select widget', id);
dispatch({ type: 'selectWidget', payload: { id } }); dispatch({ type: 'selectWidget', payload: { id } });
}; };
@ -34,19 +95,126 @@ export function Canvas() {
if (!w) return; if (!w) return;
const initX = w.x; const initX = w.x;
const initY = w.y; const initY = w.y;
console.log('[Canvas] Drag start', id, 'at', initX, initY);
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX; const dx = ev.clientX - startX;
const dy = ev.clientY - startY; const dy = ev.clientY - startY;
const newX = initX + dx;
const newY = initY + dy;
dispatch({ dispatch({
type: 'updateWidget', type: 'updateWidget',
payload: { id, patch: { x: initX + dx, y: initY + dy } }, payload: { id, patch: { x: newX, y: newY } },
}); });
}; };
const onUp = () => { const onUp = () => {
window.removeEventListener('mousemove', onMove); window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp); 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<HTMLDivElement>,
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<Widget> = {};
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<HTMLDivElement>,
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('mousemove', onMove);
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
@ -54,9 +222,24 @@ export function Canvas() {
return ( return (
<div className="canvas-container"> <div className="canvas-container">
<div className="window-frame">
<div className="window-title-bar">
<div className="window-title">
{design.form?.title || 'Tkinter App'}
</div>
<div className="window-controls">
<div className="window-control minimize"></div>
<div className="window-control maximize"></div>
<div className="window-control close"></div>
</div>
</div>
<div <div
id="designCanvas" id="designCanvas"
className="design-canvas" className="design-canvas"
style={{
'--canvas-width': `${design.form.size.width}px`,
'--canvas-height': `${design.form.size.height}px`,
} as React.CSSProperties}
ref={containerRef} ref={containerRef}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={onDrop} onDrop={onDrop}
@ -72,12 +255,11 @@ export function Canvas() {
key={w.id} key={w.id}
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`} className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
style={{ style={{
position: 'absolute', '--widget-x': `${w.x}px`,
left: w.x, '--widget-y': `${w.y}px`,
top: w.y, '--widget-width': `${w.width}px`,
width: w.width, '--widget-height': `${w.height}px`,
height: w.height, } as React.CSSProperties}
}}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSelect(w.id); onSelect(w.id);
@ -85,11 +267,79 @@ export function Canvas() {
onMouseDown={(e) => onMouseDown(e, w.id)} onMouseDown={(e) => onMouseDown(e, w.id)}
> >
<div className="widget-content"> <div className="widget-content">
{w.properties?.text || w.type} {renderWidgetContent(w)}
</div> </div>
{selectedWidgetId === w.id && (
<>
<div
className="resize-handle n"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'n')
}
/>
<div
className="resize-handle s"
onMouseDown={(e) =>
onResizeStart(e, w.id, 's')
}
/>
<div
className="resize-handle e"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'e')
}
/>
<div
className="resize-handle w"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'w')
}
/>
<div
className="resize-handle ne"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'ne')
}
/>
<div
className="resize-handle nw"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'nw')
}
/>
<div
className="resize-handle se"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'se')
}
/>
<div
className="resize-handle sw"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'sw')
}
/>
</>
)}
</div> </div>
))} ))}
</div> </div>
<div
className="window-resize-handle e"
onMouseDown={(e) => onWindowResizeStart(e, 'e')}
onDragStart={(e) => e.preventDefault()}
/>
<div
className="window-resize-handle s"
onMouseDown={(e) => onWindowResizeStart(e, 's')}
onDragStart={(e) => e.preventDefault()}
/>
<div
className="window-resize-handle se"
onMouseDown={(e) => onWindowResizeStart(e, 'se')}
onDragStart={(e) => e.preventDefault()}
/>
</div>
</div> </div>
); );
} }

View File

@ -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<Props, State> {
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 (
<div style={{ padding: '20px', color: 'red', backgroundColor: '#ffe6e6', border: '1px solid red', borderRadius: '4px' }}>
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
</details>
<button
style={{ marginTop: '10px', padding: '5px 10px', cursor: 'pointer' }}
onClick={() => this.setState({ hasError: false })}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -5,8 +5,7 @@ export function EventsPanel() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { design, selectedWidgetId, vscode } = useAppState(); const { design, selectedWidgetId, vscode } = useAppState();
const [eventType, setEventType] = useState('command'); const [eventType, setEventType] = useState('command');
const [eventName, setEventName] = useState('onClick'); const [eventName, setEventName] = useState('on_click');
const [eventCode, setEventCode] = useState('print("clicked")');
const w = design.widgets.find((x) => x.id === selectedWidgetId); const w = design.widgets.find((x) => x.id === selectedWidgetId);
const widgetEvents = (id: string | undefined) => const widgetEvents = (id: string | undefined) =>
@ -20,10 +19,17 @@ export function EventsPanel() {
}); });
return; return;
} }
if (!eventType || !eventName || !eventCode) { if (!eventName) {
vscode.postMessage({ vscode.postMessage({
type: 'showError', 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; return;
} }
@ -33,21 +39,39 @@ export function EventsPanel() {
widget: w.id, widget: w.id,
type: eventType, type: eventType,
name: eventName, name: eventName,
code: eventCode, code: 'pass',
}, },
}); });
vscode.postMessage({ vscode.postMessage({
type: 'showInfo', type: 'showInfo',
text: `Event added: ${eventType} -> ${eventName}`, text: `Event added: ${eventName}`,
}); });
}; };
const remove = (type: string) => { const remove = (type: string, name: string) => {
if (!w) return; 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' }); vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
}; };
const commonEvents = [
'command',
'<Button-1>',
'<Button-2>',
'<Button-3>',
'<Double-Button-1>',
'<Enter>',
'<Leave>',
'<FocusIn>',
'<FocusOut>',
'<Return>',
'<Key>',
'<Configure>',
];
return ( return (
<div className="events-panel"> <div className="events-panel">
<h3>Events & Commands</h3> <h3>Events & Commands</h3>
@ -60,12 +84,41 @@ export function EventsPanel() {
<label className="property-label"> <label className="property-label">
Event Type: Event Type:
</label> </label>
<div className="mb-8">
<select
className="event-handler-input mb-4"
value={
commonEvents.includes(eventType)
? eventType
: 'custom'
}
onChange={(e) => {
if (e.target.value !== 'custom') {
setEventType(e.target.value);
} else {
setEventType('');
}
}}
>
{commonEvents.map((evt) => (
<option key={evt} value={evt}>
{evt}
</option>
))}
<option value="custom">Custom...</option>
</select>
{!commonEvents.includes(eventType) && (
<input <input
className="event-type-select" className="event-handler-input"
type="text" type="text"
placeholder="Enter custom event type"
value={eventType} value={eventType}
onChange={(e) => setEventType(e.target.value)} onChange={(e) =>
setEventType(e.target.value)
}
/> />
)}
</div>
<label className="property-label"> <label className="property-label">
Handler Name: Handler Name:
</label> </label>
@ -75,14 +128,6 @@ export function EventsPanel() {
value={eventName} value={eventName}
onChange={(e) => setEventName(e.target.value)} onChange={(e) => setEventName(e.target.value)}
/> />
<label className="property-label">
Handler Code:
</label>
<textarea
className="event-handler-input"
value={eventCode}
onChange={(e) => setEventCode(e.target.value)}
/>
</div> </div>
<div className="event-buttons"> <div className="event-buttons">
<button className="event-btn primary" onClick={add}> <button className="event-btn primary" onClick={add}>
@ -97,23 +142,26 @@ export function EventsPanel() {
widgetEvents(w.id).map((ev) => ( widgetEvents(w.id).map((ev) => (
<div <div
className="event-item" className="event-item"
key={`${ev.widget}-${ev.type}`} key={`${ev.widget}-${ev.type}-${ev.name}`}
> >
<div className="event-info"> <div className="event-info">
<span className="event-name"> <span
{ev.name} className="event-name event-label-bold"
</span>{' '} >
<span className="event-handler"> {ev.type}:
{ev.type} </span>
<span
className="event-name event-value-margin"
>
{ev.name}
</span> </span>
<div className="event-code">
{ev.code}
</div>
</div> </div>
<div className="event-actions"> <div className="event-actions">
<button <button
className="event-btn secondary" className="event-btn secondary"
onClick={() => remove(ev.type)} onClick={() =>
remove(ev.type, ev.name)
}
> >
Remove Remove
</button> </button>

View File

@ -5,6 +5,7 @@ import type { WidgetType } from '../types';
const WIDGETS: WidgetType[] = [ const WIDGETS: WidgetType[] = [
'Label', 'Label',
'Button', 'Button',
'Entry',
'Text', 'Text',
'Checkbutton', 'Checkbutton',
'Radiobutton', 'Radiobutton',
@ -15,7 +16,6 @@ export function Palette() {
e: React.DragEvent<HTMLDivElement>, e: React.DragEvent<HTMLDivElement>,
type: WidgetType type: WidgetType
) => { ) => {
console.log('[Palette] Drag start', type);
e.dataTransfer.setData('text/plain', type); e.dataTransfer.setData('text/plain', type);
}; };
return ( return (

View File

@ -1,19 +1,88 @@
import React from 'react'; import React from 'react';
import { useAppDispatch, useAppState } from '../state'; import { useAppDispatch, useAppState } from '../state';
import { Widget } from '../types';
export function PropertiesPanel() { export function PropertiesPanel() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { design, selectedWidgetId } = useAppState(); const { design, selectedWidgetId } = useAppState();
const updateForm = <K extends keyof typeof design.form>(key: K, value: typeof design.form[K]) => {
dispatch({
type: 'setForm',
payload: { [key]: value },
});
};
if (selectedWidgetId === null) {
return (
<div className="properties-panel">
<h3>Form Properties</h3>
<div className="properties-form">
<div className="property-row">
<label className="property-label">Title:</label>
<input
className="property-input"
type="text"
value={design.form.title}
onChange={(e) =>
updateForm('title', e.target.value)
}
/>
</div>
<div className="property-row">
<label className="property-label">Class Name:</label>
<input
className="property-input"
type="text"
value={design.form.className || 'Application'}
onChange={(e) =>
updateForm('className', e.target.value)
}
/>
</div>
<div className="property-row">
<label className="property-label">Width:</label>
<input
className="property-input"
type="number"
value={design.form.size.width}
onChange={(e) =>
updateForm('size', {
...design.form.size,
width: Number(e.target.value),
})
}
/>
</div>
<div className="property-row">
<label className="property-label">Height:</label>
<input
className="property-input"
type="number"
value={design.form.size.height}
onChange={(e) =>
updateForm('size', {
...design.form.size,
height: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
);
}
const w = design.widgets.find((x) => x.id === selectedWidgetId); const w = design.widgets.find((x) => x.id === selectedWidgetId);
const update = (patch: Partial<typeof w>) => { const update = (patch: Partial<Widget>) => {
if (!w) return; if (!w) return;
dispatch({ dispatch({
type: 'updateWidget', type: 'updateWidget',
payload: { id: w.id, patch: patch as any }, payload: { id: w.id, patch },
}); });
}; };
const updateProp = (key: string, value: any) => { const updateProp = (key: string, value: string | number | boolean) => {
if (!w) return; if (!w) return;
dispatch({ dispatch({
type: 'updateProps', type: 'updateProps',
@ -27,7 +96,7 @@ export function PropertiesPanel() {
return ( return (
<div className="properties-panel"> <div className="properties-panel">
<h3>Properties</h3> <h3>Widget Properties</h3>
<div id="propertiesContent"> <div id="propertiesContent">
{!w ? ( {!w ? (
<p>Select a widget to edit properties</p> <p>Select a widget to edit properties</p>

View File

@ -1,36 +1,23 @@
import React, { useRef } from 'react'; import React from 'react';
import { useAppDispatch, useAppState } from '../state'; import { useAppDispatch, useAppState } from '../state';
export function Toolbar() { export function Toolbar() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { vscode, design } = useAppState(); const { vscode, design } = useAppState();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const exportProject = () => { const exportProject = () => {
try { try {
console.log('[Toolbar] Export project');
const exportData = { const exportData = {
version: '1.0', version: '1.0',
created: new Date().toISOString(), created: new Date().toISOString(),
description: 'Tkinter Designer Project', description: 'Tkinter Designer Project',
data: design, data: design,
}; };
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tkinter-project-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
vscode.postMessage({ vscode.postMessage({
type: 'showInfo', type: 'exportProject',
text: 'Project exported successfully!', data: exportData,
}); });
} catch (error: any) { } catch (error: any) {
console.error('Export error:', error);
vscode.postMessage({ vscode.postMessage({
type: 'showError', type: 'showError',
text: `Export failed: ${error.message}`, text: `Export failed: ${error.message}`,
@ -38,41 +25,13 @@ export function Toolbar() {
} }
}; };
const importProject = (e: React.ChangeEvent<HTMLInputElement>) => { const importProject = () => {
console.log('[Toolbar] Import project');
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const jsonString = ev.target?.result as string;
const obj = JSON.parse(jsonString);
if (!obj || !obj.data)
throw new Error('Invalid project file format');
dispatch({ type: 'init', payload: obj.data });
vscode.postMessage({ vscode.postMessage({
type: 'showInfo', type: 'importProject',
text: 'Project imported successfully!',
}); });
} catch (error: any) {
console.error('Import error:', error);
vscode.postMessage({
type: 'showError',
text: `Import failed: ${error.message}`,
});
}
};
reader.onerror = () =>
vscode.postMessage({
type: 'showError',
text: 'Failed to read file',
});
reader.readAsText(file);
e.target.value = '';
}; };
const clearAll = () => { const clearAll = () => {
console.log('[Toolbar] Clear all');
dispatch({ type: 'clear' }); dispatch({ type: 'clear' });
vscode.postMessage({ vscode.postMessage({
type: 'showInfo', type: 'showInfo',
@ -81,7 +40,6 @@ export function Toolbar() {
}; };
const generateCode = () => { const generateCode = () => {
console.log('[Toolbar] Generate code');
if (design.widgets.length === 0) { if (design.widgets.length === 0) {
vscode.postMessage({ vscode.postMessage({
type: 'showError', type: 'showError',
@ -96,18 +54,42 @@ export function Toolbar() {
vscode.postMessage({ type: 'generateCode', data: design }); vscode.postMessage({ type: 'generateCode', data: design });
}; };
const applyChanges = () => {
if (design.widgets.length === 0) {
vscode.postMessage({
type: 'showError',
text: 'No widgets to apply changes!',
});
return;
}
vscode.postMessage({
type: 'showInfo',
text: 'Applying changes...',
});
vscode.postMessage({ type: 'applyChanges', data: design });
};
return ( return (
<div className="toolbar"> <div className="toolbar">
<input
ref={fileInputRef}
type="file"
id="importFileInput"
accept=".json"
style={{ display: 'none' }}
onChange={importProject}
/>
<h2>Tkinter Visual Designer</h2> <h2>Tkinter Visual Designer</h2>
<div className="toolbar-buttons"> <div className="toolbar-buttons">
<div className="toolbar-group">
<label htmlFor="formName" className="toolbar-label">
Form Name:
</label>
<input
id="formName"
type="text"
value={design.form.name || 'Form1'}
onChange={(e) =>
dispatch({
type: 'setForm',
payload: { name: e.target.value },
})
}
className="toolbar-input"
/>
</div>
<button <button
id="undoBtn" id="undoBtn"
className="btn btn-outline" className="btn btn-outline"
@ -137,7 +119,7 @@ export function Toolbar() {
id="importBtn" id="importBtn"
className="btn btn-outline" className="btn btn-outline"
title="Import project from JSON" title="Import project from JSON"
onClick={() => fileInputRef.current?.click()} onClick={importProject}
> >
📥 Import 📥 Import
</button> </button>
@ -149,6 +131,13 @@ export function Toolbar() {
> >
Generate Code Generate Code
</button> </button>
<button
id="applyBtn"
className="btn btn-primary"
onClick={applyChanges}
>
Apply Changes
</button>
<button <button
id="clearBtn" id="clearBtn"
className="btn btn-secondary" className="btn btn-secondary"

View File

@ -12,7 +12,7 @@ declare global {
const vscode = const vscode =
typeof window.acquireVsCodeApi === 'function' typeof window.acquireVsCodeApi === 'function'
? window.acquireVsCodeApi() ? window.acquireVsCodeApi()
: { postMessage: (msg: any) => console.log('[Webview message]', msg) }; : { postMessage: (msg: any) => {} };
const container = document.getElementById('root'); const container = document.getElementById('root');
if (container) { if (container) {

View File

@ -1,4 +1,5 @@
import React, { createContext, useContext, useReducer, useMemo } from 'react'; import React, { createContext, useContext, useReducer, useMemo } from 'react';
import { produce } from 'immer';
import type { DesignData, Widget, WidgetType, EventBinding } from './types'; import type { DesignData, Widget, WidgetType, EventBinding } from './types';
export interface AppState { export interface AppState {
@ -20,39 +21,90 @@ type Action =
} }
| { type: 'deleteWidget'; payload: { id: string } } | { type: 'deleteWidget'; payload: { id: string } }
| { type: 'addEvent'; payload: EventBinding } | { type: 'addEvent'; payload: EventBinding }
| { type: 'removeEvent'; payload: { widget: string; type: string } } | {
type: 'removeEvent';
payload: { widget: string; type: string; name?: string };
}
| { type: 'clear' } | { type: 'clear' }
| { type: 'undo' } | { type: 'undo' }
| { type: 'redo' } | { type: 'redo' }
| { | {
type: 'setForm'; type: 'setForm';
payload: { payload: {
name?: string;
title?: string; title?: string;
size?: { width: number; height: number }; size?: { width: number; height: number };
className?: string;
}; };
}; }
| { type: 'pushHistory' };
const AppStateContext = createContext<AppState | undefined>(undefined); const AppStateContext = createContext<AppState | undefined>(undefined);
const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>( const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
undefined undefined
); );
function clone(data: DesignData): DesignData { // Helper to check if two design states are effectively different to warrant a history entry
return JSON.parse(JSON.stringify(data)); // This helps prevent spamming history with tiny drag updates if we were to push every frame (though we usually push on mouse up)
function hasDesignChanged(prev: DesignData, next: DesignData): boolean {
return JSON.stringify(prev) !== JSON.stringify(next);
} }
function reducer(state: AppState, action: Action): AppState { function reducer(state: AppState, action: Action): AppState {
console.log('[State] Action:', action.type); if (action.type === 'undo') {
if (state.historyIndex <= 0) return state;
const idx = state.historyIndex - 1;
return {
...state,
historyIndex: idx,
design: JSON.parse(JSON.stringify(state.history[idx])),
};
}
if (action.type === 'redo') {
if (state.historyIndex >= state.history.length - 1) return state;
const idx = state.historyIndex + 1;
return {
...state,
historyIndex: idx,
design: JSON.parse(JSON.stringify(state.history[idx])),
};
}
if (action.type === 'pushHistory') {
// This action is explicitly called when an operation is "finished" (e.g. onMouseUp after drag)
if (state.historyIndex >= 0 && !hasDesignChanged(state.history[state.historyIndex], state.design)) {
return state;
}
return produce(state, (draft) => {
pushHistoryDraft(draft);
});
}
// For other actions, we update the state but DO NOT push to history automatically
// unless it's a discrete action like 'addWidget' or 'deleteWidget'.
// Continuous actions like 'updateWidget' (drag) should only update state,
// and rely on a subsequent 'pushHistory' or explicit logic to save state.
// However, to keep backward compatibility with existing components that might not call pushHistory,
// we will still push history for "one-off" actions, but we should be careful with high-frequency updates.
// In the previous implementation, every update pushed history.
// We will optimize this by only pushing history for significant actions,
// or letting the UI trigger history pushes for drag operations.
return produce(state, (draft) => {
let shouldPushHistory = false;
switch (action.type) { switch (action.type) {
case 'init': { case 'init': {
const next = { draft.design = action.payload;
...state, draft.selectedWidgetId = null;
design: action.payload, if (!draft.design.events) draft.design.events = [];
selectedWidgetId: null, // Init resets history usually
}; draft.history = [JSON.parse(JSON.stringify(draft.design))];
if (!next.design.events) next.design.events = []; draft.historyIndex = 0;
console.log('[State] Init design widgets:', next.design.widgets.length); break;
return pushHistory(next);
} }
case 'addWidget': { case 'addWidget': {
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
@ -65,135 +117,127 @@ function reducer(state: AppState, action: Action): AppState {
height: 40, height: 40,
properties: { text: action.payload.type }, properties: { text: action.payload.type },
}; };
const design = clone(state.design); draft.design.widgets.push(w);
design.widgets.push(w); draft.selectedWidgetId = id;
const next = { ...state, design, selectedWidgetId: id }; shouldPushHistory = true;
console.log('[State] Added widget:', id, w.type); break;
return pushHistory(next);
} }
case 'selectWidget': { case 'selectWidget': {
console.log('[State] Select widget:', action.payload.id); draft.selectedWidgetId = action.payload.id;
return { ...state, selectedWidgetId: action.payload.id }; // Selection change doesn't need history
break;
} }
case 'updateWidget': { case 'updateWidget': {
const design = clone(state.design); const idx = draft.design.widgets.findIndex(
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id (w) => w.id === action.payload.id
); );
if (idx >= 0) { if (idx >= 0) {
design.widgets[idx] = { Object.assign(
...design.widgets[idx], draft.design.widgets[idx],
...action.payload.patch, action.payload.patch
}; );
} }
const next = { ...state, design }; // Don't push history here for every drag frame.
console.log('[State] Updated widget:', action.payload.id); // Components should dispatch 'pushHistory' on drag end.
return pushHistory(next); break;
} }
case 'updateProps': { case 'updateProps': {
const design = clone(state.design); const idx = draft.design.widgets.findIndex(
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id (w) => w.id === action.payload.id
); );
if (idx >= 0) { if (idx >= 0) {
design.widgets[idx] = { draft.design.widgets[idx].properties = {
...design.widgets[idx], ...draft.design.widgets[idx].properties,
properties: {
...design.widgets[idx].properties,
...action.payload.properties, ...action.payload.properties,
},
}; };
} }
const next = { ...state, design }; shouldPushHistory = true;
console.log('[State] Updated properties for:', action.payload.id); break;
return pushHistory(next);
} }
case 'deleteWidget': { case 'deleteWidget': {
const design = clone(state.design); draft.design.widgets = draft.design.widgets.filter(
design.widgets = design.widgets.filter(
(w) => w.id !== action.payload.id (w) => w.id !== action.payload.id
); );
if (!design.events) design.events = []; if (!draft.design.events) draft.design.events = [];
design.events = design.events.filter( draft.design.events = draft.design.events.filter(
(e) => e.widget !== action.payload.id (e) => e.widget !== action.payload.id
); );
const next = { ...state, design, selectedWidgetId: null }; draft.selectedWidgetId = null;
console.log('[State] Deleted widget:', action.payload.id); shouldPushHistory = true;
return pushHistory(next); break;
} }
case 'addEvent': { case 'addEvent': {
const design = clone(state.design); if (!draft.design.events) draft.design.events = [];
if (!design.events) design.events = []; const existsIndex = draft.design.events.findIndex(
const exists = design.events.find(
(e) => (e) =>
e.widget === action.payload.widget && e.widget === action.payload.widget &&
e.type === action.payload.type e.type === action.payload.type &&
e.name === action.payload.name
); );
if (!exists) { if (existsIndex >= 0) {
design.events.push(action.payload); draft.design.events[existsIndex].code = action.payload.code;
} else {
draft.design.events.push(action.payload);
} }
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget); shouldPushHistory = true;
return pushHistory({ ...state, design }); break;
} }
case 'removeEvent': { case 'removeEvent': {
const design = clone(state.design); if (!draft.design.events) draft.design.events = [];
if (!design.events) design.events = []; draft.design.events = draft.design.events.filter((e) => {
design.events = design.events.filter( const matchWidget = e.widget === action.payload.widget;
(e) => const matchType = e.type === action.payload.type;
!( const matchName = action.payload.name
e.widget === action.payload.widget && ? e.name === action.payload.name
e.type === action.payload.type : true;
) return !(matchWidget && matchType && matchName);
); });
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget); shouldPushHistory = true;
return pushHistory({ ...state, design }); break;
} }
case 'clear': { case 'clear': {
const design: DesignData = { draft.design.widgets = [];
form: state.design.form, draft.design.events = [];
widgets: [], draft.selectedWidgetId = null;
events: [], shouldPushHistory = true;
}; break;
console.log('[State] Cleared design');
return pushHistory({ ...state, design, selectedWidgetId: null });
}
case 'undo': {
if (state.historyIndex <= 0) return state;
const idx = state.historyIndex - 1;
console.log('[State] Undo to index:', idx);
return {
...state,
historyIndex: idx,
design: clone(state.history[idx]),
};
}
case 'redo': {
if (state.historyIndex >= state.history.length - 1) return state;
const idx = state.historyIndex + 1;
console.log('[State] Redo to index:', idx);
return {
...state,
historyIndex: idx,
design: clone(state.history[idx]),
};
} }
case 'setForm': { case 'setForm': {
const design = clone(state.design); if (action.payload.name)
if (action.payload.title) design.form.title = action.payload.title; draft.design.form.name = action.payload.name;
if (action.payload.size) design.form.size = action.payload.size; if (action.payload.title)
console.log('[State] Set form', action.payload); draft.design.form.title = action.payload.title;
return pushHistory({ ...state, design }); if (action.payload.size)
} draft.design.form.size = action.payload.size;
default: if (action.payload.className)
return state; draft.design.form.className = action.payload.className;
shouldPushHistory = true;
break;
} }
} }
function pushHistory(next: AppState): AppState { if (shouldPushHistory) {
const hist = next.history.slice(0, next.historyIndex + 1); pushHistoryDraft(draft);
hist.push(clone(next.design)); }
console.log('[State] History length:', hist.length); });
return { ...next, history: hist, historyIndex: hist.length - 1 }; }
function pushHistoryDraft(draft: AppState) {
// Limit history stack size to prevent memory issues
const MAX_HISTORY = 50;
// Remove future history if we were in the middle of the stack
if (draft.historyIndex < draft.history.length - 1) {
draft.history = draft.history.slice(0, draft.historyIndex + 1);
}
draft.history.push(JSON.parse(JSON.stringify(draft.design)));
if (draft.history.length > MAX_HISTORY) {
draft.history.shift(); // Remove oldest
}
draft.historyIndex = draft.history.length - 1;
} }
export function useAppState() { export function useAppState() {
@ -217,7 +261,11 @@ export function AppProvider({
}) { }) {
const initialDesign: DesignData = useMemo( const initialDesign: DesignData = useMemo(
() => ({ () => ({
form: { title: 'My App', size: { width: 800, height: 600 } }, form: {
name: 'Form1',
title: 'My App',
size: { width: 800, height: 600 },
},
widgets: [], widgets: [],
events: [], events: [],
}), }),

View File

@ -1,6 +1,7 @@
export type WidgetType = export type WidgetType =
| 'Label' | 'Label'
| 'Button' | 'Button'
| 'Entry'
| 'Text' | 'Text'
| 'Checkbutton' | 'Checkbutton'
| 'Radiobutton'; | 'Radiobutton';
@ -24,9 +25,19 @@ export interface EventBinding {
export interface DesignData { export interface DesignData {
form: { form: {
name: string;
title: string; title: string;
size: { width: number; height: number }; size: { width: number; height: number };
className?: string;
}; };
widgets: Widget[]; widgets: Widget[];
events: EventBinding[]; events: EventBinding[];
} }
export type WebviewMessage =
| { type: 'designUpdated'; data: DesignData }
| { type: 'getDesignData' }
| { type: 'webviewReady' }
| { type: 'generateCode'; data: DesignData }
| { type: 'applyChanges'; data: DesignData };

View File

@ -15,6 +15,12 @@ export function useMessaging() {
initializedRef.current = true; initializedRef.current = true;
dispatch({ type: 'init', payload: message.data }); dispatch({ type: 'init', payload: message.data });
break; break;
case 'updateFormName':
dispatch({
type: 'setForm',
payload: { name: message.name },
});
break;
default: default:
break; break;
} }
@ -25,7 +31,12 @@ export function useMessaging() {
useEffect(() => { useEffect(() => {
if (!initializedRef.current) return; if (!initializedRef.current) return;
const timeoutId = setTimeout(() => {
vscode.postMessage({ type: 'designUpdated', data: design }); vscode.postMessage({ type: 'designUpdated', data: design });
}, 200);
return () => clearTimeout(timeoutId);
}, [design, vscode]); }, [design, vscode]);
useEffect(() => { useEffect(() => {

View File

@ -77,12 +77,13 @@ body {
.btn-outline { .btn-outline {
background-color: transparent; background-color: transparent;
color: var(--vscode-button-foreground); color: white;
border: 1px solid var(--vscode-button-border); border: 1px solid var(--vscode-button-border);
} }
.btn-outline:hover { .btn-outline:hover {
background-color: var(--vscode-button-hoverBackground); background-color: black;
border-color: black;
} }
.btn-outline:disabled { .btn-outline:disabled {
@ -359,6 +360,24 @@ body {
.event-item .event-info { .event-item .event-info {
flex: 1; flex: 1;
display: flex;
align-items: center;
}
.event-label-bold {
font-weight: 600;
}
.event-value-margin {
margin-left: 5px;
}
.mb-4 {
margin-bottom: 4px;
}
.mb-8 {
margin-bottom: 8px;
} }
.event-item .event-name { .event-item .event-name {
@ -434,22 +453,16 @@ body {
.canvas-container { .canvas-container {
flex: 1; flex: 1;
padding: 16px; padding: 40px;
overflow: auto; overflow: auto;
} background-color: #e0e0e0;
display: flex;
.design-canvas { align-items: flex-start;
min-height: 600px;
min-width: 800px;
background-color: #f0f0f0;
border: 2px dashed var(--vscode-panel-border);
border-radius: 4px;
position: relative;
background-image: background-image:
linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, #d0d0d0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #d0d0d0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(45deg, transparent 75%, #d0d0d0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%); linear-gradient(-45deg, transparent 75%, #d0d0d0 75%);
background-size: 20px 20px; background-size: 20px 20px;
background-position: background-position:
0 0, 0 0,
@ -458,9 +471,141 @@ body {
-10px 0px; -10px 0px;
} }
.design-canvas.drag-over { .window-frame {
border-color: var(--vscode-focusBorder); display: flex;
background-color: var(--vscode-list-dropBackground); flex-direction: column;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 1px solid #999;
background-color: #f0f0f0;
position: relative;
}
.window-title-bar {
height: 30px;
background-color: #ffffff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 0 10px;
border-bottom: 1px solid #ccc;
user-select: none;
}
.window-title {
font-size: 12px;
color: #333;
font-family: 'Segoe UI', sans-serif;
}
.window-controls {
display: flex;
height: 100%;
}
.window-control {
width: 46px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: transparent;
border: none;
cursor: default;
transition: background-color 0.1s;
}
.window-control:hover {
background-color: #e5e5e5;
}
.window-control.close:hover {
background-color: #e81123;
color: white;
}
/* Minimize Icon */
.window-control.minimize::before {
content: '';
width: 10px;
height: 1px;
background-color: #000;
}
/* Maximize Icon */
.window-control.maximize::before {
content: '';
width: 10px;
height: 10px;
border: 1px solid #000;
box-sizing: border-box;
}
/* Close Icon (X) */
.window-control.close {
position: relative;
}
.window-control.close::before,
.window-control.close::after {
content: '';
position: absolute;
width: 10px;
height: 1px;
background-color: #000;
}
.window-control.close::before {
transform: rotate(45deg);
}
.window-control.close::after {
transform: rotate(-45deg);
}
.window-control.close:hover::before,
.window-control.close:hover::after {
background-color: white;
}
.design-canvas {
background-color: white;
position: relative;
overflow: hidden;
width: var(--canvas-width);
height: var(--canvas-height);
}
.window-resize-handle {
position: absolute;
z-index: 5;
}
.window-resize-handle.e {
top: 0;
right: -10px;
width: 20px;
height: 100%;
cursor: ew-resize;
z-index: 5;
}
.window-resize-handle.s {
bottom: -10px;
left: 0;
width: 100%;
height: 20px;
cursor: ns-resize;
z-index: 5;
}
.window-resize-handle.se {
bottom: -10px;
right: -10px;
width: 20px;
height: 20px;
cursor: nwse-resize;
z-index: 10;
} }
.canvas-placeholder { .canvas-placeholder {
@ -486,6 +631,10 @@ body {
font-size: 12px; font-size: 12px;
background-color: white; background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
left: var(--widget-x);
top: var(--widget-y);
width: var(--widget-width);
height: var(--widget-height);
} }
.canvas-widget:hover { .canvas-widget:hover {
@ -497,6 +646,71 @@ body {
box-shadow: 0 0 0 1px var(--vscode-button-background); box-shadow: 0 0 0 1px var(--vscode-button-background);
} }
.resize-handle {
position: absolute;
width: 8px;
height: 8px;
background-color: white;
border: 1px solid var(--vscode-button-background);
z-index: 10;
}
.resize-handle:hover {
background-color: var(--vscode-button-background);
}
.resize-handle.n {
top: -5px;
left: 50%;
transform: translateX(-50%);
cursor: ns-resize;
}
.resize-handle.s {
bottom: -5px;
left: 50%;
transform: translateX(-50%);
cursor: ns-resize;
}
.resize-handle.e {
top: 50%;
right: -5px;
transform: translateY(-50%);
cursor: ew-resize;
}
.resize-handle.w {
top: 50%;
left: -5px;
transform: translateY(-50%);
cursor: ew-resize;
}
.resize-handle.ne {
top: -5px;
right: -5px;
cursor: nesw-resize;
}
.resize-handle.nw {
top: -5px;
left: -5px;
cursor: nwse-resize;
}
.resize-handle.se {
bottom: -5px;
right: -5px;
cursor: nwse-resize;
}
.resize-handle.sw {
bottom: -5px;
left: -5px;
cursor: nesw-resize;
}
.canvas-widget.widget-label { .canvas-widget.widget-label {
background-color: #f8f9fa; background-color: #f8f9fa;
color: #333; color: #333;
@ -516,38 +730,27 @@ body {
border: 1px solid #ced4da; border: 1px solid #ced4da;
padding: 8px; padding: 8px;
text-align: left; text-align: left;
resize: both; overflow: hidden;
overflow: auto;
} }
.canvas-widget.widget-checkbutton { .canvas-widget.widget-checkbutton {
background-color: white; background-color: white;
color: #333;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px; padding: 4px;
} }
.canvas-widget.widget-checkbutton::before {
content: '☑️';
font-size: 14px;
}
.canvas-widget.widget-radiobutton { .canvas-widget.widget-radiobutton {
background-color: white; background-color: white;
color: #333;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px; padding: 4px;
} }
.canvas-widget.widget-radiobutton::before {
content: '🔘';
font-size: 14px;
}
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
@ -591,3 +794,73 @@ body {
height: 200px; height: 200px;
} }
} }
/* Widget specific styles for Canvas content */
.widget-label-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
white-space: nowrap;
}
.widget-button-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
.widget-entry-content {
width: 100%;
height: 100%;
}
.widget-text-content {
width: 100%;
height: 100%;
resize: none;
}
.widget-check-content,
.widget-radio-content {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 100%;
cursor: default;
white-space: nowrap;
overflow: hidden;
color: inherit;
pointer-events: none;
}
.widget-check-input {
margin: 0;
}
.widget-check-text {
color: #333;
}
/* Toolbar specific styles */
.toolbar-group {
display: flex;
align-items: center;
margin-right: 10px;
}
.toolbar-label {
margin-right: 5px;
}
.toolbar-input {
padding: 4px;
border-radius: 4px;
border: 1px solid #ccc;
}