Initial empty commit

This commit is contained in:
IDK 2025-11-27 13:32:01 +03:00
commit c87e51053c
48 changed files with 5310 additions and 0 deletions

14
.eslintrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": 2020, "sourceType": "module" },
"plugins": ["@typescript-eslint", "prettier"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"prettier/prettier": ["error", { "useTabs": false, "tabWidth": 4 }]
}
}

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.vscode/
out/
docs/
node_modules/
README.md
examples/

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
out

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"useTabs": false,
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
}

1747
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "tkinter-designer",
"displayName": "Tkinter Visual Designer",
"description": "Visual drag-and-drop designer for Python tkinter GUI applications",
"version": "0.1.0",
"engines": {
"vscode": "^1.74.0"
},
"categories": [
"Other"
],
"activationEvents": [
"onCommand:tkinter-designer.openDesigner"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "tkinter-designer.openDesigner",
"title": "Open Tkinter Designer",
"category": "Tkinter"
},
{
"command": "tkinter-designer.generateCode",
"title": "Generate Python Code",
"category": "Tkinter"
},
{
"command": "tkinter-designer.parseCode",
"title": "Parse Tkinter Code",
"category": "Tkinter"
}
],
"menus": {
"explorer/context": [
{
"command": "tkinter-designer.openDesigner",
"when": "resourceExtname == .py",
"group": "navigation"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./ && npm run copy-python-files && npm run build:webview",
"copy-python-files": "node scripts/copy_python_files.js",
"build:webview": "node scripts/build_webview.js",
"watch": "tsc -watch -p ./",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "16.x",
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"@types/vscode": "^1.74.0",
"esbuild": "^0.21.5",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"typescript": "^4.9.5"
}
}

26
scripts/build_webview.js Normal file
View File

@ -0,0 +1,26 @@
/* eslint-disable */
const esbuild = require('esbuild');
const path = require('path');
async function build() {
const entry = path.resolve(__dirname, '../src/webview/react/index.tsx');
const outFile = path.resolve(__dirname, '../out/webview/react-webview.js');
try {
await esbuild.build({
entryPoints: [entry],
outfile: outFile,
bundle: true,
platform: 'browser',
format: 'iife',
sourcemap: true,
minify: false,
loader: { '.ts': 'ts', '.tsx': 'tsx' },
});
console.log('Built React webview to', outFile);
} catch (err) {
console.error('Failed to build React webview:', err);
process.exit(1);
}
}
build();

View File

@ -0,0 +1,42 @@
const fs = require('fs');
const path = require('path');
function copyFile(src, dst) {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
}
function copyDir(src, dst) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dst, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const dstPath = path.join(dst, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, dstPath);
} else {
fs.copyFileSync(srcPath, dstPath);
}
}
}
function main() {
const projectRoot = process.cwd();
const srcParserPath = path.join(projectRoot, 'src', 'parser');
const outParserPath = path.join(projectRoot, 'out', 'parser');
copyFile(
path.join(srcParserPath, 'tkinter_ast_parser.py'),
path.join(outParserPath, 'tkinter_ast_parser.py')
);
copyDir(
path.join(srcParserPath, 'tk_ast'),
path.join(outParserPath, 'tk_ast')
);
console.log('Copied Python files to out/parser.');
}
main();

142
src/extension.ts Normal file
View File

@ -0,0 +1,142 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { CodeGenerator } from './generator/CodeGenerator';
import { CodeParser } from './parser/CodeParser';
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
export function activate(context: vscode.ExtensionContext) {
const provider = new TkinterDesignerProvider(context.extensionUri);
TkinterDesignerProvider._instance = provider;
const openDesignerCommand = vscode.commands.registerCommand(
'tkinter-designer.openDesigner',
() => {
TkinterDesignerProvider.createOrShow(context.extensionUri);
}
);
const generateCodeCommand = vscode.commands.registerCommand(
'tkinter-designer.generateCode',
async () => {
console.log('[GenerateCode] Command invoked');
const generator = new CodeGenerator();
const designData = await provider.getDesignData();
if (
!designData ||
!designData.widgets ||
designData.widgets.length === 0
) {
console.log('[GenerateCode] No design data');
vscode.window.showWarningMessage(
'No design data found. Please open the designer and create some widgets first.'
);
return;
}
const pythonCode = generator.generateTkinterCode(designData);
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') {
console.log('[GenerateCode] Writing into active Python file');
const doc = activeEditor.document;
const start = new vscode.Position(0, 0);
const end = doc.lineCount
? doc.lineAt(doc.lineCount - 1).range.end
: start;
const fullRange = new vscode.Range(start, end);
await activeEditor.edit((editBuilder) => {
editBuilder.replace(fullRange, pythonCode);
});
await doc.save();
vscode.window.showInformationMessage(
'Python code generated into the active file'
);
} else {
console.log('[GenerateCode] Creating new Python file');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
return;
}
const fileName = `app_${Date.now()}.py`;
const filePath = path.join(
workspaceFolder.uri.fsPath,
fileName
);
const fileUri = vscode.Uri.file(filePath);
const encoder = new TextEncoder();
const fileBytes = encoder.encode(pythonCode);
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc, { preview: false });
vscode.window.showInformationMessage(
`Python file created: ${fileName}`
);
}
console.log('[GenerateCode] Done');
}
);
const parseCodeCommand = vscode.commands.registerCommand(
'tkinter-designer.parseCode',
async () => {
console.log('[ParseCode] Command invoked');
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') {
const parser = new CodeParser();
const code = activeEditor.document.getText();
console.log('[ParseCode] Code length:', code.length);
try {
const designData = await parser.parseCodeToDesign(code);
if (
designData &&
designData.widgets &&
designData.widgets.length > 0
) {
console.log('[ParseCode] Widgets found:', designData.widgets.length);
const designerInstance =
TkinterDesignerProvider.createOrShow(
context.extensionUri
);
if (designerInstance) {
designerInstance.loadDesignData(designData);
} else {
}
vscode.window.showInformationMessage(
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
);
} else {
console.log('[ParseCode] No widgets found');
vscode.window.showWarningMessage(
'No tkinter widgets found in the code. Make sure your code contains tkinter widget creation statements like tk.Label(), tk.Button(), etc.'
);
}
} catch (error) {
console.error('[ParseCode] Error:', error);
vscode.window.showErrorMessage(
`Error parsing code: ${error}`
);
}
} else {
console.log('[ParseCode] No active Python editor');
vscode.window.showErrorMessage(
'Please open a Python file with tkinter code'
);
}
}
);
context.subscriptions.push(
openDesignerCommand,
generateCodeCommand,
parseCodeCommand
);
}
export function deactivate() {}

View File

@ -0,0 +1,116 @@
import { DesignData, WidgetData } from './types';
import {
getVariableName,
generateVariableNames,
getWidgetTypeForGeneration,
indentText,
} from './utils';
import {
getWidgetParameters,
getPlaceParameters,
generateWidgetContent,
} from './widgetHelpers';
import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers';
export class CodeGenerator {
private indentLevel = 0;
private designData: DesignData | null = null;
public generateTkinterCode(designData: DesignData): string {
this.designData = designData;
console.log('[Generator] Start, widgets:', designData.widgets.length, 'events:', designData.events?.length || 0);
const lines: string[] = [];
const nameMap = generateVariableNames(designData.widgets);
lines.push('import tkinter as tk');
lines.push('');
lines.push('class Application:');
this.indentLevel = 1;
lines.push(this.indent('def __init__(self):'));
this.indentLevel = 2;
lines.push(this.indent('self.root = tk.Tk()'));
lines.push(this.indent(`self.root.title("${designData.form.title}")`));
lines.push(
this.indent(
`self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")`
)
);
lines.push(this.indent('self.create_widgets()'));
lines.push('');
this.indentLevel = 1;
lines.push(this.indent('def create_widgets(self):'));
this.indentLevel = 2;
designData.widgets.forEach((widget) => {
console.log('[Generator] Widget:', widget.id, widget.type);
lines.push(...this.generateWidgetCode(widget, nameMap));
lines.push('');
});
this.indentLevel = 1;
lines.push(this.indent('def run(self):'));
this.indentLevel = 2;
lines.push(this.indent('self.root.mainloop()'));
lines.push('');
const hasEvents = designData.events && designData.events.length > 0;
if (hasEvents) {
console.log('[Generator] Generating event handlers');
lines.push(
...generateEventHandlers(
designData,
(text) => indentText(1, text),
(text) => indentText(2, text)
)
);
}
this.indentLevel = 0;
lines.push('if __name__ == "__main__":');
this.indentLevel = 1;
lines.push(this.indent('app = Application()'));
lines.push(this.indent('app.run()'));
return lines.join('\n');
}
private generateWidgetCode(
widget: WidgetData,
nameMap: Map<string, string>
): string[] {
const lines: string[] = [];
const varName = getVariableName(widget, nameMap);
const widgetType = getWidgetTypeForGeneration(widget.type);
lines.push(
this.indent(
`self.${varName} = ${widgetType}(self.root${getWidgetParameters(widget)})`
)
);
const placeParams = getPlaceParameters(widget);
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
const contentLines = generateWidgetContent(widget, varName);
contentLines.forEach((line) => lines.push(this.indent(line)));
lines.push(
...getWidgetEventBindings(
this.designData,
widget,
varName,
(text) => this.indent(text)
)
);
return lines;
}
private indent(text: string): string {
return indentText(this.indentLevel, text);
}
}

View File

