Initial empty commit
This commit is contained in:
commit
c87e51053c
14
.eslintrc.json
Normal file
14
.eslintrc.json
Normal 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
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.vscode/
|
||||
out/
|
||||
docs/
|
||||
node_modules/
|
||||
README.md
|
||||
examples/
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
out
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
1747
package-lock.json
generated
Normal file
1747
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
package.json
Normal file
68
package.json
Normal 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
26
scripts/build_webview.js
Normal 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();
|
||||
42
scripts/copy_python_files.js
Normal file
42
scripts/copy_python_files.js
Normal 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
142
src/extension.ts
Normal 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() {}
|
||||
116
src/generator/CodeGenerator.ts
Normal file
116
src/generator/CodeGenerator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
74
src/generator/eventHelpers.ts
Normal file
74
src/generator/eventHelpers.ts
Normal 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
28
src/generator/types.ts
Normal 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
49
src/generator/utils.ts
Normal 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;
|
||||
}
|
||||
73
src/generator/widgetHelpers.ts
Normal file
73
src/generator/widgetHelpers.ts
Normal 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
137
src/parser/CodeParser.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
BIN
src/parser/__pycache__/tkinter_ast_parser.cpython-313.pyc
Normal file
BIN
src/parser/__pycache__/tkinter_ast_parser.cpython-313.pyc
Normal file
Binary file not shown.
127
src/parser/astConverter.ts
Normal file
127
src/parser/astConverter.ts
Normal 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;
|
||||
}
|
||||
80
src/parser/pythonRunner.ts
Normal file
80
src/parser/pythonRunner.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
3
src/parser/tk_ast/__init__.py
Normal file
3
src/parser/tk_ast/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .analyzer import TkinterAnalyzer
|
||||
from .grid_layout import GridLayoutAnalyzer
|
||||
from .parser import parse_tkinter_code, parse_file
|
||||
1
src/parser/tk_ast/analyzer/__init__.py
Normal file
1
src/parser/tk_ast/analyzer/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .base import TkinterAnalyzer
|
||||
109
src/parser/tk_ast/analyzer/base.py
Normal file
109
src/parser/tk_ast/analyzer/base.py
Normal 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)
|
||||
44
src/parser/tk_ast/analyzer/calls.py
Normal file
44
src/parser/tk_ast/analyzer/calls.py
Normal 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)
|
||||
93
src/parser/tk_ast/analyzer/connections.py
Normal file
93
src/parser/tk_ast/analyzer/connections.py
Normal 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
|
||||
13
src/parser/tk_ast/analyzer/context.py
Normal file
13
src/parser/tk_ast/analyzer/context.py
Normal 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
|
||||
77
src/parser/tk_ast/analyzer/events.py
Normal file
77
src/parser/tk_ast/analyzer/events.py
Normal 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
|
||||
28
src/parser/tk_ast/analyzer/extractors.py
Normal file
28
src/parser/tk_ast/analyzer/extractors.py
Normal 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'
|
||||
14
src/parser/tk_ast/analyzer/imports.py
Normal file
14
src/parser/tk_ast/analyzer/imports.py
Normal 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
|
||||
39
src/parser/tk_ast/analyzer/placements.py
Normal file
39
src/parser/tk_ast/analyzer/placements.py
Normal 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', ''),
|
||||
}
|
||||
149
src/parser/tk_ast/analyzer/values.py
Normal file
149
src/parser/tk_ast/analyzer/values.py
Normal 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__}>"
|
||||
96
src/parser/tk_ast/analyzer/widget_creation.py
Normal file
96
src/parser/tk_ast/analyzer/widget_creation.py
Normal 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,
|
||||
})
|
||||
55
src/parser/tk_ast/grid_layout.py
Normal file
55
src/parser/tk_ast/grid_layout.py
Normal 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
|
||||
54
src/parser/tk_ast/parser.py
Normal file
54
src/parser/tk_ast/parser.py
Normal 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)
|
||||
21
src/parser/tkinter_ast_parser.py
Normal file
21
src/parser/tkinter_ast_parser.py
Normal 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
21
src/parser/utils.ts
Normal 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;
|
||||
}
|
||||
237
src/webview/TkinterDesignerProvider.ts
Normal file
237
src/webview/TkinterDesignerProvider.ts
Normal 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
13
src/webview/preview.html
Normal 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
27
src/webview/react/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/webview/react/components/Canvas.tsx
Normal file
95
src/webview/react/components/Canvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/webview/react/components/EventsPanel.tsx
Normal file
130
src/webview/react/components/EventsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/webview/react/components/Palette.tsx
Normal file
37
src/webview/react/components/Palette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/webview/react/components/PropertiesPanel.tsx
Normal file
112
src/webview/react/components/PropertiesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/webview/react/components/Toolbar.tsx
Normal file
162
src/webview/react/components/Toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/webview/react/index.tsx
Normal file
25
src/webview/react/index.tsx
Normal 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
242
src/webview/react/state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/webview/react/types.ts
Normal file
32
src/webview/react/types.ts
Normal 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[];
|
||||
}
|
||||
35
src/webview/react/useMessaging.ts
Normal file
35
src/webview/react/useMessaging.ts
Normal 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
593
src/webview/style.css
Normal 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
15
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user