@ -0,0 +1,74 @@
import { DesignData, Event, WidgetData } from './types';
export function generateEventHandlers(
designData: DesignData,
indentDef: (text: string) => string,
indentBody: (text: string) => string
): string[] {
const lines: string[] = [];
if (designData.events && designData.events.length > 0) {
designData.events.forEach((event: Event) => {
const handlerName = event.name;
const isBindEvent =
event.type.startsWith('<') && event.type.endsWith('>');
let hasCode = false;
const widget = (designData.widgets || []).find(
(w) => w.id === event.widget
);
if (isBindEvent) {
lines.push(indentDef(`def ${handlerName}(self, event):`));
} else {
lines.push(indentDef(`def ${handlerName}(self):`));
}
const codeLines = (event.code || '').split('\n');
for (const line of codeLines) {
if (line.trim()) {
lines.push(indentBody(line));
hasCode = true;
}
}
if (!hasCode) {
lines.push(indentBody('pass'));
}
});
}
return lines;
}
export function getWidgetEventBindings(
designData: DesignData | null,
widget: WidgetData,
varName: string,
indentLine: (text: string) => string
): string[] {
const lines: string[] = [];
if (designData && designData.events) {
const widgetEvents = designData.events.filter(
(event) => event.widget === widget.id
);
widgetEvents.forEach((event) => {
if (event.type === 'command') {
lines.push(
indentLine(
`self.${varName}.config(command=self.${event.name})`
)
);
} else {
lines.push(
indentLine(
`self.${varName}.bind("${event.type}", self.${event.name})`
)
);
}
});
}
return lines;
}

28
src/generator/types.ts Normal file
View File

@ -0,0 +1,28 @@
export interface Event {
widget: string;
type: string;
name: string;
code: string;
}
export interface WidgetData {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
properties: { [key: string]: any };
}
export interface DesignData {
form: {
title: string;
size: {
width: number;
height: number;
};
};
widgets: WidgetData[];
events?: Event[];
}

49
src/generator/utils.ts Normal file
View File

@ -0,0 +1,49 @@
import { WidgetData } from './types';
export function escapeString(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n');
}
export function getVariableName(
widget: WidgetData,
nameMap?: Map<string, string>
): string {
if (nameMap && nameMap.has(widget.id)) {
return nameMap.get(widget.id)!;
}
return widget.id.toLowerCase().replace(/[^a-z0-9_]/g, '_');
}
export function generateVariableNames(
widgets: WidgetData[]
): Map<string, string> {
const counts = new Map<string, number>();
const names = new Map<string, string>();
widgets.forEach((widget) => {
let baseName = widget.type.toLowerCase();
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
if (baseName === 'button') baseName = 'btn';
if (baseName === 'checkbutton') baseName = 'chk';
if (baseName === 'radiobutton') baseName = 'radio';
if (baseName === 'label') baseName = 'lbl';
const count = (counts.get(baseName) || 0) + 1;
counts.set(baseName, count);
names.set(widget.id, `${baseName}${count}`);
});
return names;
}
export function getWidgetTypeForGeneration(widgetType: string): string {
return `tk.${widgetType}`;
}
export function indentText(level: number, text: string): string {
return ' '.repeat(level) + text;
}

View File

@ -0,0 +1,73 @@
import { WidgetData } from './types';
import { escapeString } from './utils';
export function getWidgetParameters(widget: WidgetData): string {
const params: string[] = [];
const props = widget.properties;
switch (widget.type) {
case 'Label':
case 'Button':
case 'Checkbutton':
case 'Radiobutton':
if (props.text) {
params.push(`text="${escapeString(props.text)}"`);
}
break;
case 'Text':
if (props.text) {
}
break;
}
if (widget.width) {
if (widget.type === 'Text') {
const charWidth = Math.floor(widget.width / 8);
params.push(`width=${charWidth}`);
} else {
params.push(`width=${widget.width}`);
}
}
if (widget.height && widget.type === 'Text') {
if (widget.type === 'Text') {
const lineHeight = Math.floor(widget.height / 20);
params.push(`height=${lineHeight}`);
}
}
return params.length > 0 ? ', ' + params.join(', ') : '';
}
export function getPlaceParameters(widget: WidgetData): string {
const params = [`x=${widget.x}`, `y=${widget.y}`];
if (widget.width) {
params.push(`width=${widget.width}`);
}
if (widget.height) {
params.push(`height=${widget.height}`);
}
return params.join(', ');
}
export function generateWidgetContent(
widget: WidgetData,
varName: string
): string[] {
const lines: string[] = [];
const props = widget.properties;
switch (widget.type) {
case 'Text':
if (props.text) {
lines.push(
`self.${varName}.insert(tk.END, "${escapeString(props.text)}")`
);
}
break;
}
return lines;
}

137
src/parser/CodeParser.ts Normal file
View File

@ -0,0 +1,137 @@
import { DesignData, WidgetData, Event } from '../generator/types';
import { runPythonAst } from './pythonRunner';
import { convertASTResultToDesignData } from './astConverter';
export class CodeParser {
public async parseCodeToDesign(
pythonCode: string
): Promise<DesignData | null> {
console.log(
'[Parser] parseCodeToDesign start, code length:',
pythonCode.length
);
const astRaw = await runPythonAst(pythonCode);
const astDesign = convertASTResultToDesignData(astRaw);
if (astDesign && astDesign.widgets && astDesign.widgets.length > 0) {
console.log(
'[Parser] AST parsed widgets:',
astDesign.widgets.length
);
return astDesign;
}
console.log('[Parser] AST returned no widgets, using regex fallback');
const regexDesign = this.parseWithRegexInline(pythonCode);
console.log(
'[Parser] Regex parsed widgets:',
regexDesign?.widgets?.length || 0
);
return regexDesign;
}
private parseWithRegexInline(code: string): DesignData | null {
const widgetRegex =
/(self\.)?(\w+)\s*=\s*tk\.(Label|Button|Text|Checkbutton|Radiobutton)\s*\(([^)]*)\)/g;
const placeRegex = /(self\.)?(\w+)\.place\s*\(([^)]*)\)/g;
const titleRegex = /\.title\s*\(\s*(["'])(.*?)\1\s*\)/;
const geometryRegex = /\.geometry\s*\(\s*(["'])(\d+)x(\d+)\1\s*\)/;
const widgets: WidgetData[] = [];
const widgetMap = new Map<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 {
form: {
title: formTitle,
size: { width: formWidth, height: formHeight },
},
widgets,
events,
};
}
}

127
src/parser/astConverter.ts Normal file
View File

@ -0,0 +1,127 @@
import { DesignData, WidgetData, Event } from '../generator/types';
import { getDefaultWidth, getDefaultHeight } from './utils';
export function convertASTResultToDesignData(
astResult: any
): DesignData | null {
if (!astResult || !astResult.widgets || astResult.widgets.length === 0) {
return null;
}
const widgets: WidgetData[] = [];
let formTitle =
(astResult.window && astResult.window.title) ||
astResult.title ||
'Parsed App';
let formWidth =
(astResult.window && astResult.window.width) || astResult.width || 800;
let formHeight =
(astResult.window && astResult.window.height) ||
astResult.height ||
600;
let counter = 0;
const allowedTypes = new Set([
'Label',
'Button',
'Text',
'Checkbutton',
'Radiobutton',
]);
for (const w of astResult.widgets) {
counter++;
const type = w.type || 'Widget';
if (!allowedTypes.has(type)) {
continue;
}
const place = w.placement || {};
const x = place.x !== undefined ? place.x : w.x !== undefined ? w.x : 0;
const y = place.y !== undefined ? place.y : w.y !== undefined ? w.y : 0;
const p = (w.properties || w.params || {}) as any;
const width =
place.width !== undefined
? place.width
: w.width !== undefined
? w.width
: p.width !== undefined
? p.width
: getDefaultWidth(type);
const height =
place.height !== undefined
? place.height
: w.height !== undefined
? w.height
: p.height !== undefined
? p.height
: getDefaultHeight(type);
const id = w.variable_name || `ast_widget_${counter}`;
const widget: WidgetData = {
id,
type,
x,
y,
width,
height,
properties: extractWidgetPropertiesFromAST(w),
};
widgets.push(widget);
}
const events: Event[] = [];
if (astResult.command_callbacks) {
for (const callback of astResult.command_callbacks) {
const rawName = callback.command?.name;
const cleanName = rawName
? String(rawName).replace(/^self\./, '')
: `on_${callback.widget}_command`;
events.push({
widget: callback.widget,
type: 'command',
name: cleanName,
code: callback.command?.lambda_body || '',
});
}
}
if (astResult.bind_events) {
for (const bindEvent of astResult.bind_events) {
const rawName = bindEvent.callback?.name;
const cleanName = rawName
? String(rawName).replace(/^self\./, '')
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
events.push({
widget: bindEvent.widget,
type: bindEvent.event,
name: cleanName,
code: bindEvent.callback?.lambda_body || '',
});
}
}
const keptIds = new Set(widgets.map((w) => w.id));
const filteredEvents = events.filter((e) => keptIds.has(e.widget));
const result: DesignData = {
form: {
title: formTitle,
size: { width: formWidth, height: formHeight },
},
widgets,
events: filteredEvents.length ? filteredEvents : [],
};
return result;
}
function extractWidgetPropertiesFromAST(w: any): any {
const props: any = {};
if (!w) return props;
const p = w.properties || w.params || {};
if (p.text) props.text = p.text;
if (p.command) props.command = p.command;
if (p.variable) props.variable = p.variable;
if (p.orient) props.orient = p.orient;
if (p.from_ !== undefined) props.from_ = p.from_;
if (p.to !== undefined) props.to = p.to;
if (p.width !== undefined) props.width = p.width;
if (p.height !== undefined) props.height = p.height;
return props;
}

View File

@ -0,0 +1,80 @@
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { spawn } from 'child_process';
async function executePythonScript(
pythonScriptPath: string,
pythonFilePath: string
): Promise<string> {
return await new Promise((resolve, reject) => {
const pythonCommand = getPythonCommand();
const start = Date.now();
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath);
const process = spawn(pythonCommand, [
pythonScriptPath,
pythonFilePath,
]);
let result = '';
let errorOutput = '';
process.stdout.on('data', (data) => {
result += data.toString();
});
process.stderr.on('data', (data) => {
errorOutput += data.toString();
});
process.on('close', (code) => {
const ms = Date.now() - start;
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms);
if (code === 0) {
resolve(result);
} else {
reject(
new Error(
`Python script failed with code ${code}: ${errorOutput}`
)
);
}
});
});
}
function getPythonCommand(): string {
return process.platform === 'win32' ? 'python' : 'python3';
}
function createTempPythonFile(pythonCode: string): string {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, `tk_ast_${Date.now()}.py`);
fs.writeFileSync(tempFilePath, pythonCode, 'utf8');
return tempFilePath;
}
function cleanupTempFile(tempFile: string): void {
try {
fs.unlinkSync(tempFile);
} catch {}
}
export async function runPythonAst(pythonCode: string): Promise<any | null> {
const tempFilePath = createTempPythonFile(pythonCode);
try {
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
const output = await executePythonScript(
pythonScriptPath,
tempFilePath
);
console.log('[PythonRunner] Received AST JSON length:', output.length);
const parsed = JSON.parse(output);
return parsed;
} catch (err) {
console.error('[PythonRunner] Error running Python AST:', err);
return null;
} finally {
cleanupTempFile(tempFilePath);
console.log('[PythonRunner] Temp file cleaned:', tempFilePath);
}
}

View File

@ -0,0 +1,3 @@
from .analyzer import TkinterAnalyzer
from .grid_layout import GridLayoutAnalyzer
from .parser import parse_tkinter_code, parse_file

View File

@ -0,0 +1 @@
from .base import TkinterAnalyzer

View File

@ -0,0 +1,109 @@
import ast
from typing import Dict, List, Any, Optional
from .imports import handle_import, handle_import_from
from .context import enter_class, exit_class, enter_function, exit_function
from .calls import handle_method_call
from .widget_creation import is_widget_creation, extract_widget_info, analyze_widget_creation_commands
from .extractors import extract_call_parameters, extract_parent_container
from .values import extract_value, get_variable_name, analyze_lambda_complexity, extract_lambda_body, get_operator_symbol
from .placements import update_widget_placement
from .events import analyze_bind_event, analyze_config_command, analyze_callback, is_interactive_widget
from .connections import create_widget_handler_connections
class TkinterAnalyzer(ast.NodeVisitor):
def __init__(self):
self.widgets: List[Dict[str, Any]] = []
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
self.imports: Dict[str, str] = {}
self.variables: Dict[str, Any] = {}
self.current_class: Optional[str] = None
self.current_method: Optional[str] = None
self.event_handlers: List[Dict[str, Any]] = []
self.command_callbacks: List[Dict[str, Any]] = []
self.bind_events: List[Dict[str, Any]] = []
def visit_Import(self, node: ast.Import):
handle_import(self, node)
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom):
handle_import_from(self, node)
self.generic_visit(node)
def visit_ClassDef(self, node: ast.ClassDef):
prev = self.current_class
enter_class(self, node)
self.generic_visit(node)
exit_class(self, prev)
def visit_FunctionDef(self, node: ast.FunctionDef):
prev = self.current_method
enter_function(self, node)
self.generic_visit(node)
exit_function(self, prev)
def visit_Assign(self, node: ast.Assign):
if is_widget_creation(self, node):
info = extract_widget_info(self, node)
if info:
self.widgets.append(info)
analyze_widget_creation_commands(self, node)
for target in node.targets:
if isinstance(target, ast.Name):
self.variables[target.id] = node.value
elif isinstance(target, ast.Attribute):
if isinstance(target.value, ast.Name) and target.value.id == 'self':
self.variables[target.attr] = node.value
self.generic_visit(node)
def visit_Call(self, node: ast.Call):
if isinstance(node.func, ast.Attribute):
handle_method_call(self, node)
self.generic_visit(node)
def extract_call_parameters(self, call_node: ast.Call) -> Dict[str, Any]:
return extract_call_parameters(self, call_node)
def extract_value(self, node: ast.AST) -> Any:
return extract_value(node)
def extract_parent_container(self, call_node: ast.Call) -> str:
return extract_parent_container(self, call_node)
def get_variable_name(self, node: ast.AST) -> str:
return get_variable_name(node)
def update_widget_placement(self, target_var: str, call_node: ast.Call, method: str):
return update_widget_placement(self, target_var, call_node, method)
def analyze_bind_event(self, target_var: str, call_node: ast.Call):
return analyze_bind_event(self, target_var, call_node)
def analyze_config_command(self, target_var: str, call_node: ast.Call):
return analyze_config_command(self, target_var, call_node)
def analyze_callback(self, callback_node: ast.AST) -> Dict[str, Any]:
return analyze_callback(self, callback_node)
def analyze_lambda_complexity(self, lambda_node: ast.Lambda) -> str:
return analyze_lambda_complexity(lambda_node)
def extract_lambda_body(self, body_node: ast.AST) -> str:
return extract_lambda_body(body_node)
def get_operator_symbol(self, op_node: ast.AST) -> str:
return get_operator_symbol(op_node)
def analyze_widget_creation_commands(self, node: ast.Assign):
return analyze_widget_creation_commands(self, node)
def create_widget_handler_connections(self, widgets: List[Dict[str, Any]]) -> Dict[str, Any]:
return create_widget_handler_connections(self, widgets)
def is_interactive_widget(self, widget_type: str) -> bool:
return is_interactive_widget(widget_type)

View File

@ -0,0 +1,44 @@
import ast
from .values import get_variable_name
def handle_method_call(analyzer, node: ast.Call):
if not isinstance(node.func, ast.Attribute):
return
method_name = node.func.attr
target_var = get_variable_name(node.func.value)
if target_var.startswith('self.'):
target_var = target_var[5:]
if method_name == 'title' and node.args:
arg0 = node.args[0]
if isinstance(arg0, ast.Constant):
analyzer.window_config['title'] = arg0.value
elif isinstance(arg0, ast.Str):
analyzer.window_config['title'] = arg0.s
elif method_name == 'geometry' and node.args:
arg0 = node.args[0]
if isinstance(arg0, ast.Constant):
geometry = arg0.value
elif isinstance(arg0, ast.Str):
geometry = arg0.s
else:
return
if 'x' in str(geometry):
try:
width, height = str(geometry).split('x')
analyzer.window_config['width'] = int(width)
analyzer.window_config['height'] = int(height)
except ValueError:
pass
elif method_name == 'place':
analyzer.update_widget_placement(target_var, node, 'place')
elif method_name == 'grid':
analyzer.update_widget_placement(target_var, node, 'grid')
elif method_name == 'bind':
analyzer.analyze_bind_event(target_var, node)
elif method_name == 'config':
analyzer.analyze_config_command(target_var, node)

View File

@ -0,0 +1,93 @@
from typing import Dict, Any, List
from .events import is_interactive_widget
def create_widget_handler_connections(analyzer, widgets: List[Dict[str, Any]]) -> Dict[str, Any]:
connections: Dict[str, Any] = {
'widgets_with_commands': [],
'widgets_with_bind_events': [],
'handler_methods': [],
'lambda_handlers': [],
'unused_handlers': [],
'connection_summary': {},
}
widget_dict = {w['variable_name']: w for w in widgets}
for callback in analyzer.command_callbacks:
widget_name = callback['widget']
if widget_name in widget_dict:
connections['widgets_with_commands'].append({
'widget': widget_name,
'widget_type': widget_dict[widget_name]['type'],
'handler': callback['command'],
'context': {
'class': callback['class_context'],
'method': callback['method_context'],
},
})
for bind_event in analyzer.bind_events:
widget_name = bind_event['widget']
if widget_name in widget_dict:
connections['widgets_with_bind_events'].append({
'widget': widget_name,
'widget_type': widget_dict[widget_name]['type'],
'event': bind_event['event'],
'handler': bind_event['callback'],
'context': {
'class': bind_event['class_context'],
'method': bind_event['method_context'],
},
})
all_handlers = set()
for callback in analyzer.command_callbacks:
if callback['command']['name']:
all_handlers.add(callback['command']['name'])
for bind_event in analyzer.bind_events:
if bind_event['callback']['name']:
all_handlers.add(bind_event['callback']['name'])
for handler_name in all_handlers:
if handler_name and not handler_name.startswith('lambda'):
handler_info = {
'name': handler_name,
'type': 'method',
'used_by': [],
}
for callback in analyzer.command_callbacks:
if callback['command']['name'] == handler_name:
handler_info['used_by'].append({
'widget': callback['widget'],
'type': 'command',
})
for bind_event in analyzer.bind_events:
if bind_event['callback']['name'] == handler_name:
handler_info['used_by'].append({
'widget': bind_event['widget'],
'type': 'bind',
'event': bind_event['event'],
})
connections['handler_methods'].append(handler_info)
lambda_count = 0
for callback in analyzer.command_callbacks:
if callback['command']['is_lambda']:
lambda_count += 1
connections['lambda_handlers'].append({
'widget': callback['widget'],
'complexity': callback['command']['complexity'],
'body': callback['command']['lambda_body'],
})
connections['connection_summary'] = {
'total_widgets': len(widgets),
'widgets_with_commands': len(connections['widgets_with_commands']),
'widgets_with_bind_events': len(connections['widgets_with_bind_events']),
'total_handlers': len(connections['handler_methods']),
'lambda_handlers': lambda_count,
'interactive_widgets': len([w for w in widgets if is_interactive_widget(w['type'])]),
}
return connections

View File

@ -0,0 +1,13 @@
import ast
def enter_class(analyzer, node: ast.ClassDef):
analyzer.current_class = node.name
def exit_class(analyzer, prev):
analyzer.current_class = prev
def enter_function(analyzer, node: ast.FunctionDef):
analyzer.current_method = node.name
def exit_function(analyzer, prev):
analyzer.current_method = prev

View File

@ -0,0 +1,77 @@
import ast
from typing import Dict, Any
from .values import extract_value, get_variable_name, analyze_lambda_complexity, extract_lambda_body
def analyze_bind_event(analyzer, target_var: str, call_node: ast.Call):
if len(call_node.args) < 2:
return
event_sequence = extract_value(call_node.args[0])
callback = call_node.args[1]
callback_info = analyze_callback(analyzer, callback)
analyzer.bind_events.append({
'widget': target_var,
'event': event_sequence,
'callback': callback_info,
'class_context': analyzer.current_class,
'method_context': analyzer.current_method,
})
def analyze_config_command(analyzer, target_var: str, call_node: ast.Call):
for keyword in call_node.keywords:
if keyword.arg == 'command':
callback_info = analyze_callback(analyzer, keyword.value)
analyzer.command_callbacks.append({
'widget': target_var,
'command': callback_info,
'class_context': analyzer.current_class,
'method_context': analyzer.current_method,
})
def analyze_callback(analyzer, callback_node: ast.AST) -> Dict[str, Any]:
info: Dict[str, Any] = {
'type': 'unknown',
'name': None,
'is_lambda': False,
'lambda_body': None,
'parameters': [],
'arguments': [],
'complexity': 'simple',
}
if isinstance(callback_node, ast.Name):
info['type'] = 'function_reference'
info['name'] = callback_node.id
elif isinstance(callback_node, ast.Attribute):
info['type'] = 'method_reference'
info['name'] = get_variable_name(callback_node)
elif isinstance(callback_node, ast.Lambda):
info['type'] = 'lambda'
info['is_lambda'] = True
info['parameters'] = [arg.arg for arg in callback_node.args.args]
if isinstance(callback_node.body, ast.Expr):
info['lambda_body'] = extract_lambda_body(callback_node.body.value)
else:
info['lambda_body'] = extract_lambda_body(callback_node.body)
info['complexity'] = analyze_lambda_complexity(callback_node)
elif isinstance(callback_node, ast.Call):
info['type'] = 'function_call'
info['name'] = get_variable_name(callback_node.func)
info['arguments'] = [extract_value(arg) for arg in callback_node.args]
elif isinstance(callback_node, ast.ListComp):
info['type'] = 'list_comprehension'
info['complexity'] = 'complex'
elif isinstance(callback_node, ast.DictComp):
info['type'] = 'dict_comprehension'
info['complexity'] = 'complex'
return info
def is_interactive_widget(widget_type: str) -> bool:
interactive_types = [
'Button', 'Checkbutton', 'Radiobutton', 'Text',
]
return widget_type in interactive_types

View File

@ -0,0 +1,28 @@
import ast
from typing import Dict, Any
from .values import extract_value, get_variable_name
def extract_call_parameters(analyzer, call_node: ast.Call) -> Dict[str, Any]:
params: Dict[str, Any] = {}
for i, arg in enumerate(call_node.args):
if i == 0:
continue
params[f'arg_{i}'] = extract_value(arg)
for keyword in call_node.keywords:
if keyword.arg:
params[keyword.arg] = extract_value(keyword.value)
return params
def extract_parent_container(analyzer, call_node: ast.Call) -> str:
if call_node.args and len(call_node.args) > 0:
parent_arg = call_node.args[0]
if isinstance(parent_arg, ast.Name):
return parent_arg.id
elif isinstance(parent_arg, ast.Attribute):
return get_variable_name(parent_arg)
return 'root'

View File

@ -0,0 +1,14 @@
import ast
def handle_import(analyzer, node: ast.Import):
for alias in node.names:
if alias.name == 'tkinter':
analyzer.imports['tkinter'] = alias.asname or 'tkinter'
def handle_import_from(analyzer, node: ast.ImportFrom):
if node.module == 'tkinter':
for alias in node.names:
analyzer.imports[alias.name] = alias.asname or alias.name
elif node.module == 'tkinter.ttk':
for alias in node.names:
analyzer.imports[alias.name] = alias.asname or alias.name

View File

@ -0,0 +1,39 @@
import ast
from .extractors import extract_call_parameters
def update_widget_placement(analyzer, target_var: str, call_node: ast.Call, method: str):
widget = None
for w in analyzer.widgets:
if w['variable_name'] == target_var:
widget = w
break
if not widget:
return
placement_params = extract_call_parameters(analyzer, call_node)
if method == 'place':
widget['placement'] = {
'method': 'place',
'x': placement_params.get('x', 0),
'y': placement_params.get('y', 0),
'width': placement_params.get('width'),
'height': placement_params.get('height'),
'relx': placement_params.get('relx'),
'rely': placement_params.get('rely'),
'relwidth': placement_params.get('relwidth'),
'relheight': placement_params.get('relheight'),
}
elif method == 'grid':
widget['placement'] = {
'method': 'grid',
'row': placement_params.get('row', 0),
'column': placement_params.get('column', 0),
'rowspan': placement_params.get('rowspan', 1),
'columnspan': placement_params.get('columnspan', 1),
'padx': placement_params.get('padx', 0),
'pady': placement_params.get('pady', 0),
'sticky': placement_params.get('sticky', ''),
}

View File

@ -0,0 +1,149 @@
import ast
from typing import Any
def get_variable_name(node: ast.AST) -> str:
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
if isinstance(node.value, ast.Name):
return f"{node.value.id}.{node.attr}"
else:
return f"{get_variable_name(node.value)}.{node.attr}"
else:
return str(node)
def extract_value(node: ast.AST) -> Any:
if isinstance(node, ast.Constant):
return node.value
elif isinstance(node, ast.Str):
return node.s
elif isinstance(node, ast.Num):
return node.n
elif isinstance(node, ast.Name):
return f"${node.id}"
elif isinstance(node, ast.Attribute):
return f"${get_variable_name(node)}"
elif isinstance(node, ast.List):
return [extract_value(item) for item in node.elts]
elif isinstance(node, ast.Tuple):
return tuple(extract_value(item) for item in node.elts)
else:
return str(node)
def get_operator_symbol(op_node: ast.AST) -> str:
operator_map = {
ast.Add: '+',
ast.Sub: '-',
ast.Mult: '*',
ast.Div: '/',
ast.Mod: '%',
ast.Pow: '**',
ast.LShift: '<<',
ast.RShift: '>>',
ast.BitOr: '|',
ast.BitXor: '^',
ast.BitAnd: '&',
ast.FloorDiv: '//',
ast.Eq: '==',
ast.NotEq: '!=',
ast.Lt: '<',
ast.LtE: '<=',
ast.Gt: '>',
ast.GtE: '>=',
ast.Is: 'is',
ast.IsNot: 'is not',
ast.In: 'in',
ast.NotIn: 'not in',
ast.And: 'and',
ast.Or: 'or',
ast.Not: 'not',
ast.UAdd: '+',
ast.USub: '-',
ast.Invert: '~',
}
return operator_map.get(type(op_node), str(op_node))
def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
body = lambda_node.body
if isinstance(body, (ast.Constant, ast.Str, ast.Num)):
return 'simple'
elif isinstance(body, (ast.Name, ast.Attribute)):
return 'simple'
elif isinstance(body, (ast.Call, ast.BinOp, ast.Compare)):
return 'medium'
else:
return 'complex'
def extract_lambda_body(body_node: ast.AST) -> str:
if isinstance(body_node, ast.Constant):
return str(body_node.value)
elif isinstance(body_node, ast.Str):
return f'"{body_node.s}"'
elif isinstance(body_node, ast.Num):
return str(body_node.n)
elif isinstance(body_node, ast.Name):
return body_node.id
elif isinstance(body_node, ast.Attribute):
return get_variable_name(body_node)
elif isinstance(body_node, ast.Call):
func_name = get_variable_name(body_node.func)
args = [extract_lambda_body(arg) for arg in body_node.args]
kwargs = [f"{kw.arg}={extract_lambda_body(kw.value)}" for kw in body_node.keywords if kw.arg]
all_args = args + kwargs
return f"{func_name}({', '.join(all_args)})"
elif isinstance(body_node, ast.BinOp):
left = extract_lambda_body(body_node.left)
right = extract_lambda_body(body_node.right)
op = get_operator_symbol(body_node.op)
return f"({left} {op} {right})"
elif isinstance(body_node, ast.Compare):
left = extract_lambda_body(body_node.left)
comparators = [extract_lambda_body(comp) for comp in body_node.comparators]
ops = [get_operator_symbol(op) for op in body_node.ops]
return f"({left} {' '.join([f'{op} {comp}' for op, comp in zip(ops, comparators)])})"
elif isinstance(body_node, ast.BoolOp):
op = get_operator_symbol(body_node.op)
values = [extract_lambda_body(value) for value in body_node.values]
return f"({f' {op} '.join(values)})"
elif isinstance(body_node, ast.UnaryOp):
op = get_operator_symbol(body_node.op)
operand = extract_lambda_body(body_node.operand)
return f"{op}{operand}"
elif isinstance(body_node, ast.IfExp):
test = extract_lambda_body(body_node.test)
body = extract_lambda_body(body_node.body)
orelse = extract_lambda_body(body_node.orelse)
return f"({body} if {test} else {orelse})"
elif isinstance(body_node, ast.List):
elements = [extract_lambda_body(el) for el in body_node.elts]
return f"[{', '.join(elements)}]"
elif isinstance(body_node, ast.Dict):
keys = [extract_lambda_body(k) for k in body_node.keys]
values = [extract_lambda_body(v) for v in body_node.values]
pairs = [f"{k}: {v}" for k, v in zip(keys, values)]
return f"{{{', '.join(pairs)}}}"
elif isinstance(body_node, ast.Subscript):
value = extract_lambda_body(body_node.value)
if hasattr(ast, 'Index') and isinstance(body_node.slice, ast.Index):
slice_val = extract_lambda_body(body_node.slice.value)
else:
slice_val = extract_lambda_body(body_node.slice)
return f"{value}[{slice_val}]"
elif isinstance(body_node, ast.ListComp):
return "<list_comprehension>"
elif isinstance(body_node, ast.DictComp):
return "<dict_comprehension>"
elif isinstance(body_node, ast.JoinedStr):
parts = []
for value in body_node.values:
if isinstance(value, ast.Constant):
parts.append(str(value.value))
elif isinstance(value, ast.Str):
parts.append(value.s)
else:
parts.append(f"{{{extract_lambda_body(value)}}}")
return f"f\"{''.join(parts)}\""
elif isinstance(body_node, ast.FormattedValue):
return f"{{{extract_lambda_body(body_node.value)}}}"
else:
return f"<complex_expression:{type(body_node).__name__}>"

View File

@ -0,0 +1,96 @@
import ast
from typing import Dict, Any, Optional
from .extractors import extract_call_parameters, extract_parent_container
from .values import get_variable_name
def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool:
if not isinstance(call_node.func, ast.Attribute):
return False
module_name = None
if isinstance(call_node.func.value, ast.Name):
module_name = call_node.func.value.id
elif isinstance(call_node.func.value, ast.Attribute):
full = get_variable_name(call_node.func.value)
parts = full.split('.')
module_name = parts[-1]
widget_type = call_node.func.attr
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
return False
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
if not isinstance(node.value, ast.Call):
return False
if isinstance(node.value.func, ast.Attribute):
return is_tkinter_widget_call(analyzer, node.value)
elif isinstance(node.value.func, ast.Name):
return node.value.func.id in ['Label', 'Button', 'Text', 'Checkbutton']
return False
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
if isinstance(node.targets[0], ast.Name):
variable_name = node.targets[0].id
elif isinstance(node.targets[0], ast.Attribute):
if isinstance(node.targets[0].value, ast.Name) and node.targets[0].value.id == 'self':
variable_name = node.targets[0].attr
else:
return None
else:
return None
call_node = node.value
if isinstance(call_node.func, ast.Attribute):
widget_type = call_node.func.attr
if isinstance(call_node.func.value, ast.Name):
module_name = call_node.func.value.id
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
return None
elif isinstance(call_node.func, ast.Name):
widget_type = call_node.func.id
else:
return None
params = extract_call_parameters(analyzer, call_node)
parent = extract_parent_container(analyzer, call_node)
return {
'variable_name': variable_name,
'type': widget_type,
'params': params,
'parent': parent,
'placement': None,
'class_context': analyzer.current_class,
'method_context': analyzer.current_method,
}
def analyze_widget_creation_commands(analyzer, node: ast.Assign):
if not is_widget_creation(analyzer, node):
return
call_node = node.value
for keyword in call_node.keywords:
if keyword.arg == 'command':
if isinstance(node.targets[0], ast.Name):
widget_name = node.targets[0].id
elif isinstance(node.targets[0], ast.Attribute):
widget_name = node.targets[0].attr
else:
continue
callback_info = analyzer.analyze_callback(keyword.value)
analyzer.command_callbacks.append({
'widget': widget_name,
'command': callback_info,
'class_context': analyzer.current_class,
'method_context': analyzer.current_method,
'created_at_creation': True,
})

View File

@ -0,0 +1,55 @@
from typing import Dict, List, Any
class GridLayoutAnalyzer:
def __init__(self):
self.grid_widgets = []
self.cell_width = 100
self.cell_height = 40
self.padding_x = 10
self.padding_y = 10
def analyze_grid_layout(self, widgets: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
grid_widgets = []
for w in widgets:
placement = w.get('placement')
if placement and placement.get('method') == 'grid':
grid_widgets.append(w)
if not grid_widgets:
return widgets
max_row = max((w['placement']['row'] for w in grid_widgets), default=0)
max_col = max((w['placement']['column'] for w in grid_widgets), default=0)
window_width = 800
window_height = 600
if max_col > 0:
self.cell_width = (window_width - 2 * self.padding_x) // (max_col + 1)
if max_row > 0:
self.cell_height = (window_height - 2 * self.padding_y) // (max_row + 1)
for widget in grid_widgets:
placement = widget.get('placement', {})
if not placement:
continue
row = placement.get('row', 0)
col = placement.get('column', 0)
x = col * self.cell_width + self.padding_x
y = row * self.cell_height + self.padding_y
width = self.cell_width * placement.get('columnspan', 1)
height = self.cell_height * placement.get('rowspan', 1)
widget['placement'] = {
'method': 'place',
'x': x,
'y': y,
'width': width,
'height': height
}
return widgets

View File

@ -0,0 +1,54 @@
import ast
import json
from typing import Dict, Any, List
from tk_ast.analyzer import TkinterAnalyzer
from tk_ast.grid_layout import GridLayoutAnalyzer
def parse_tkinter_code(code: str) -> Dict[str, Any]:
try:
tree = ast.parse(code)
analyzer = TkinterAnalyzer()
analyzer.visit(tree)
grid_analyzer = GridLayoutAnalyzer()
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
result: Dict[str, Any] = {
'window': analyzer.window_config,
'widgets': widgets,
'command_callbacks': analyzer.command_callbacks,
'bind_events': analyzer.bind_events,
'success': True
}
return result
except SyntaxError as e:
return {
'error': f'Syntax error: {e}',
'success': False
}
except Exception as e:
return {
'error': f'Parsing error: {e}',
'success': False
}
def parse_file(filename: str) -> str:
try:
with open(filename, 'r', encoding='utf-8') as f:
code = f.read()
result = parse_tkinter_code(code)
return json.dumps(result, indent=2, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({
'error': f'File not found: {filename}',
'success': False
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
'error': f'File reading error: {e}',
'success': False
}, ensure_ascii=False)

View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
import sys
import json
try:
from tk_ast.parser import parse_tkinter_code, parse_file
except Exception as e:
raise RuntimeError(
f"Failed to import tk_ast package: {e}. Ensure 'tk_ast' exists next to this script."
)
if __name__ == '__main__':
if len(sys.argv) > 1:
print(parse_file(sys.argv[1]))
else:
code = sys.stdin.read()
result = parse_tkinter_code(code)
print(json.dumps(result, indent=2, ensure_ascii=False))

21
src/parser/utils.ts Normal file
View File

@ -0,0 +1,21 @@
export function getDefaultWidth(type: string): number {
const defaults: { [key: string]: number } = {
Label: 100,
Button: 80,
Text: 200,
Checkbutton: 100,
Radiobutton: 100,
};
return defaults[type] || 100;
}
export function getDefaultHeight(type: string): number {
const defaults: { [key: string]: number } = {
Label: 25,
Button: 30,
Text: 100,
Checkbutton: 25,
Radiobutton: 25,
};
return defaults[type] || 25;
}

View File

@ -0,0 +1,237 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { Uri } from 'vscode';
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'tkinter-designer';
public static _instance: TkinterDesignerProvider | undefined;
private _view?: vscode.WebviewPanel;
private _designData: any = {
widgets: [],
events: [],
form: { title: 'My App', size: { width: 800, height: 600 } },
};
constructor(private readonly _extensionUri: vscode.Uri) {}
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
if (TkinterDesignerProvider._instance?._view) {
console.log('[Webview] Revealing existing panel');
TkinterDesignerProvider._instance._view.reveal(column);
return TkinterDesignerProvider._instance;
}
console.log('[Webview] Creating new panel');
const panel = vscode.window.createWebviewPanel(
TkinterDesignerProvider.viewType,
'Tkinter Designer',
column || vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
extensionUri,
vscode.Uri.joinPath(extensionUri, 'media'),
vscode.Uri.joinPath(extensionUri, 'src', 'webview'),
vscode.Uri.joinPath(extensionUri, 'out', 'webview'),
],
}
);
if (!TkinterDesignerProvider._instance) {
TkinterDesignerProvider._instance = new TkinterDesignerProvider(
extensionUri
);
}
TkinterDesignerProvider._instance._view = panel;
TkinterDesignerProvider._instance._setWebviewContent(panel.webview);
TkinterDesignerProvider._instance._setupMessageHandling(panel.webview);
panel.onDidDispose(() => {
console.log('[Webview] Panel disposed');
if (TkinterDesignerProvider._instance) {
TkinterDesignerProvider._instance._view = undefined;
}
});
return TkinterDesignerProvider._instance;
}
public resolveWebviewView(
webviewView: vscode.WebviewView,
context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken
) {
this._view = webviewView as any;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [
this._extensionUri,
vscode.Uri.joinPath(this._extensionUri, 'src', 'webview'),
vscode.Uri.joinPath(this._extensionUri, 'out', 'webview'),
vscode.Uri.joinPath(this._extensionUri, 'media'),
],
};
this._setWebviewContent(webviewView.webview);
this._setupMessageHandling(webviewView.webview);
}
private _setWebviewContent(webview: vscode.Webview) {
webview.html = this._getHtmlForWebview(webview);
}
private _setupMessageHandling(webview: vscode.Webview) {
webview.onDidReceiveMessage((message) => {
console.log('[Webview] Message:', message.type);
switch (message.type) {
case 'designUpdated':
this._designData = message.data;
break;
case 'getDesignData':
webview.postMessage({
type: 'designData',
data: this._designData,
});
break;
case 'webviewReady':
if (
this._designData &&
this._designData.widgets &&
this._designData.widgets.length > 0
) {
webview.postMessage({
type: 'loadDesign',
data: this._designData,
});
console.log('[Webview] Sent loadDesign');
}
break;
case 'generateCode':
console.log('[Webview] Generating code from webview');
this.handleGenerateCode(message.data);
break;
case 'showInfo':
vscode.window.showInformationMessage(message.text);
break;
case 'showError':
vscode.window.showErrorMessage(message.text);
break;
}
}, undefined);
}
public async getDesignData(): Promise<any> {
return this._designData;
}
public loadDesignData(data: any): void {
this._designData = data;
if (this._view) {
const webview = (this._view as any).webview || this._view;
webview.postMessage({
type: 'loadDesign',
data: this._designData,
});
console.log('[Webview] loadDesign posted');
} else {
}
}
private async handleGenerateCode(designData: any): Promise<void> {
try {
console.log('[GenerateCode] Start');
const { CodeGenerator } = await import(
'../generator/CodeGenerator'
);
const generator = new CodeGenerator();
const pythonCode = generator.generateTkinterCode(designData);
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') {
console.log('[GenerateCode] Writing into active editor');
const doc = activeEditor.document;
const start = new vscode.Position(0, 0);
const end = doc.lineCount
? doc.lineAt(doc.lineCount - 1).range.end
: start;
const fullRange = new vscode.Range(start, end);
await activeEditor.edit((editBuilder) => {
editBuilder.replace(fullRange, pythonCode);
});
await doc.save();
vscode.window.showInformationMessage(
'Python code generated into the active file'
);
} else {
console.log('[GenerateCode] Creating new file');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
return;
}
const fileName = `app_${Date.now()}.py`;
const filePath = path.join(
workspaceFolder.uri.fsPath,
fileName
);
const fileUri = Uri.file(filePath);
const encoder = new TextEncoder();
const fileBytes = encoder.encode(pythonCode);
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc, {
preview: false,
});
vscode.window.showInformationMessage(
`Python file created: ${fileName}`
);
}
console.log('[GenerateCode] Done');
} catch (error) {
console.error('[GenerateCode] Error:', error);
vscode.window.showErrorMessage(`Error generating code: ${error}`);
}
}
private _getHtmlForWebview(webview: vscode.Webview): string {
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(
this._extensionUri,
'src',
'webview',
'style.css'
)
);
const reactBundleUri = webview.asWebviewUri(
vscode.Uri.joinPath(
this._extensionUri,
'out',
'webview',
'react-webview.js'
)
);
const csp = `default-src 'none'; img-src ${webview.cspSource} https:; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource};`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<link href="${styleUri}" rel="stylesheet">
<title>Tkinter Designer</title>
</head>
<body>
<div id="root"></div>
<script src="${reactBundleUri}"></script>
</body>
</html>`;
}
}

13
src/webview/preview.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/src/webview/style.css" rel="stylesheet" />
<title>Tkinter Designer Preview</title>
</head>
<body>
<div id="root"></div>
<script src="/out/webview/react-webview.js"></script>
</body>
</html>

27
src/webview/react/App.tsx Normal file
View File

@ -0,0 +1,27 @@
import React from 'react';
import { Toolbar } from './components/Toolbar';
import { Palette } from './components/Palette';
import { PropertiesPanel } from './components/PropertiesPanel';
import { EventsPanel } from './components/EventsPanel';
import { Canvas } from './components/Canvas';
import { useMessaging } from './useMessaging';
export function App() {
useMessaging();
return (
<div className="container">
<Toolbar />
<div className="main-content">
<div className="sidebar">
<h3>Widgets</h3>
<Palette />
<PropertiesPanel />
<EventsPanel />
</div>
<div className="design-area">
<Canvas />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import React, { useRef } from 'react';
import { useAppDispatch, useAppState } from '../state';
import type { WidgetType } from '../types';
export function Canvas() {
const dispatch = useAppDispatch();
const { design, selectedWidgetId } = useAppState();
const containerRef = useRef<HTMLDivElement | null>(null);
const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain') as WidgetType;
const rect = containerRef.current?.getBoundingClientRect();
const x = e.clientX - (rect?.left || 0);
const y = e.clientY - (rect?.top || 0);
console.log('[Canvas] Drop widget', type, 'at', x, y);
if (type) dispatch({ type: 'addWidget', payload: { type, x, y } });
};
const onSelect = (id: string | null) => {
console.log('[Canvas] Select widget', id);
dispatch({ type: 'selectWidget', payload: { id } });
};
const onMouseDown = (e: React.MouseEvent<HTMLDivElement>, id: 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;
console.log('[Canvas] Drag start', id, 'at', initX, initY);
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
dispatch({
type: 'updateWidget',
payload: { id, patch: { x: initX + dx, y: initY + dy } },
});
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
console.log('[Canvas] Drag end', id);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
return (
<div className="canvas-container">
<div
id="designCanvas"
className="design-canvas"
ref={containerRef}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => onSelect(null)}
>
{design.widgets.length === 0 && (
<div className="canvas-placeholder">
<p>Drag widgets here to start designing</p>
</div>
)}
{design.widgets.map((w) => (
<div
key={w.id}
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
style={{
position: 'absolute',
left: w.x,
top: w.y,
width: w.width,
height: w.height,
}}
onClick={(e) => {
e.stopPropagation();
onSelect(w.id);
}}
onMouseDown={(e) => onMouseDown(e, w.id)}
>
<div className="widget-content">
{w.properties?.text || w.type}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { useAppDispatch, useAppState } from '../state';
export function EventsPanel() {
const dispatch = useAppDispatch();
const { design, selectedWidgetId, vscode } = useAppState();
const [eventType, setEventType] = useState('command');
const [eventName, setEventName] = useState('onClick');
const [eventCode, setEventCode] = useState('print("clicked")');
const w = design.widgets.find((x) => x.id === selectedWidgetId);
const widgetEvents = (id: string | undefined) =>
(design.events || []).filter((e) => e.widget === id);
const add = () => {
if (!w) {
vscode.postMessage({
type: 'showError',
text: 'Select a widget to add events to',
});
return;
}
if (!eventType || !eventName || !eventCode) {
vscode.postMessage({
type: 'showError',
text: 'Please fill in all fields: event type, name, and code',
});
return;
}
dispatch({
type: 'addEvent',
payload: {
widget: w.id,
type: eventType,
name: eventName,
code: eventCode,
},
});
vscode.postMessage({
type: 'showInfo',
text: `Event added: ${eventType} -> ${eventName}`,
});
};
const remove = (type: string) => {
if (!w) return;
dispatch({ type: 'removeEvent', payload: { widget: w.id, type } });
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
};
return (
<div className="events-panel">
<h3>Events & Commands</h3>
<div id="eventsContent">
{!w ? (
<p>Select a widget to configure events</p>
) : (
<div className="event-section">
<div className="event-controls">
<label className="property-label">
Event Type:
</label>
<input
className="event-type-select"
type="text"
value={eventType}
onChange={(e) => setEventType(e.target.value)}
/>
<label className="property-label">
Handler Name:
</label>
<input
className="event-handler-input"
type="text"
value={eventName}
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 className="event-buttons">
<button className="event-btn primary" onClick={add}>
Add Event
</button>
</div>
<div className="event-list">
<h4>Configured Events</h4>
{widgetEvents(w.id).length === 0 ? (
<p>No events added yet.</p>
) : (
widgetEvents(w.id).map((ev) => (
<div
className="event-item"
key={`${ev.widget}-${ev.type}`}
>
<div className="event-info">
<span className="event-name">
{ev.name}
</span>{' '}
<span className="event-handler">
{ev.type}
</span>
<div className="event-code">
{ev.code}
</div>
</div>
<div className="event-actions">
<button
className="event-btn secondary"
onClick={() => remove(ev.type)}
>
Remove
</button>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import React from 'react';
import { useAppDispatch } from '../state';
import type { WidgetType } from '../types';
const WIDGETS: WidgetType[] = [
'Label',
'Button',
'Text',
'Checkbutton',
'Radiobutton',
];
export function Palette() {
const onDragStart = (
e: React.DragEvent<HTMLDivElement>,
type: WidgetType
) => {
console.log('[Palette] Drag start', type);
e.dataTransfer.setData('text/plain', type);
};
return (
<div className="widget-palette">
{WIDGETS.map((type) => (
<div
key={type}
className="widget-item"
draggable
onDragStart={(e) => onDragStart(e, type)}
data-widget-type={type}
>
<span className="widget-icon">🧩</span>
<span>{type}</span>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,112 @@
import React from 'react';
import { useAppDispatch, useAppState } from '../state';
export function PropertiesPanel() {
const dispatch = useAppDispatch();
const { design, selectedWidgetId } = useAppState();
const w = design.widgets.find((x) => x.id === selectedWidgetId);
const update = (patch: Partial<typeof w>) => {
if (!w) return;
dispatch({
type: 'updateWidget',
payload: { id: w.id, patch: patch as any },
});
};
const updateProp = (key: string, value: any) => {
if (!w) return;
dispatch({
type: 'updateProps',
payload: { id: w.id, properties: { [key]: value } },
});
};
const onDelete = () => {
if (!w) return;
dispatch({ type: 'deleteWidget', payload: { id: w.id } });
};
return (
<div className="properties-panel">
<h3>Properties</h3>
<div id="propertiesContent">
{!w ? (
<p>Select a widget to edit properties</p>
) : (
<div className="properties-form">
<div className="property-row">
<label className="property-label">Type:</label>
<span>{w.type}</span>
</div>
<div className="property-row">
<label className="property-label">ID:</label>
<span>{w.id}</span>
</div>
<div className="property-row">
<label className="property-label">X:</label>
<input
className="property-input"
type="number"
value={w.x}
onChange={(e) =>
update({ x: Number(e.target.value) })
}
/>
</div>
<div className="property-row">
<label className="property-label">Y:</label>
<input
className="property-input"
type="number"
value={w.y}
onChange={(e) =>
update({ y: Number(e.target.value) })
}
/>
</div>
<div className="property-row">
<label className="property-label">Width:</label>
<input
className="property-input"
type="number"
value={w.width}
onChange={(e) =>
update({ width: Number(e.target.value) })
}
/>
</div>
<div className="property-row">
<label className="property-label">Height:</label>
<input
className="property-input"
type="number"
value={w.height}
onChange={(e) =>
update({ height: Number(e.target.value) })
}
/>
</div>
<div className="property-row">
<label className="property-label">Text:</label>
<input
className="property-input"
type="text"
value={w.properties?.text || ''}
onChange={(e) =>
updateProp('text', e.target.value)
}
/>
</div>
<div className="property-row">
<button
className="btn btn-danger"
onClick={onDelete}
>
Delete Widget
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,162 @@
import React, { useRef } from 'react';
import { useAppDispatch, useAppState } from '../state';
export function Toolbar() {
const dispatch = useAppDispatch();
const { vscode, design } = useAppState();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const exportProject = () => {
try {
console.log('[Toolbar] Export project');
const exportData = {
version: '1.0',
created: new Date().toISOString(),
description: 'Tkinter Designer Project',
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({
type: 'showInfo',
text: 'Project exported successfully!',
});
} catch (error: any) {
console.error('Export error:', error);
vscode.postMessage({
type: 'showError',
text: `Export failed: ${error.message}`,
});
}
};
const importProject = (e: React.ChangeEvent<HTMLInputElement>) => {
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({
type: 'showInfo',
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 = () => {
console.log('[Toolbar] Clear all');
dispatch({ type: 'clear' });
vscode.postMessage({
type: 'showInfo',
text: 'Canvas cleared successfully!',
});
};
const generateCode = () => {
console.log('[Toolbar] Generate code');
if (design.widgets.length === 0) {
vscode.postMessage({
type: 'showError',
text: 'No widgets to generate code for!',
});
return;
}
vscode.postMessage({
type: 'showInfo',
text: 'Generating Python code...',
});
vscode.postMessage({ type: 'generateCode', data: design });
};
return (
<div className="toolbar">
<input
ref={fileInputRef}
type="file"
id="importFileInput"
accept=".json"
style={{ display: 'none' }}
onChange={importProject}
/>
<h2>Tkinter Visual Designer</h2>
<div className="toolbar-buttons">
<button
id="undoBtn"
className="btn btn-outline"
title="Undo (Ctrl+Z)"
onClick={() => dispatch({ type: 'undo' })}
>
Undo
</button>
<button
id="redoBtn"
className="btn btn-outline"
title="Redo (Ctrl+Y)"
onClick={() => dispatch({ type: 'redo' })}
>
Redo
</button>
<div className="toolbar-separator" />
<button
id="exportBtn"
className="btn btn-outline"
title="Export project to JSON"
onClick={exportProject}
>
📤 Export
</button>
<button
id="importBtn"
className="btn btn-outline"
title="Import project from JSON"
onClick={() => fileInputRef.current?.click()}
>
📥 Import
</button>
<div className="toolbar-separator" />
<button
id="generateBtn"
className="btn btn-primary"
onClick={generateCode}
>
Generate Code
</button>
<button
id="clearBtn"
className="btn btn-secondary"
onClick={clearAll}
>
Clear All
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { AppProvider } from './state';
import { App } from './App';
declare global {
interface Window {
acquireVsCodeApi?: () => { postMessage: (msg: any) => void };
}
}
const vscode =
typeof window.acquireVsCodeApi === 'function'
? window.acquireVsCodeApi()
: { postMessage: (msg: any) => console.log('[Webview message]', msg) };
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(
<AppProvider vscode={vscode}>
<App />
</AppProvider>
);
}

242
src/webview/react/state.tsx Normal file
View File

@ -0,0 +1,242 @@
import React, { createContext, useContext, useReducer, useMemo } from 'react';
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
export interface AppState {
vscode: { postMessage: (msg: any) => void };
design: DesignData;
selectedWidgetId: string | null;
history: DesignData[];
historyIndex: number;
}
type Action =
| { type: 'init'; payload: DesignData }
| { type: 'addWidget'; payload: { type: WidgetType; x: number; y: number } }
| { type: 'selectWidget'; payload: { id: string | null } }
| { type: 'updateWidget'; payload: { id: string; patch: Partial<Widget> } }
| {
type: 'updateProps';
payload: { id: string; properties: Record<string, any> };
}
| { type: 'deleteWidget'; payload: { id: string } }
| { type: 'addEvent'; payload: EventBinding }
| { type: 'removeEvent'; payload: { widget: string; type: string } }
| { type: 'clear' }
| { type: 'undo' }
| { type: 'redo' }
| {
type: 'setForm';
payload: {
title?: string;
size?: { width: number; height: number };
};
};
const AppStateContext = createContext<AppState | undefined>(undefined);
const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
undefined
);
function clone(data: DesignData): DesignData {
return JSON.parse(JSON.stringify(data));
}
function reducer(state: AppState, action: Action): AppState {
console.log('[State] Action:', action.type);
switch (action.type) {
case 'init': {
const next = {
...state,
design: action.payload,
selectedWidgetId: null,
};
if (!next.design.events) next.design.events = [];
console.log('[State] Init design widgets:', next.design.widgets.length);
return pushHistory(next);
}
case 'addWidget': {
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const w: Widget = {
id,
type: action.payload.type,
x: action.payload.x,
y: action.payload.y,
width: 120,
height: 40,
properties: { text: action.payload.type },
};
const design = clone(state.design);
design.widgets.push(w);
const next = { ...state, design, selectedWidgetId: id };
console.log('[State] Added widget:', id, w.type);
return pushHistory(next);
}
case 'selectWidget': {
console.log('[State] Select widget:', action.payload.id);
return { ...state, selectedWidgetId: action.payload.id };
}
case 'updateWidget': {
const design = clone(state.design);
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
design.widgets[idx] = {
...design.widgets[idx],
...action.payload.patch,
};
}
const next = { ...state, design };
console.log('[State] Updated widget:', action.payload.id);
return pushHistory(next);
}
case 'updateProps': {
const design = clone(state.design);
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
design.widgets[idx] = {
...design.widgets[idx],
properties: {
...design.widgets[idx].properties,
...action.payload.properties,
},
};
}
const next = { ...state, design };
console.log('[State] Updated properties for:', action.payload.id);
return pushHistory(next);
}
case 'deleteWidget': {
const design = clone(state.design);
design.widgets = design.widgets.filter(
(w) => w.id !== action.payload.id
);
if (!design.events) design.events = [];
design.events = design.events.filter(
(e) => e.widget !== action.payload.id
);
const next = { ...state, design, selectedWidgetId: null };
console.log('[State] Deleted widget:', action.payload.id);
return pushHistory(next);
}
case 'addEvent': {
const design = clone(state.design);
if (!design.events) design.events = [];
const exists = design.events.find(
(e) =>
e.widget === action.payload.widget &&
e.type === action.payload.type
);
if (!exists) {
design.events.push(action.payload);
}
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget);
return pushHistory({ ...state, design });
}
case 'removeEvent': {
const design = clone(state.design);
if (!design.events) design.events = [];
design.events = design.events.filter(
(e) =>
!(
e.widget === action.payload.widget &&
e.type === action.payload.type
)
);
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget);
return pushHistory({ ...state, design });
}
case 'clear': {
const design: DesignData = {
form: state.design.form,
widgets: [],
events: [],
};
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': {
const design = clone(state.design);
if (action.payload.title) design.form.title = action.payload.title;
if (action.payload.size) design.form.size = action.payload.size;
console.log('[State] Set form', action.payload);
return pushHistory({ ...state, design });
}
default:
return state;
}
}
function pushHistory(next: AppState): AppState {
const hist = next.history.slice(0, next.historyIndex + 1);
hist.push(clone(next.design));
console.log('[State] History length:', hist.length);
return { ...next, history: hist, historyIndex: hist.length - 1 };
}
export function useAppState() {
const ctx = useContext(AppStateContext);
if (!ctx) throw new Error('useAppState must be used within AppProvider');
return ctx;
}
export function useAppDispatch() {
const ctx = useContext(AppDispatchContext);
if (!ctx) throw new Error('useAppDispatch must be used within AppProvider');
return ctx;
}
export function AppProvider({
children,
vscode,
}: {
children: React.ReactNode;
vscode: { postMessage: (msg: any) => void };
}) {
const initialDesign: DesignData = useMemo(
() => ({
form: { title: 'My App', size: { width: 800, height: 600 } },
widgets: [],
events: [],
}),
[]
);
const initial: AppState = {
vscode,
design: initialDesign,
selectedWidgetId: null,
history: [initialDesign],
historyIndex: 0,
};
const [state, dispatch] = useReducer(reducer, initial);
return (
<AppStateContext.Provider value={state}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
}

View File

@ -0,0 +1,32 @@
export type WidgetType =
| 'Label'
| 'Button'
| 'Text'
| 'Checkbutton'
| 'Radiobutton';
export interface Widget {
id: string;
type: WidgetType;
x: number;
y: number;
width: number;
height: number;
properties: Record<string, any>;
}
export interface EventBinding {
widget: string;
type: string;
name: string;
code: string;
}
export interface DesignData {
form: {
title: string;
size: { width: number; height: number };
};
widgets: Widget[];
events: EventBinding[];
}

View File

@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
import { useAppDispatch, useAppState } from './state';
export function useMessaging() {
const dispatch = useAppDispatch();
const { vscode, design } = useAppState();
const initializedRef = useRef(false);
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data;
switch (message.type) {
case 'designData':
case 'loadDesign':
initializedRef.current = true;
dispatch({ type: 'init', payload: message.data });
break;
default:
break;
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [dispatch]);
useEffect(() => {
if (!initializedRef.current) return;
vscode.postMessage({ type: 'designUpdated', data: design });
}, [design, vscode]);
useEffect(() => {
vscode.postMessage({ type: 'getDesignData' });
vscode.postMessage({ type: 'webviewReady' });
}, [vscode]);
}

593
src/webview/style.css Normal file
View File

@ -0,0 +1,593 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
line-height: 1.4;
color: var(--vscode-foreground);
background-color: var(--vscode-editor-background);
overflow: hidden;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background-color: var(--vscode-titleBar-activeBackground);
border-bottom: 1px solid var(--vscode-panel-border);
}
.toolbar h2 {
font-size: 14px;
font-weight: 600;
color: var(--vscode-titleBar-activeForeground);
}
.toolbar-buttons {
display: flex;
gap: 8px;
}
.btn {
padding: 4px 12px;
border: 1px solid var(--vscode-button-border);
border-radius: 3px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover {
background-color: var(--vscode-button-hoverBackground);
}
.btn-primary {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.btn-secondary {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
.btn-success {
background-color: var(--vscode-terminal-ansiGreen);
color: var(--vscode-button-foreground);
}
.btn-info {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
.btn-outline {
background-color: transparent;
color: var(--vscode-button-foreground);
border: 1px solid var(--vscode-button-border);
}
.btn-outline:hover {
background-color: var(--vscode-button-hoverBackground);
}
.btn-outline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-outline:disabled:hover {
background-color: transparent;
}
.toolbar-separator {
width: 1px;
height: 20px;
background-color: var(--vscode-panel-border);
margin: 0 4px;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 250px;
background-color: var(--vscode-sideBar-background);
border-right: 1px solid var(--vscode-sideBar-border);
display: flex;
flex-direction: column;
}
.sidebar h3 {
padding: 12px 16px 8px;
font-size: 12px;
font-weight: 600;
color: var(--vscode-sideBarTitle-foreground);
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.widget-palette {
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: none;
height: 220px;
min-height: 80px;
resize: vertical;
scroll-behavior: smooth;
}
.widget-palette::-webkit-scrollbar {
width: 8px;
}
.widget-palette::-webkit-scrollbar-track {
background: var(--vscode-scrollbarSlider-background);
border-radius: 4px;
}
.widget-palette::-webkit-scrollbar-thumb {
background: var(--vscode-scrollbarSlider-hoverBackground);
border-radius: 4px;
}
.widget-palette::-webkit-scrollbar-thumb:hover {
background: var(--vscode-scrollbarSlider-activeBackground);
}
.widget-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
cursor: grab;
background-color: var(--vscode-list-inactiveSelectionBackground);
border: 1px solid transparent;
transition: all 0.2s;
}
.widget-item:hover {
background-color: var(--vscode-list-hoverBackground);
border-color: var(--vscode-focusBorder);
}
.widget-item:active {
cursor: grabbing;
}
.widget-separator {
display: flex;
align-items: center;
padding: 8px 12px;
margin: 8px 0 4px 0;
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
border-top: 1px solid var(--vscode-sideBar-border);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.widget-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.properties-panel {
flex: none;
padding: 8px;
border-top: 1px solid var(--vscode-sideBar-border);
overflow-y: auto;
height: 260px;
min-height: 100px;
resize: vertical;
scroll-behavior: smooth;
}
.properties-panel::-webkit-scrollbar {
width: 8px;
}
.properties-panel::-webkit-scrollbar-track {
background: var(--vscode-scrollbarSlider-background);
border-radius: 4px;
}
.properties-panel::-webkit-scrollbar-thumb {
background: var(--vscode-scrollbarSlider-hoverBackground);
border-radius: 4px;
}
.properties-panel::-webkit-scrollbar-thumb:hover {
background: var(--vscode-scrollbarSlider-activeBackground);
}
.properties-panel h3 {
border-bottom: none;
margin-bottom: 12px;
}
#propertiesContent {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.events-panel {
background-color: var(--vscode-sideBar-background);
border-top: 1px solid var(--vscode-sideBar-border);
padding: 12px;
overflow-y: auto;
flex: none;
height: 220px;
min-height: 80px;
resize: vertical;
}
.events-panel h3 {
margin: 0 0 12px 0;
font-size: 13px;
font-weight: 600;
color: var(--vscode-sideBarTitle-foreground);
}
#eventsContent {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.event-section {
margin-bottom: 16px;
padding: 8px;
background-color: var(--vscode-input-background);
border-radius: 4px;
border: 1px solid var(--vscode-input-border);
}
.event-section h4 {
margin: 0 0 8px 0;
font-size: 11px;
font-weight: 600;
color: var(--vscode-input-foreground);
}
.event-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.event-type-select {
padding: 4px 8px;
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-size: 11px;
}
.event-handler-input {
padding: 4px 8px;
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-size: 11px;
font-family: var(--vscode-editor-font-family);
}
.event-handler-input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
.event-handler-input[type='textarea'] {
resize: vertical;
min-height: 60px;
font-family: var(--vscode-editor-font-family);
line-height: 1.4;
}
.event-buttons {
display: flex;
gap: 4px;
}
.event-btn {
padding: 4px 8px;
border: 1px solid var(--vscode-button-border);
border-radius: 3px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font-size: 10px;
cursor: pointer;
transition: background-color 0.2s;
}
.event-btn:hover {
background-color: var(--vscode-button-hoverBackground);
}
.event-btn.primary {
background-color: var(--vscode-button-background);
}
.event-btn.secondary {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
.event-btn.danger {
background-color: var(--vscode-button-background);
color: var(--vscode-errorForeground);
}
.event-list {
margin-top: 8px;
}
.event-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
margin-bottom: 4px;
background-color: var(--vscode-list-hoverBackground);
border-radius: 3px;
font-size: 11px;
}
.event-item .event-info {
flex: 1;
}
.event-item .event-name {
font-weight: 500;
color: var(--vscode-input-foreground);
}
.event-item .event-handler {
color: var(--vscode-descriptionForeground);
font-family: var(--vscode-editor-font-family);
}
.event-item .event-code {
color: var(--vscode-descriptionForeground);
font-family: var(--vscode-editor-font-family);
font-size: 10px;
margin-top: 2px;
opacity: 0.8;
}
.event-item .event-actions {
display: flex;
gap: 4px;
}
.event-item .event-actions button {
padding: 2px 6px;
border: none;
border-radius: 2px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font-size: 10px;
cursor: pointer;
}
.event-item .event-actions button:hover {
background-color: var(--vscode-button-hoverBackground);
}
.property-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.property-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-input-foreground);
}
.property-input {
padding: 4px 8px;
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-size: 12px;
}
.property-input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
.design-area {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
}
.canvas-container {
flex: 1;
padding: 16px;
overflow: auto;
}
.design-canvas {
min-height: 600px;
min-width: 800px;
background-color: #f0f0f0;
border: 2px dashed var(--vscode-panel-border);
border-radius: 4px;
position: relative;
background-image:
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0px;
}
.design-canvas.drag-over {
border-color: var(--vscode-focusBorder);
background-color: var(--vscode-list-dropBackground);
}
.canvas-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--vscode-descriptionForeground);
font-size: 14px;
pointer-events: none;
}
.canvas-widget {
position: absolute;
border: 2px solid transparent;
border-radius: 3px;
cursor: move;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.canvas-widget:hover {
border-color: var(--vscode-focusBorder);
}
.canvas-widget.selected {
border-color: var(--vscode-button-background);
box-shadow: 0 0 0 1px var(--vscode-button-background);
}
.canvas-widget.widget-label {
background-color: #f8f9fa;
color: #333;
padding: 4px 8px;
}
.canvas-widget.widget-button {
background-color: #e9ecef;
color: #333;
border: 1px solid #ced4da;
padding: 6px 12px;
border-radius: 4px;
}
.canvas-widget.widget-text {
background-color: white;
border: 1px solid #ced4da;
padding: 8px;
text-align: left;
resize: both;
overflow: auto;
}
.canvas-widget.widget-checkbutton {
background-color: white;
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
}
.canvas-widget.widget-checkbutton::before {
content: '☑️';
font-size: 14px;
}
.canvas-widget.widget-radiobutton {
background-color: white;
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
}
.canvas-widget.widget-radiobutton::before {
content: '🔘';
font-size: 14px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.canvas-widget.newly-added {
animation: fadeIn 0.3s ease-out;
}
@media (max-width: 768px) {
.sidebar {
width: 200px;
}
.widget-item {
padding: 6px 8px;
font-size: 11px;
}
.widget-palette {
height: 180px;
}
.properties-panel {
height: 220px;
}
}
@media (max-height: 600px) {
.widget-palette {
height: 160px;
}
.properties-panel {
height: 200px;
}
}

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"outDir": "out",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"sourceMap": true,
"rootDir": "src",
"strict": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"esModuleInterop": true
},
"exclude": ["node_modules", ".vscode-test"]
}