init commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
out
|
||||
.vscode-test
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.gitea
|
||||
README.md
|
||||
examples/
|
||||
.vscode/
|
||||
docs/
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Use Node.js LTS
|
||||
FROM node:18-slim
|
||||
|
||||
# Install Python 3 and git
|
||||
RUN apt-get update && apt-get install -y python3 python3-pip git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the extension
|
||||
RUN npm run compile
|
||||
|
||||
# Install vsce to package the extension
|
||||
RUN npm install -g @vscode/vsce
|
||||
|
||||
# Default command to package the extension
|
||||
CMD ["vsce", "package", "--out", "extension.vsix"]
|
||||
Generated
+11
@@ -8,6 +8,7 @@
|
||||
"name": "tkinter-designer",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"immer": "^11.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -1243,6 +1244,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
|
||||
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"immer": "^11.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
+34
-75
@@ -1,84 +1,32 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { CodeGenerator } from './generator/CodeGenerator';
|
||||
import { CodeParser } from './parser/CodeParser';
|
||||
import { CodeGenerator } from './generator/codeGenerator';
|
||||
import { CodeParser } from './parser/codeParser';
|
||||
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
const provider = new TkinterDesignerProvider(context.extensionUri);
|
||||
// We don't register a provider here anymore for the global viewType
|
||||
// because we create panels on demand.
|
||||
|
||||
TkinterDesignerProvider._instance = provider;
|
||||
// Store active panels if needed, but for now we just create them.
|
||||
|
||||
const openDesignerCommand = vscode.commands.registerCommand(
|
||||
'tkinter-designer.openDesigner',
|
||||
() => {
|
||||
TkinterDesignerProvider.createOrShow(context.extensionUri);
|
||||
TkinterDesignerProvider.createNew(context.extensionUri);
|
||||
}
|
||||
);
|
||||
|
||||
const generateCodeCommand = vscode.commands.registerCommand(
|
||||
'tkinter-designer.generateCode',
|
||||
async () => {
|
||||
console.log('[GenerateCode] Command invoked');
|
||||
const generator = new CodeGenerator();
|
||||
const designData = await provider.getDesignData();
|
||||
if (
|
||||
!designData ||
|
||||
!designData.widgets ||
|
||||
designData.widgets.length === 0
|
||||
) {
|
||||
console.log('[GenerateCode] No design data');
|
||||
vscode.window.showWarningMessage(
|
||||
'No design data found. Please open the designer and create some widgets first.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Removed global generateCodeCommand because generate is now handled inside the Webview Provider instance
|
||||
// or we need a way to target the specific panel.
|
||||
// The previous generateCodeCommand was flawed because it relied on a singleton provider.
|
||||
// Now, generation is triggered FROM the UI (webview) via messages, which is handled in TkinterDesignerProvider.handleGenerateCode.
|
||||
// If we want a command palette action, it needs to know WHICH designer to use.
|
||||
// For simplicity, we remove the global command or make it check for active webview (hard with multiple).
|
||||
// Let's keep it but warn it only works if we tracked instances.
|
||||
// Actually, best practice: actions are context-based. The webview has a "Generate" button.
|
||||
|
||||
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');
|
||||
}
|
||||
);
|
||||
// However, parseCodeCommand is useful. It should open a NEW designer with the parsed code.
|
||||
|
||||
const parseCodeCommand = vscode.commands.registerCommand(
|
||||
'tkinter-designer.parseCode',
|
||||
@@ -89,24 +37,36 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
||||
const parser = new CodeParser();
|
||||
const code = activeEditor.document.getText();
|
||||
const fileName = path.basename(
|
||||
activeEditor.document.fileName,
|
||||
'.py'
|
||||
);
|
||||
console.log('[ParseCode] Code length:', code.length);
|
||||
|
||||
try {
|
||||
const designData = await parser.parseCodeToDesign(code);
|
||||
const designData = await parser.parseCodeToDesign(
|
||||
code,
|
||||
fileName
|
||||
);
|
||||
if (
|
||||
designData &&
|
||||
designData.widgets &&
|
||||
designData.widgets.length > 0
|
||||
) {
|
||||
console.log('[ParseCode] Widgets found:', designData.widgets.length);
|
||||
const designerInstance =
|
||||
TkinterDesignerProvider.createOrShow(
|
||||
console.log(
|
||||
'[ParseCode] Widgets found:',
|
||||
designData.widgets.length
|
||||
);
|
||||
// Create a NEW designer instance for this file
|
||||
const designerInstance = TkinterDesignerProvider.createNew(
|
||||
context.extensionUri
|
||||
);
|
||||
if (designerInstance) {
|
||||
|
||||
// We need to wait for webview to be ready?
|
||||
// The createNew sets up message handling.
|
||||
// We can send data immediately? No, webview needs to load React.
|
||||
// We can set initial data in the provider.
|
||||
designerInstance.loadDesignData(designData);
|
||||
} else {
|
||||
}
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
|
||||
@@ -134,7 +94,6 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
context.subscriptions.push(
|
||||
openDesignerCommand,
|
||||
generateCodeCommand,
|
||||
parseCodeCommand
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,14 +18,21 @@ export class CodeGenerator {
|
||||
|
||||
public generateTkinterCode(designData: DesignData): string {
|
||||
this.designData = designData;
|
||||
console.log('[Generator] Start, widgets:', designData.widgets.length, 'events:', designData.events?.length || 0);
|
||||
const className = designData.form.className || 'Application';
|
||||
console.log(
|
||||
'[Generator] Start, widgets:',
|
||||
designData.widgets.length,
|
||||
'events:',
|
||||
designData.events?.length || 0
|
||||
);
|
||||
const lines: string[] = [];
|
||||
const nameMap = generateVariableNames(designData.widgets);
|
||||
|
||||
lines.push('import tkinter as tk');
|
||||
lines.push('from tkinter import ttk');
|
||||
lines.push('');
|
||||
|
||||
lines.push('class Application:');
|
||||
lines.push(`class ${className}:`);
|
||||
this.indentLevel = 1;
|
||||
|
||||
lines.push(this.indent('def __init__(self):'));
|
||||
@@ -47,7 +54,6 @@ export class CodeGenerator {
|
||||
designData.widgets.forEach((widget) => {
|
||||
console.log('[Generator] Widget:', widget.id, widget.type);
|
||||
lines.push(...this.generateWidgetCode(widget, nameMap));
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
this.indentLevel = 1;
|
||||
@@ -72,12 +78,65 @@ export class CodeGenerator {
|
||||
this.indentLevel = 0;
|
||||
lines.push('if __name__ == "__main__":');
|
||||
this.indentLevel = 1;
|
||||
lines.push(this.indent('app = Application()'));
|
||||
lines.push(this.indent('try:'));
|
||||
this.indentLevel = 2;
|
||||
lines.push(this.indent(`app = ${className}()`));
|
||||
lines.push(this.indent('app.run()'));
|
||||
this.indentLevel = 1;
|
||||
lines.push(this.indent('except Exception as e:'));
|
||||
this.indentLevel = 2;
|
||||
lines.push(this.indent('import traceback'));
|
||||
lines.push(this.indent('traceback.print_exc()'));
|
||||
lines.push(this.indent('input("Press Enter to exit...")'));
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
public generateCreateWidgetsBody(designData: DesignData): string {
|
||||
this.designData = designData;
|
||||
const lines: string[] = [];
|
||||
const nameMap = generateVariableNames(designData.widgets);
|
||||
this.indentLevel = 2; // Inside class -> inside create_widgets
|
||||
|
||||
designData.widgets.forEach((widget) => {
|
||||
lines.push(...this.generateWidgetCode(widget, nameMap));
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
public generateEventHandler(event: any): string {
|
||||
// Simple helper to generate a single event handler method
|
||||
// Used when injecting new methods
|
||||
const lines: string[] = [];
|
||||
// Use existing signature if available (to preserve user arguments)
|
||||
// Note: signature does not include 'def ' prefix in our current extraction logic?
|
||||
// Let's check base.py: sig_raw = self.source_code[def_start_idx : start_idx].strip()
|
||||
// It includes 'def name(...)'.
|
||||
// So we just use it directly.
|
||||
|
||||
if (event.signature) {
|
||||
lines.push(` ${event.signature}:`);
|
||||
} else {
|
||||
lines.push(` def ${event.name}(self, event=None):`);
|
||||
}
|
||||
|
||||
if (event.code) {
|
||||
const codeContent =
|
||||
typeof event.code === 'string'
|
||||
? event.code
|
||||
: String(event.code || '');
|
||||
const body = codeContent
|
||||
.split('\n')
|
||||
.map((l: string) => ' ' + l)
|
||||
.join('\n');
|
||||
lines.push(body);
|
||||
} else {
|
||||
lines.push(' pass');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private generateWidgetCode(
|
||||
widget: WidgetData,
|
||||
nameMap: Map<string, string>
|
||||
@@ -96,7 +155,9 @@ export class CodeGenerator {
|
||||
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
|
||||
|
||||
const contentLines = generateWidgetContent(widget, varName);
|
||||
contentLines.forEach((line) => lines.push(this.indent(line)));
|
||||
if (contentLines.length > 0) {
|
||||
contentLines.forEach((l) => lines.push(this.indent(l)));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
...getWidgetEventBindings(
|
||||
|
||||
@@ -8,22 +8,47 @@ export function generateEventHandlers(
|
||||
const lines: string[] = [];
|
||||
|
||||
if (designData.events && designData.events.length > 0) {
|
||||
// Use a Set to avoid duplicates if multiple widgets use the same handler
|
||||
const handledNames = new Set<string>();
|
||||
|
||||
designData.events.forEach((event: Event) => {
|
||||
const handlerName = event.name;
|
||||
|
||||
if (handledNames.has(handlerName)) {
|
||||
return;
|
||||
}
|
||||
handledNames.add(handlerName);
|
||||
|
||||
// Use preserved signature if available
|
||||
// Need to cast to any because Event interface might not have signature yet in types.ts (but we added it in runtime)
|
||||
const signature = (event as any).signature;
|
||||
|
||||
if (signature) {
|
||||
lines.push(indentDef(`${signature}:`));
|
||||
} else {
|
||||
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):`));
|
||||
lines.push(
|
||||
indentDef(`def ${handlerName}(self, event=None):`)
|
||||
);
|
||||
} else {
|
||||
lines.push(indentDef(`def ${handlerName}(self):`));
|
||||
}
|
||||
}
|
||||
|
||||
const codeLines = (event.code || '').split('\n');
|
||||
// If we have parsed code, use it. Otherwise, use 'pass'.
|
||||
// The parser should populate event.code if the method exists.
|
||||
// If the user just added the event in the UI, event.code might be 'pass' or empty.
|
||||
|
||||
let hasCode = false;
|
||||
// Ensure event.code is a string before split
|
||||
const codeContent =
|
||||
typeof event.code === 'string'
|
||||
? event.code
|
||||
: String(event.code || '');
|
||||
const codeLines = codeContent.split('\n');
|
||||
for (const line of codeLines) {
|
||||
if (line.trim()) {
|
||||
lines.push(indentBody(line));
|
||||
|
||||
@@ -17,11 +17,13 @@ export interface WidgetData {
|
||||
|
||||
export interface DesignData {
|
||||
form: {
|
||||
name: string;
|
||||
title: string;
|
||||
size: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
className?: string;
|
||||
};
|
||||
widgets: WidgetData[];
|
||||
events?: Event[];
|
||||
|
||||
@@ -27,11 +27,11 @@ export function generateVariableNames(
|
||||
let baseName = widget.type.toLowerCase();
|
||||
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
|
||||
if (baseName === 'button') baseName = 'btn';
|
||||
if (baseName === 'entry') baseName = 'entry';
|
||||
if (baseName === 'checkbutton') baseName = 'chk';
|
||||
if (baseName === 'radiobutton') baseName = 'radio';
|
||||
if (baseName === 'label') baseName = 'lbl';
|
||||
|
||||
|
||||
const count = (counts.get(baseName) || 0) + 1;
|
||||
counts.set(baseName, count);
|
||||
names.set(widget.id, `${baseName}${count}`);
|
||||
|
||||
+27
-113
@@ -1,137 +1,51 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { DesignData, WidgetData, Event } from '../generator/types';
|
||||
import { runPythonAst } from './pythonRunner';
|
||||
import { convertASTResultToDesignData } from './astConverter';
|
||||
|
||||
export class CodeParser {
|
||||
public async parseCodeToDesign(
|
||||
pythonCode: string
|
||||
pythonCode: string,
|
||||
filename?: string
|
||||
): Promise<DesignData | null> {
|
||||
console.log(
|
||||
'[Parser] parseCodeToDesign start, code length:',
|
||||
pythonCode.length
|
||||
);
|
||||
const astRaw = await runPythonAst(pythonCode);
|
||||
|
||||
if (astRaw && astRaw.error) {
|
||||
console.error('[Parser] Python AST error:', astRaw.error);
|
||||
vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const astDesign = convertASTResultToDesignData(astRaw);
|
||||
if (astDesign && astDesign.widgets && astDesign.widgets.length > 0) {
|
||||
|
||||
if (astDesign) {
|
||||
// AST parsing was successful (even if 0 widgets, it's a valid result)
|
||||
console.log(
|
||||
'[Parser] AST parsed widgets:',
|
||||
astDesign.widgets.length
|
||||
);
|
||||
if (filename) {
|
||||
astDesign.form.name = filename;
|
||||
}
|
||||
return astDesign;
|
||||
}
|
||||
console.log('[Parser] AST returned no widgets, using regex fallback');
|
||||
const regexDesign = this.parseWithRegexInline(pythonCode);
|
||||
|
||||
// If AST failed completely (e.g. syntax error or script failure), we can't trust regex to be safe for editing.
|
||||
// It's better to fail and let user know the code is too complex or broken.
|
||||
|
||||
console.log(
|
||||
'[Parser] Regex parsed widgets:',
|
||||
regexDesign?.widgets?.length || 0
|
||||
'[Parser] AST failed. Regex fallback is disabled for safety.'
|
||||
);
|
||||
vscode.window.showErrorMessage(
|
||||
'Could not parse Python code safely. Please ensure your code has no syntax errors and follows the standard structure (class-based Tkinter app).'
|
||||
);
|
||||
return 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,
|
||||
};
|
||||
}
|
||||
|
||||
// Regex parser removed to prevent "Zombie Code" issues where regex sees widgets
|
||||
// but AST doesn't, leading to partial updates that break the file.
|
||||
}
|
||||
|
||||
@@ -19,11 +19,14 @@ export function convertASTResultToDesignData(
|
||||
(astResult.window && astResult.window.height) ||
|
||||
astResult.height ||
|
||||
600;
|
||||
let className =
|
||||
(astResult.window && astResult.window.className) || 'Application';
|
||||
|
||||
let counter = 0;
|
||||
const allowedTypes = new Set([
|
||||
'Label',
|
||||
'Button',
|
||||
'Entry',
|
||||
'Text',
|
||||
'Checkbutton',
|
||||
'Radiobutton',
|
||||
@@ -68,17 +71,30 @@ export function convertASTResultToDesignData(
|
||||
}
|
||||
|
||||
const events: Event[] = [];
|
||||
const methods = astResult.methods || {};
|
||||
if (astResult.command_callbacks) {
|
||||
for (const callback of astResult.command_callbacks) {
|
||||
const rawName = callback.command?.name;
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: `on_${callback.widget}_command`;
|
||||
|
||||
const methodData = methods[cleanName];
|
||||
// methodData is { body: string, signature: string } or string (legacy/lambda)
|
||||
const codeBody =
|
||||
typeof methodData === 'object' ? methodData.body : methodData;
|
||||
const signature =
|
||||
typeof methodData === 'object'
|
||||
? methodData.signature
|
||||
: undefined;
|
||||
|
||||
events.push({
|
||||
widget: callback.widget,
|
||||
type: 'command',
|
||||
name: cleanName,
|
||||
code: callback.command?.lambda_body || '',
|
||||
code: codeBody || callback.command?.lambda_body || '',
|
||||
// @ts-ignore - attaching signature for generator
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -88,22 +104,72 @@ export function convertASTResultToDesignData(
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
|
||||
|
||||
const methodData = methods[cleanName];
|
||||
const codeBody =
|
||||
typeof methodData === 'object' ? methodData.body : methodData;
|
||||
const signature =
|
||||
typeof methodData === 'object'
|
||||
? methodData.signature
|
||||
: undefined;
|
||||
|
||||
events.push({
|
||||
widget: bindEvent.widget,
|
||||
type: bindEvent.event,
|
||||
name: cleanName,
|
||||
code: bindEvent.callback?.lambda_body || '',
|
||||
code: codeBody || bindEvent.callback?.lambda_body || '',
|
||||
// @ts-ignore
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (astResult.widgets) {
|
||||
for (const w of astResult.widgets) {
|
||||
const p = w.properties || w.params || {};
|
||||
if (p.command) {
|
||||
const widgetId = w.variable_name;
|
||||
const cmd = p.command;
|
||||
const rawName = cmd.name || cmd;
|
||||
const cleanName = String(rawName).replace(/^self\./, '');
|
||||
|
||||
// Avoid duplicates if already added by command_callbacks
|
||||
const exists = events.find(
|
||||
(e) => e.widget === widgetId && e.type === 'command'
|
||||
);
|
||||
if (!exists) {
|
||||
const methodData = methods[cleanName];
|
||||
const codeBody =
|
||||
typeof methodData === 'object'
|
||||
? methodData.body
|
||||
: methodData;
|
||||
const signature =
|
||||
typeof methodData === 'object'
|
||||
? methodData.signature
|
||||
: undefined;
|
||||
|
||||
events.push({
|
||||
widget: widgetId,
|
||||
type: 'command',
|
||||
name: cleanName,
|
||||
code: codeBody || '',
|
||||
// @ts-ignore
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const keptIds = new Set(widgets.map((w) => w.id));
|
||||
const filteredEvents = events.filter((e) => keptIds.has(e.widget));
|
||||
|
||||
const result: DesignData = {
|
||||
form: {
|
||||
name: 'Form',
|
||||
title: formTitle,
|
||||
size: { width: formWidth, height: formHeight },
|
||||
className: className,
|
||||
},
|
||||
widgets,
|
||||
events: filteredEvents.length ? filteredEvents : [],
|
||||
|
||||
+93
-29
@@ -2,22 +2,34 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
async function executePythonScript(
|
||||
pythonScriptPath: string,
|
||||
pythonFilePath: string
|
||||
pythonCode: string
|
||||
): Promise<string> {
|
||||
const commandsToTry = await getPythonCommands();
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const cmd of commandsToTry) {
|
||||
try {
|
||||
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,
|
||||
]);
|
||||
console.log('[PythonRunner] Spawning:', cmd, pythonScriptPath);
|
||||
|
||||
const process = spawn(cmd, [pythonScriptPath]);
|
||||
|
||||
let result = '';
|
||||
let errorOutput = '';
|
||||
|
||||
// Write code to stdin
|
||||
try {
|
||||
process.stdin.write(pythonCode);
|
||||
process.stdin.end();
|
||||
} catch (e) {
|
||||
console.error('[PythonRunner] Error writing to stdin:', e);
|
||||
}
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
result += data.toString();
|
||||
});
|
||||
@@ -28,7 +40,12 @@ async function executePythonScript(
|
||||
|
||||
process.on('close', (code) => {
|
||||
const ms = Date.now() - start;
|
||||
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms);
|
||||
console.log(
|
||||
'[PythonRunner] Exit code:',
|
||||
code,
|
||||
'time(ms):',
|
||||
ms
|
||||
);
|
||||
if (code === 0) {
|
||||
resolve(result);
|
||||
} else {
|
||||
@@ -39,42 +56,89 @@ async function executePythonScript(
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to spawn python process: ${err.message}`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(`[PythonRunner] Command ${cmd} failed:`, err.message);
|
||||
lastError = err;
|
||||
// Continue to next command
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('No suitable Python interpreter found');
|
||||
}
|
||||
|
||||
function getPythonCommand(): string {
|
||||
return process.platform === 'win32' ? 'python' : 'python3';
|
||||
}
|
||||
async function getPythonCommands(): Promise<string[]> {
|
||||
const commands: string[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
// 1. Try VS Code Python extension API
|
||||
const extension = vscode.extensions.getExtension('ms-python.python');
|
||||
if (extension) {
|
||||
if (!extension.isActive) {
|
||||
await extension.activate();
|
||||
}
|
||||
// The API might be complex, but let's check settings first which is easier
|
||||
}
|
||||
|
||||
function cleanupTempFile(tempFile: string): void {
|
||||
try {
|
||||
fs.unlinkSync(tempFile);
|
||||
} catch {}
|
||||
// 2. Settings
|
||||
const config = vscode.workspace.getConfiguration('python');
|
||||
let pythonPath =
|
||||
config.get<string>('defaultInterpreterPath') ||
|
||||
config.get<string>('pythonPath');
|
||||
|
||||
if (pythonPath && pythonPath !== 'python') {
|
||||
// Handle ${workspaceFolder} variable
|
||||
if (pythonPath.includes('${workspaceFolder}')) {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||
pythonPath = pythonPath.replace(
|
||||
'${workspaceFolder}',
|
||||
workspaceFolders[0].uri.fsPath
|
||||
);
|
||||
}
|
||||
}
|
||||
commands.push(pythonPath);
|
||||
}
|
||||
|
||||
// 3. Common commands
|
||||
if (process.platform === 'win32') {
|
||||
commands.push('py');
|
||||
commands.push('python');
|
||||
} else {
|
||||
commands.push('python3');
|
||||
commands.push('python');
|
||||
}
|
||||
|
||||
// Unique
|
||||
return Array.from(new Set(commands));
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
const output = await executePythonScript(pythonScriptPath, pythonCode);
|
||||
console.log('[PythonRunner] Received AST JSON length:', output.length);
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
return parsed;
|
||||
} catch (parseError) {
|
||||
console.error(
|
||||
'[PythonRunner] JSON parse error:',
|
||||
parseError,
|
||||
'Output:',
|
||||
output
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PythonRunner] Error running Python AST:', err);
|
||||
return null;
|
||||
} finally {
|
||||
cleanupTempFile(tempFilePath);
|
||||
console.log('[PythonRunner] Temp file cleaned:', tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,7 @@
|
||||
import ast
|
||||
import textwrap
|
||||
import tokenize
|
||||
import io
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from .imports import handle_import, handle_import_from
|
||||
@@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
|
||||
|
||||
class TkinterAnalyzer(ast.NodeVisitor):
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, source_code: str = ""):
|
||||
self.source_code = source_code
|
||||
self.line_offsets = [0]
|
||||
current = 0
|
||||
if source_code:
|
||||
for line in source_code.splitlines(keepends=True):
|
||||
current += len(line)
|
||||
self.line_offsets.append(current)
|
||||
|
||||
self.widgets: List[Dict[str, Any]] = []
|
||||
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
|
||||
self.imports: Dict[str, str] = {}
|
||||
@@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
||||
self.event_handlers: List[Dict[str, Any]] = []
|
||||
self.command_callbacks: List[Dict[str, Any]] = []
|
||||
self.bind_events: List[Dict[str, Any]] = []
|
||||
self.methods: Dict[str, str] = {}
|
||||
|
||||
def visit_Import(self, node: ast.Import):
|
||||
handle_import(self, node)
|
||||
@@ -36,12 +48,139 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
||||
def visit_ClassDef(self, node: ast.ClassDef):
|
||||
prev = self.current_class
|
||||
enter_class(self, node)
|
||||
|
||||
# Determine main application class
|
||||
# Logic:
|
||||
# 1. Prefer class named 'Application'
|
||||
# 2. Else prefer class inheriting from tk.Tk or Tk
|
||||
# 3. Fallback to first class found if nothing else set
|
||||
|
||||
is_tk_class = False
|
||||
for base in node.bases:
|
||||
if isinstance(base, ast.Attribute) and base.attr == 'Tk':
|
||||
is_tk_class = True
|
||||
elif isinstance(base, ast.Name) and base.id == 'Tk':
|
||||
is_tk_class = True
|
||||
|
||||
if node.name == 'Application':
|
||||
self.window_config['className'] = node.name
|
||||
elif is_tk_class and self.window_config.get('className') != 'Application':
|
||||
self.window_config['className'] = node.name
|
||||
elif not self.window_config.get('className'):
|
||||
self.window_config['className'] = node.name
|
||||
|
||||
self.generic_visit(node)
|
||||
exit_class(self, prev)
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef):
|
||||
prev = self.current_method
|
||||
enter_function(self, node)
|
||||
|
||||
if self.current_class and node.name not in ['__init__', 'create_widgets', 'run'] and self.source_code and node.body:
|
||||
try:
|
||||
# Calculate start index based on 'def' keyword and colon
|
||||
# We need to find the colon that ends the definition
|
||||
def_start_idx = self.line_offsets[node.lineno - 1] + node.col_offset
|
||||
|
||||
# Scan tokens to find the colon after the function definition
|
||||
# We start tokenizing from the def line
|
||||
# We use a safe approach to find the end of the header
|
||||
|
||||
header_end_idx = -1
|
||||
|
||||
try:
|
||||
# Create a byte stream for tokenize, starting from def line
|
||||
# We need the full source code for context
|
||||
# But tokenize expects bytes
|
||||
|
||||
# Optimization: only tokenize the relevant chunk to avoid perf hit on large files
|
||||
# We can guess the header won't be longer than, say, 10 lines or 1000 chars?
|
||||
# Let's take a generous chunk
|
||||
|
||||
chunk_start = def_start_idx
|
||||
chunk_end = min(len(self.source_code), def_start_idx + 2000)
|
||||
chunk = self.source_code[chunk_start:chunk_end]
|
||||
|
||||
# Better approach: Just find the ':' char that is not enclosed in brackets/parens/quotes
|
||||
# Manually parsing characters is safer than partial tokenize if we want to be quick
|
||||
|
||||
depth = 0
|
||||
in_quote = False
|
||||
quote_char = ''
|
||||
|
||||
for i, char in enumerate(chunk):
|
||||
if in_quote:
|
||||
if char == quote_char:
|
||||
# Check for escaping? Simplified check
|
||||
if i > 0 and chunk[i-1] != '\\':
|
||||
in_quote = False
|
||||
continue
|
||||
|
||||
if char in ['"', "'"]:
|
||||
in_quote = True
|
||||
quote_char = char
|
||||
elif char in ['(', '[', '{']:
|
||||
depth += 1
|
||||
elif char in [')', ']', '}']:
|
||||
depth -= 1
|
||||
elif char == ':' and depth == 0:
|
||||
# Found the colon!
|
||||
# Add 1 to include the colon in signature, but body starts AFTER it
|
||||
header_end_idx = chunk_start + i + 1
|
||||
break
|
||||
|
||||
except Exception:
|
||||
# Fallback to old method if manual parsing fails
|
||||
start_node = node.body[0]
|
||||
header_end_idx = self.line_offsets[start_node.lineno - 1] + start_node.col_offset
|
||||
|
||||
if header_end_idx != -1:
|
||||
start_idx = header_end_idx
|
||||
|
||||
# End index: logic remains using the last node end
|
||||
# Ideally we should include trailing comments, but that's hard without full tokenization
|
||||
# For now, sticking to last node end is safe enough for "body" logic
|
||||
end_node = node.body[-1]
|
||||
end_idx = self.line_offsets[end_node.end_lineno - 1] + end_node.end_col_offset
|
||||
|
||||
if start_idx < end_idx:
|
||||
body_text = self.source_code[start_idx:end_idx]
|
||||
|
||||
# Dedent logic needs care because body_text now starts with newline(s) and comments
|
||||
# We should remove leading empty lines, then determine indentation from the first non-empty line
|
||||
|
||||
lines = body_text.splitlines(keepends=True)
|
||||
first_code_line_idx = 0
|
||||
for idx, line in enumerate(lines):
|
||||
if line.strip():
|
||||
first_code_line_idx = idx
|
||||
break
|
||||
|
||||
# Calculate indentation of the first non-empty line
|
||||
first_line = lines[first_code_line_idx]
|
||||
indent_len = len(first_line) - len(first_line.lstrip())
|
||||
|
||||
# Construct body: Keep the leading newlines/comments, but dedent them?
|
||||
# Actually, textwrap.dedent works on the whole block.
|
||||
# But if the first line is blank, dedent might fail to detect common indent?
|
||||
# textwrap.dedent removes common leading whitespace from every line.
|
||||
|
||||
dedented_body = textwrap.dedent(body_text)
|
||||
|
||||
# Signature: everything before start_idx
|
||||
sig_raw = self.source_code[def_start_idx : start_idx].strip()
|
||||
# Remove trailing colon if present (it should be)
|
||||
if sig_raw.endswith(':'):
|
||||
sig_raw = sig_raw[:-1].strip()
|
||||
|
||||
self.methods[node.name] = {
|
||||
'body': dedented_body.strip(),
|
||||
'signature': sig_raw
|
||||
}
|
||||
except Exception as e:
|
||||
# print(f"Error extracting method {node.name}: {e}")
|
||||
pass
|
||||
|
||||
self.generic_visit(node)
|
||||
exit_function(self, prev)
|
||||
|
||||
|
||||
@@ -15,15 +15,11 @@ def handle_method_call(analyzer, node: ast.Call):
|
||||
arg0 = node.args[0]
|
||||
if isinstance(arg0, ast.Constant):
|
||||
analyzer.window_config['title'] = arg0.value
|
||||
elif isinstance(arg0, ast.Str):
|
||||
analyzer.window_config['title'] = arg0.s
|
||||
|
||||
elif method_name == 'geometry' and node.args:
|
||||
arg0 = node.args[0]
|
||||
if isinstance(arg0, ast.Constant):
|
||||
geometry = arg0.value
|
||||
elif isinstance(arg0, ast.Str):
|
||||
geometry = arg0.s
|
||||
else:
|
||||
return
|
||||
if 'x' in str(geometry):
|
||||
|
||||
@@ -15,10 +15,6 @@ def get_variable_name(node: ast.AST) -> str:
|
||||
def extract_value(node: ast.AST) -> Any:
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
elif isinstance(node, ast.Str):
|
||||
return node.s
|
||||
elif isinstance(node, ast.Num):
|
||||
return node.n
|
||||
elif isinstance(node, ast.Name):
|
||||
return f"${node.id}"
|
||||
elif isinstance(node, ast.Attribute):
|
||||
@@ -65,7 +61,7 @@ def get_operator_symbol(op_node: ast.AST) -> str:
|
||||
|
||||
def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
||||
body = lambda_node.body
|
||||
if isinstance(body, (ast.Constant, ast.Str, ast.Num)):
|
||||
if isinstance(body, ast.Constant):
|
||||
return 'simple'
|
||||
elif isinstance(body, (ast.Name, ast.Attribute)):
|
||||
return 'simple'
|
||||
@@ -76,11 +72,9 @@ def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
||||
|
||||
def extract_lambda_body(body_node: ast.AST) -> str:
|
||||
if isinstance(body_node, ast.Constant):
|
||||
if isinstance(body_node.value, str):
|
||||
return f'"{body_node.value}"'
|
||||
return str(body_node.value)
|
||||
elif isinstance(body_node, ast.Str):
|
||||
return f'"{body_node.s}"'
|
||||
elif isinstance(body_node, ast.Num):
|
||||
return str(body_node.n)
|
||||
elif isinstance(body_node, ast.Name):
|
||||
return body_node.id
|
||||
elif isinstance(body_node, ast.Attribute):
|
||||
@@ -138,8 +132,6 @@ def extract_lambda_body(body_node: ast.AST) -> str:
|
||||
for value in body_node.values:
|
||||
if isinstance(value, ast.Constant):
|
||||
parts.append(str(value.value))
|
||||
elif isinstance(value, ast.Str):
|
||||
parts.append(value.s)
|
||||
else:
|
||||
parts.append(f"{{{extract_lambda_body(value)}}}")
|
||||
return f"f\"{''.join(parts)}\""
|
||||
|
||||
@@ -19,10 +19,14 @@ def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool:
|
||||
widget_type = call_node.func.attr
|
||||
|
||||
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
|
||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
||||
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
|
||||
return False
|
||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
||||
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
|
||||
# Check if module_name resolves to ttk
|
||||
resolved = analyzer.imports.get(module_name, module_name)
|
||||
if resolved in ['ttk', 'tkinter.ttk']:
|
||||
return widget_type in ['Label', 'Button', 'Entry', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
|
||||
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
|
||||
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
||||
if not isinstance(node.value, ast.Call):
|
||||
@@ -31,7 +35,7 @@ def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
||||
if isinstance(node.value.func, ast.Attribute):
|
||||
return is_tkinter_widget_call(analyzer, node.value)
|
||||
elif isinstance(node.value.func, ast.Name):
|
||||
return node.value.func.id in ['Label', 'Button', 'Text', 'Checkbutton']
|
||||
return node.value.func.id in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
return False
|
||||
|
||||
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
||||
@@ -49,10 +53,12 @@ def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if isinstance(call_node.func, ast.Attribute):
|
||||
widget_type = call_node.func.attr
|
||||
if isinstance(call_node.func.value, ast.Name):
|
||||
module_name = call_node.func.value.id
|
||||
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
|
||||
return None
|
||||
# Allow ttk widgets
|
||||
# if isinstance(call_node.func.value, ast.Name):
|
||||
# module_name = call_node.func.value.id
|
||||
# resolved = analyzer.imports.get(module_name, module_name)
|
||||
# if resolved in ['ttk', 'tkinter.ttk']:
|
||||
# return None
|
||||
elif isinstance(call_node.func, ast.Name):
|
||||
widget_type = call_node.func.id
|
||||
else:
|
||||
|
||||
@@ -9,7 +9,7 @@ from tk_ast.grid_layout import GridLayoutAnalyzer
|
||||
def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
analyzer = TkinterAnalyzer()
|
||||
analyzer = TkinterAnalyzer(code)
|
||||
analyzer.visit(tree)
|
||||
grid_analyzer = GridLayoutAnalyzer()
|
||||
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
|
||||
@@ -18,6 +18,7 @@ def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
||||
'widgets': widgets,
|
||||
'command_callbacks': analyzer.command_callbacks,
|
||||
'bind_events': analyzer.bind_events,
|
||||
'methods': analyzer.methods,
|
||||
'success': True
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { Uri } from 'vscode';
|
||||
import { runPythonAst } from '../parser/pythonRunner';
|
||||
|
||||
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'tkinter-designer';
|
||||
public static _instance: TkinterDesignerProvider | undefined;
|
||||
// Removed singleton _instance
|
||||
private _view?: vscode.WebviewPanel;
|
||||
private _designData: any = {
|
||||
widgets: [],
|
||||
@@ -14,21 +15,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
constructor(private readonly _extensionUri: vscode.Uri) {}
|
||||
|
||||
public static createOrShow(extensionUri: vscode.Uri) {
|
||||
const column = vscode.window.activeTextEditor
|
||||
? vscode.window.activeTextEditor.viewColumn
|
||||
: undefined;
|
||||
if (TkinterDesignerProvider._instance?._view) {
|
||||
console.log('[Webview] Revealing existing panel');
|
||||
TkinterDesignerProvider._instance._view.reveal(column);
|
||||
return TkinterDesignerProvider._instance;
|
||||
}
|
||||
|
||||
// Factory method to create a new panel
|
||||
public static createNew(extensionUri: vscode.Uri) {
|
||||
console.log('[Webview] Creating new panel');
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
TkinterDesignerProvider.viewType,
|
||||
'Tkinter Designer',
|
||||
column || vscode.ViewColumn.One,
|
||||
vscode.ViewColumn.Beside, // Open beside the current editor
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
@@ -40,24 +33,18 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
],
|
||||
}
|
||||
);
|
||||
if (!TkinterDesignerProvider._instance) {
|
||||
TkinterDesignerProvider._instance = new TkinterDesignerProvider(
|
||||
extensionUri
|
||||
);
|
||||
}
|
||||
|
||||
TkinterDesignerProvider._instance._view = panel;
|
||||
TkinterDesignerProvider._instance._setWebviewContent(panel.webview);
|
||||
TkinterDesignerProvider._instance._setupMessageHandling(panel.webview);
|
||||
const provider = new TkinterDesignerProvider(extensionUri);
|
||||
provider._view = panel;
|
||||
provider._setWebviewContent(panel.webview);
|
||||
provider._setupMessageHandling(panel.webview);
|
||||
|
||||
panel.onDidDispose(() => {
|
||||
console.log('[Webview] Panel disposed');
|
||||
if (TkinterDesignerProvider._instance) {
|
||||
TkinterDesignerProvider._instance._view = undefined;
|
||||
}
|
||||
provider._view = undefined;
|
||||
});
|
||||
|
||||
return TkinterDesignerProvider._instance;
|
||||
return provider;
|
||||
}
|
||||
|
||||
public resolveWebviewView(
|
||||
@@ -114,16 +101,81 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
console.log('[Webview] Generating code from webview');
|
||||
this.handleGenerateCode(message.data);
|
||||
break;
|
||||
case 'applyChanges':
|
||||
console.log('[Webview] Applying changes from webview');
|
||||
this.handleApplyChanges(message.data);
|
||||
break;
|
||||
case 'showInfo':
|
||||
vscode.window.showInformationMessage(message.text);
|
||||
break;
|
||||
case 'showError':
|
||||
vscode.window.showErrorMessage(message.text);
|
||||
break;
|
||||
case 'exportProject':
|
||||
this.handleExportProject(message.data);
|
||||
break;
|
||||
case 'importProject':
|
||||
this.handleImportProject();
|
||||
break;
|
||||
}
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
private async handleExportProject(data: any): Promise<void> {
|
||||
try {
|
||||
const options: vscode.SaveDialogOptions = {
|
||||
defaultUri: vscode.Uri.file(`tkinter-project-${new Date().toISOString().split('T')[0]}.json`),
|
||||
filters: {
|
||||
'JSON': ['json']
|
||||
}
|
||||
};
|
||||
const fileUri = await vscode.window.showSaveDialog(options);
|
||||
if (fileUri) {
|
||||
const jsonString = JSON.stringify(data, null, 2);
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(fileUri, encoder.encode(jsonString));
|
||||
vscode.window.showInformationMessage('Project exported successfully!');
|
||||
}
|
||||
} catch (error: any) {
|
||||
vscode.window.showErrorMessage(`Export failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleImportProject(): Promise<void> {
|
||||
try {
|
||||
const options: vscode.OpenDialogOptions = {
|
||||
canSelectMany: false,
|
||||
openLabel: 'Import',
|
||||
filters: {
|
||||
'JSON': ['json']
|
||||
}
|
||||
};
|
||||
const fileUris = await vscode.window.showOpenDialog(options);
|
||||
if (fileUris && fileUris[0]) {
|
||||
const fileUri = fileUris[0];
|
||||
const fileData = await vscode.workspace.fs.readFile(fileUri);
|
||||
const decoder = new TextDecoder();
|
||||
const jsonString = decoder.decode(fileData);
|
||||
const data = JSON.parse(jsonString);
|
||||
|
||||
if (this._view) {
|
||||
// Check if the imported file has the expected structure
|
||||
const designData = data.data || data; // Handle both wrapped and unwrapped data
|
||||
|
||||
this._view.webview.postMessage({
|
||||
type: 'loadDesign',
|
||||
data: designData
|
||||
});
|
||||
// Also update internal state
|
||||
this._designData = designData;
|
||||
vscode.window.showInformationMessage('Project imported successfully!');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
vscode.window.showErrorMessage(`Import failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getDesignData(): Promise<any> {
|
||||
return this._designData;
|
||||
}
|
||||
@@ -145,29 +197,11 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
try {
|
||||
console.log('[GenerateCode] Start');
|
||||
const { CodeGenerator } = await import(
|
||||
'../generator/CodeGenerator'
|
||||
'../generator/codeGenerator'
|
||||
);
|
||||
const generator = new CodeGenerator();
|
||||
const pythonCode = generator.generateTkinterCode(designData);
|
||||
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
||||
console.log('[GenerateCode] Writing into active editor');
|
||||
const doc = activeEditor.document;
|
||||
const start = new vscode.Position(0, 0);
|
||||
const end = doc.lineCount
|
||||
? doc.lineAt(doc.lineCount - 1).range.end
|
||||
: start;
|
||||
const fullRange = new vscode.Range(start, end);
|
||||
await activeEditor.edit((editBuilder) => {
|
||||
editBuilder.replace(fullRange, pythonCode);
|
||||
});
|
||||
await doc.save();
|
||||
vscode.window.showInformationMessage(
|
||||
'Python code generated into the active file'
|
||||
);
|
||||
} else {
|
||||
console.log('[GenerateCode] Creating new file');
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage(
|
||||
@@ -175,12 +209,27 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fileName = `app_${Date.now()}.py`;
|
||||
const filePath = path.join(
|
||||
workspaceFolder.uri.fsPath,
|
||||
fileName
|
||||
);
|
||||
const fileUri = Uri.file(filePath);
|
||||
|
||||
const formName = designData.form.name || 'Form1';
|
||||
let fileName = `${formName}.py`;
|
||||
let fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||
let counter = 1;
|
||||
|
||||
const exists = async (u: vscode.Uri) => {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(u);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
while (await exists(fileUri)) {
|
||||
fileName = `${formName}_${counter}.py`;
|
||||
fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||
counter++;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const fileBytes = encoder.encode(pythonCode);
|
||||
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
||||
@@ -192,7 +241,18 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
vscode.window.showInformationMessage(
|
||||
`Python file created: ${fileName}`
|
||||
);
|
||||
|
||||
// Update form name in UI if changed
|
||||
const newFormName = path.basename(fileName, '.py');
|
||||
if (newFormName !== formName) {
|
||||
if (this._view) {
|
||||
this._view.webview.postMessage({
|
||||
type: 'updateFormName',
|
||||
name: newFormName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[GenerateCode] Done');
|
||||
} catch (error) {
|
||||
console.error('[GenerateCode] Error:', error);
|
||||
@@ -200,6 +260,228 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApplyChanges(designData: any): Promise<void> {
|
||||
try {
|
||||
console.log('[ApplyChanges] Start');
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage(
|
||||
'No workspace folder is open. Please open a folder first.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const formName = designData.form.name || 'Form1';
|
||||
const fileName = `${formName}.py`;
|
||||
const fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||
|
||||
// Try to read existing file to preserve method bodies
|
||||
let existingMethods: any = {};
|
||||
let oldClassName = '';
|
||||
let fileContent = '';
|
||||
let fileExists = false;
|
||||
|
||||
try {
|
||||
// We check if file exists first to avoid error in openTextDocument if not exists
|
||||
await vscode.workspace.fs.stat(fileUri);
|
||||
fileExists = true;
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
||||
fileContent = doc.getText();
|
||||
const astResult = await runPythonAst(fileContent);
|
||||
|
||||
if (astResult) {
|
||||
if (astResult.methods) {
|
||||
existingMethods = astResult.methods;
|
||||
if (designData.events) {
|
||||
for (const event of designData.events) {
|
||||
// Always prefer the existing method body from disk if it exists
|
||||
// This ensures we don't overwrite user edits in the Python file
|
||||
if (existingMethods[event.name]) {
|
||||
event.code =
|
||||
existingMethods[event.name].body;
|
||||
event.signature =
|
||||
existingMethods[event.name].signature;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (astResult.window && astResult.window.className) {
|
||||
oldClassName = astResult.window.className;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'[ApplyChanges] Could not read existing file for methods preservation',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
const { CodeGenerator } = await import(
|
||||
'../generator/codeGenerator'
|
||||
);
|
||||
const generator = new CodeGenerator();
|
||||
|
||||
if (!fileExists) {
|
||||
// New file: Generate full code
|
||||
const pythonCode = generator.generateTkinterCode(designData);
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(
|
||||
fileUri,
|
||||
encoder.encode(pythonCode)
|
||||
);
|
||||
vscode.window.showInformationMessage(
|
||||
`Created new file: ${fileName}`
|
||||
);
|
||||
} else {
|
||||
// Smart Update: Replace only create_widgets and inject new methods
|
||||
const newCreateWidgets =
|
||||
generator.generateCreateWidgetsBody(designData);
|
||||
|
||||
// 1. Update create_widgets method body
|
||||
// Improved regex to handle arguments, return types, and spacing
|
||||
const createWidgetsRegex =
|
||||
/(def\s+create_widgets\s*\(\s*self[^)]*\)\s*(?:->\s*[^:]+)?\s*:)([\s\S]*?)(?=\n\s*def\s|\n\s*if\s+__name__|\Z)/;
|
||||
|
||||
let newFileContent = fileContent;
|
||||
|
||||
if (createWidgetsRegex.test(fileContent)) {
|
||||
newFileContent = fileContent.replace(
|
||||
createWidgetsRegex,
|
||||
(match, defLine) => {
|
||||
return `${defLine}\n${newCreateWidgets}\n`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Fallback if create_widgets not found (weird), just append or warn
|
||||
vscode.window.showWarningMessage(
|
||||
'Could not find create_widgets method to update. Regenerating full file might be needed if structure is broken.'
|
||||
);
|
||||
// Fallback to full generation if structure is too broken?
|
||||
// For now let's try to proceed or maybe just replace the whole file if critical?
|
||||
// Let's stick to safe update. If not found, we don't touch.
|
||||
}
|
||||
|
||||
// 2. Inject new event handlers that are not in existingMethods
|
||||
if (designData.events) {
|
||||
const methodsToInject: string[] = [];
|
||||
for (const event of designData.events) {
|
||||
if (!existingMethods[event.name]) {
|
||||
methodsToInject.push(
|
||||
generator.generateEventHandler(event)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (methodsToInject.length > 0) {
|
||||
// Insert before run() or at the end of class
|
||||
const runRegex = /\s*def\s+run\s*\(/;
|
||||
if (runRegex.test(newFileContent)) {
|
||||
newFileContent = newFileContent.replace(
|
||||
runRegex,
|
||||
(match) => {
|
||||
return `\n${methodsToInject.join('\n\n')}\n\n${match}`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Append to end of class (before if __name__)
|
||||
const mainRegex = /if\s+__name__\s*==/;
|
||||
if (mainRegex.test(newFileContent)) {
|
||||
newFileContent = newFileContent.replace(
|
||||
mainRegex,
|
||||
(match) => {
|
||||
return `${methodsToInject.join('\n\n')}\n\n${match}`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
newFileContent += `\n\n${methodsToInject.join('\n\n')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update window title/geometry in __init__
|
||||
// self.root.title("...")
|
||||
const titleRegex = /self\.root\.title\s*\(\s*["'].*?["']\s*\)/;
|
||||
newFileContent = newFileContent.replace(
|
||||
titleRegex,
|
||||
`self.root.title("${designData.form.title}")`
|
||||
);
|
||||
|
||||
// self.root.geometry("...")
|
||||
const geoRegex = /self\.root\.geometry\s*\(\s*["'].*?["']\s*\)/;
|
||||
newFileContent = newFileContent.replace(
|
||||
geoRegex,
|
||||
`self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")`
|
||||
);
|
||||
|
||||
// 4. Update class name if changed
|
||||
if (
|
||||
oldClassName &&
|
||||
designData.form.className &&
|
||||
oldClassName !== designData.form.className
|
||||
) {
|
||||
// Replace class definition
|
||||
// Be careful to match exact class name to avoid partial replacements
|
||||
const classDefRegex = new RegExp(
|
||||
`class\\s+${oldClassName}\\s*:`
|
||||
);
|
||||
newFileContent = newFileContent.replace(
|
||||
classDefRegex,
|
||||
`class ${designData.form.className}:`
|
||||
);
|
||||
|
||||
// Replace instantiation in if __name__ == "__main__":
|
||||
// app = ClassName()
|
||||
const instanceRegex = new RegExp(
|
||||
`=\\s*${oldClassName}\\s*\\(`
|
||||
);
|
||||
newFileContent = newFileContent.replace(
|
||||
instanceRegex,
|
||||
`= ${designData.form.className}(`
|
||||
);
|
||||
}
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
||||
const editor = vscode.window.visibleTextEditors.find(
|
||||
(e) => e.document.uri.toString() === fileUri.toString()
|
||||
);
|
||||
|
||||
if (editor) {
|
||||
// Use validateRange to ensure we cover the entire document safely
|
||||
// regardless of potential external changes (though we should be careful)
|
||||
const fullRange = doc.validateRange(
|
||||
new vscode.Range(
|
||||
0,
|
||||
0,
|
||||
Number.MAX_VALUE,
|
||||
Number.MAX_VALUE
|
||||
)
|
||||
);
|
||||
await editor.edit((editBuilder) => {
|
||||
editBuilder.replace(fullRange, newFileContent);
|
||||
});
|
||||
await doc.save();
|
||||
} else {
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(
|
||||
fileUri,
|
||||
encoder.encode(newFileContent)
|
||||
);
|
||||
}
|
||||
vscode.window.showInformationMessage(
|
||||
`Smart updated ${fileName}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[ApplyChanges] Done');
|
||||
} catch (error) {
|
||||
console.error('[ApplyChanges] Error:', error);
|
||||
vscode.window.showErrorMessage(`Error applying changes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private _getHtmlForWebview(webview: vscode.Webview): string {
|
||||
const styleUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(
|
||||
|
||||
@@ -5,10 +5,12 @@ import { PropertiesPanel } from './components/PropertiesPanel';
|
||||
import { EventsPanel } from './components/EventsPanel';
|
||||
import { Canvas } from './components/Canvas';
|
||||
import { useMessaging } from './useMessaging';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
||||
export function App() {
|
||||
useMessaging();
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="container">
|
||||
<Toolbar />
|
||||
<div className="main-content">
|
||||
@@ -23,5 +25,6 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,76 @@ import React, { useRef } from 'react';
|
||||
import { useAppDispatch, useAppState } from '../state';
|
||||
import type { WidgetType } from '../types';
|
||||
|
||||
import { Widget } from '../types';
|
||||
|
||||
function renderWidgetContent(w: Widget) {
|
||||
switch (w.type) {
|
||||
case 'Label':
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{w.properties?.text || 'Label'}
|
||||
</div>
|
||||
);
|
||||
case 'Button':
|
||||
return (
|
||||
<button
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
{w.properties?.text || 'Button'}
|
||||
</button>
|
||||
);
|
||||
case 'Entry':
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="Entry"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
);
|
||||
case 'Text':
|
||||
return (
|
||||
<textarea
|
||||
disabled
|
||||
style={{ width: '100%', height: '100%', resize: 'none' }}
|
||||
>
|
||||
Text Area
|
||||
</textarea>
|
||||
);
|
||||
case 'Checkbutton':
|
||||
return (
|
||||
<label>
|
||||
<input type="checkbox" disabled checked />{' '}
|
||||
{w.properties?.text || 'Check'}
|
||||
</label>
|
||||
);
|
||||
case 'Radiobutton':
|
||||
return (
|
||||
<label>
|
||||
<input type="radio" disabled checked />{' '}
|
||||
{w.properties?.text || 'Radio'}
|
||||
</label>
|
||||
);
|
||||
default:
|
||||
return w.properties?.text || w.type;
|
||||
}
|
||||
}
|
||||
|
||||
export function Canvas() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { design, selectedWidgetId } = useAppState();
|
||||
@@ -38,10 +108,13 @@ export function Canvas() {
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
const newX = initX + dx;
|
||||
const newY = initY + dy;
|
||||
dispatch({
|
||||
type: 'updateWidget',
|
||||
payload: { id, patch: { x: initX + dx, y: initY + dy } },
|
||||
payload: { id, patch: { x: newX, y: newY } },
|
||||
});
|
||||
// Auto-expand removed: user wants clipping behavior
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
@@ -52,11 +125,140 @@ export function Canvas() {
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const onResizeStart = (
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
id: string,
|
||||
direction: string
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const w = design.widgets.find((w) => w.id === id);
|
||||
if (!w) return;
|
||||
const initX = w.x;
|
||||
const initY = w.y;
|
||||
const initWidth = w.width;
|
||||
const initHeight = w.height;
|
||||
|
||||
console.log('[Canvas] Resize start', id, direction);
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
const patch: any = {};
|
||||
|
||||
if (direction.includes('e')) {
|
||||
patch.width = Math.max(10, initWidth + dx);
|
||||
}
|
||||
if (direction.includes('w')) {
|
||||
const newWidth = Math.max(10, initWidth - dx);
|
||||
patch.width = newWidth;
|
||||
patch.x = initX + (initWidth - newWidth);
|
||||
}
|
||||
if (direction.includes('s')) {
|
||||
patch.height = Math.max(10, initHeight + dy);
|
||||
}
|
||||
if (direction.includes('n')) {
|
||||
const newHeight = Math.max(10, initHeight - dy);
|
||||
patch.height = newHeight;
|
||||
patch.y = initY + (initHeight - newHeight);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'updateWidget',
|
||||
payload: { id, patch },
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
console.log('[Canvas] Resize end', id);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const onWindowResizeStart = (
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
direction: string
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const initWidth = design.form.size.width;
|
||||
const initHeight = design.form.size.height;
|
||||
|
||||
console.log('[Canvas] Window resize start', direction);
|
||||
|
||||
let animationFrameId: number | null = null;
|
||||
let currentEv: MouseEvent | null = null;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
currentEv = ev;
|
||||
|
||||
if (animationFrameId) return;
|
||||
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
if (!currentEv) return;
|
||||
|
||||
const dx = currentEv.clientX - startX;
|
||||
const dy = currentEv.clientY - startY;
|
||||
|
||||
let newWidth = initWidth;
|
||||
let newHeight = initHeight;
|
||||
|
||||
if (direction.includes('e')) {
|
||||
newWidth = Math.max(100, initWidth + dx);
|
||||
}
|
||||
if (direction.includes('s')) {
|
||||
newHeight = Math.max(100, initHeight + dy);
|
||||
}
|
||||
|
||||
// If user requested to optimize/remove binding, we can skip complex calculations here
|
||||
// But currently we just set form size which is fast enough usually.
|
||||
// The main lag comes from React re-rendering all widgets.
|
||||
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { size: { width: newWidth, height: newHeight } },
|
||||
});
|
||||
|
||||
animationFrameId = null;
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
console.log('[Canvas] Window resize end');
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
<div className="window-frame" style={{ position: 'relative' }}>
|
||||
<div className="window-title-bar">
|
||||
<div className="window-title">
|
||||
{design.form?.title || 'Tkinter App'}
|
||||
</div>
|
||||
<div className="window-controls">
|
||||
<div className="window-control minimize"></div>
|
||||
<div className="window-control maximize"></div>
|
||||
<div className="window-control close"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="designCanvas"
|
||||
className="design-canvas"
|
||||
style={{
|
||||
width: design.form.size.width,
|
||||
height: design.form.size.height,
|
||||
}}
|
||||
ref={containerRef}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
@@ -77,6 +279,9 @@ export function Canvas() {
|
||||
top: w.y,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
// Optimization: use transform for position to avoid layout thrashing during drag,
|
||||
// but for now absolute positioning is required for accurate layout.
|
||||
// We can optimize by not rendering complex children during resize if needed.
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -85,11 +290,80 @@ export function Canvas() {
|
||||
onMouseDown={(e) => onMouseDown(e, w.id)}
|
||||
>
|
||||
<div className="widget-content">
|
||||
{w.properties?.text || w.type}
|
||||
{renderWidgetContent(w)}
|
||||
</div>
|
||||
{selectedWidgetId === w.id && (
|
||||
<>
|
||||
<div
|
||||
className="resize-handle n"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'n')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle s"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 's')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle e"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'e')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle w"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'w')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle ne"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'ne')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle nw"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'nw')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle se"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'se')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle sw"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'sw')
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Window Resize Handles */}
|
||||
<div
|
||||
className="window-resize-handle e"
|
||||
onMouseDown={(e) => onWindowResizeStart(e, 'e')}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
<div
|
||||
className="window-resize-handle s"
|
||||
onMouseDown={(e) => onWindowResizeStart(e, 's')}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
<div
|
||||
className="window-resize-handle se"
|
||||
onMouseDown={(e) => onWindowResizeStart(e, 'se')}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: '20px', color: 'red', backgroundColor: '#ffe6e6', border: '1px solid red', borderRadius: '4px' }}>
|
||||
<h2>Something went wrong.</h2>
|
||||
<details style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{this.state.error && this.state.error.toString()}
|
||||
</details>
|
||||
<button
|
||||
style={{ marginTop: '10px', padding: '5px 10px', cursor: 'pointer' }}
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ export function EventsPanel() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { design, selectedWidgetId, vscode } = useAppState();
|
||||
const [eventType, setEventType] = useState('command');
|
||||
const [eventName, setEventName] = useState('onClick');
|
||||
const [eventCode, setEventCode] = useState('print("clicked")');
|
||||
const [eventName, setEventName] = useState('on_click');
|
||||
|
||||
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||
const widgetEvents = (id: string | undefined) =>
|
||||
@@ -20,10 +19,17 @@ export function EventsPanel() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!eventType || !eventName || !eventCode) {
|
||||
if (!eventName) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'Please fill in all fields: event type, name, and code',
|
||||
text: 'Please fill in handler name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!eventType) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'Please fill in event type',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -33,21 +39,39 @@ export function EventsPanel() {
|
||||
widget: w.id,
|
||||
type: eventType,
|
||||
name: eventName,
|
||||
code: eventCode,
|
||||
code: 'pass', // Default body
|
||||
},
|
||||
});
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: `Event added: ${eventType} -> ${eventName}`,
|
||||
text: `Event added: ${eventName}`,
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (type: string) => {
|
||||
const remove = (type: string, name: string) => {
|
||||
if (!w) return;
|
||||
dispatch({ type: 'removeEvent', payload: { widget: w.id, type } });
|
||||
dispatch({
|
||||
type: 'removeEvent',
|
||||
payload: { widget: w.id, type, name },
|
||||
});
|
||||
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
|
||||
};
|
||||
|
||||
const commonEvents = [
|
||||
'command',
|
||||
'<Button-1>',
|
||||
'<Button-2>',
|
||||
'<Button-3>',
|
||||
'<Double-Button-1>',
|
||||
'<Enter>',
|
||||
'<Leave>',
|
||||
'<FocusIn>',
|
||||
'<FocusOut>',
|
||||
'<Return>',
|
||||
'<Key>',
|
||||
'<Configure>',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="events-panel">
|
||||
<h3>Events & Commands</h3>
|
||||
@@ -60,12 +84,42 @@ export function EventsPanel() {
|
||||
<label className="property-label">
|
||||
Event Type:
|
||||
</label>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<select
|
||||
className="event-handler-input"
|
||||
value={
|
||||
commonEvents.includes(eventType)
|
||||
? eventType
|
||||
: 'custom'
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.value !== 'custom') {
|
||||
setEventType(e.target.value);
|
||||
} else {
|
||||
setEventType('');
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: '4px' }}
|
||||
>
|
||||
{commonEvents.map((evt) => (
|
||||
<option key={evt} value={evt}>
|
||||
{evt}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
{!commonEvents.includes(eventType) && (
|
||||
<input
|
||||
className="event-type-select"
|
||||
className="event-handler-input"
|
||||
type="text"
|
||||
placeholder="Enter custom event type"
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setEventType(e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="property-label">
|
||||
Handler Name:
|
||||
</label>
|
||||
@@ -75,14 +129,6 @@ export function EventsPanel() {
|
||||
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}>
|
||||
@@ -97,23 +143,28 @@ export function EventsPanel() {
|
||||
widgetEvents(w.id).map((ev) => (
|
||||
<div
|
||||
className="event-item"
|
||||
key={`${ev.widget}-${ev.type}`}
|
||||
key={`${ev.widget}-${ev.type}-${ev.name}`}
|
||||
>
|
||||
<div className="event-info">
|
||||
<span className="event-name">
|
||||
<span
|
||||
className="event-name"
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{ev.type}:
|
||||
</span>
|
||||
<span
|
||||
className="event-name"
|
||||
style={{ marginLeft: '5px' }}
|
||||
>
|
||||
{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)}
|
||||
onClick={() =>
|
||||
remove(ev.type, ev.name)
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WidgetType } from '../types';
|
||||
const WIDGETS: WidgetType[] = [
|
||||
'Label',
|
||||
'Button',
|
||||
'Entry',
|
||||
'Text',
|
||||
'Checkbutton',
|
||||
'Radiobutton',
|
||||
|
||||
@@ -4,6 +4,73 @@ import { useAppDispatch, useAppState } from '../state';
|
||||
export function PropertiesPanel() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { design, selectedWidgetId } = useAppState();
|
||||
const updateForm = (key: string, value: any) => {
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedWidgetId === null) {
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<h3>Form Properties</h3>
|
||||
<div className="properties-form">
|
||||
<div className="property-row">
|
||||
<label className="property-label">Title:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="text"
|
||||
value={design.form.title}
|
||||
onChange={(e) =>
|
||||
updateForm('title', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label className="property-label">Class Name:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="text"
|
||||
value={design.form.className || 'Application'}
|
||||
onChange={(e) =>
|
||||
updateForm('className', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label className="property-label">Width:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="number"
|
||||
value={design.form.size.width}
|
||||
onChange={(e) =>
|
||||
updateForm('size', {
|
||||
...design.form.size,
|
||||
width: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label className="property-label">Height:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="number"
|
||||
value={design.form.size.height}
|
||||
onChange={(e) =>
|
||||
updateForm('size', {
|
||||
...design.form.size,
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||
|
||||
const update = (patch: Partial<typeof w>) => {
|
||||
@@ -27,7 +94,7 @@ export function PropertiesPanel() {
|
||||
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<h3>Properties</h3>
|
||||
<h3>Widget Properties</h3>
|
||||
<div id="propertiesContent">
|
||||
{!w ? (
|
||||
<p>Select a widget to edit properties</p>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React 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 {
|
||||
@@ -15,19 +14,9 @@ export function Toolbar() {
|
||||
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!',
|
||||
type: 'exportProject',
|
||||
data: exportData,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Export error:', error);
|
||||
@@ -38,37 +27,11 @@ export function Toolbar() {
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
const importProject = () => {
|
||||
console.log('[Toolbar] Request Import project');
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: 'Project imported successfully!',
|
||||
type: 'importProject',
|
||||
});
|
||||
} 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 = () => {
|
||||
@@ -96,18 +59,53 @@ export function Toolbar() {
|
||||
vscode.postMessage({ type: 'generateCode', data: design });
|
||||
};
|
||||
|
||||
const applyChanges = () => {
|
||||
console.log('[Toolbar] Apply changes');
|
||||
if (design.widgets.length === 0) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'No widgets to apply changes!',
|
||||
});
|
||||
return;
|
||||
}
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: 'Applying changes...',
|
||||
});
|
||||
vscode.postMessage({ type: 'applyChanges', data: design });
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
>
|
||||
<label htmlFor="formName" style={{ marginRight: '5px' }}>
|
||||
Form Name:
|
||||
</label>
|
||||
<input
|
||||
id="formName"
|
||||
type="text"
|
||||
value={design.form.name || 'Form1'}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { name: e.target.value },
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="undoBtn"
|
||||
className="btn btn-outline"
|
||||
@@ -137,7 +135,7 @@ export function Toolbar() {
|
||||
id="importBtn"
|
||||
className="btn btn-outline"
|
||||
title="Import project from JSON"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={importProject}
|
||||
>
|
||||
📥 Import
|
||||
</button>
|
||||
@@ -149,6 +147,13 @@ export function Toolbar() {
|
||||
>
|
||||
Generate Code
|
||||
</button>
|
||||
<button
|
||||
id="applyBtn"
|
||||
className="btn btn-primary"
|
||||
onClick={applyChanges}
|
||||
>
|
||||
Apply Changes
|
||||
</button>
|
||||
<button
|
||||
id="clearBtn"
|
||||
className="btn btn-secondary"
|
||||
|
||||
+155
-97
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useContext, useReducer, useMemo } from 'react';
|
||||
import { produce } from 'immer';
|
||||
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
|
||||
|
||||
export interface AppState {
|
||||
@@ -20,15 +21,20 @@ type Action =
|
||||
}
|
||||
| { type: 'deleteWidget'; payload: { id: string } }
|
||||
| { type: 'addEvent'; payload: EventBinding }
|
||||
| { type: 'removeEvent'; payload: { widget: string; type: string } }
|
||||
| {
|
||||
type: 'removeEvent';
|
||||
payload: { widget: string; type: string; name?: string };
|
||||
}
|
||||
| { type: 'clear' }
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' }
|
||||
| {
|
||||
type: 'setForm';
|
||||
payload: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
size?: { width: number; height: number };
|
||||
className?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,22 +43,46 @@ const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
function clone(data: DesignData): DesignData {
|
||||
return JSON.parse(JSON.stringify(data));
|
||||
}
|
||||
// We don't need clone anymore because immer handles immutability
|
||||
|
||||
function reducer(state: AppState, action: Action): AppState {
|
||||
console.log('[State] Action:', action.type);
|
||||
|
||||
// Handle undo/redo separately as they manipulate the history stack directly
|
||||
if (action.type === 'undo') {
|
||||
if (state.historyIndex <= 0) return state;
|
||||
const idx = state.historyIndex - 1;
|
||||
console.log('[State] Undo to index:', idx);
|
||||
return {
|
||||
...state,
|
||||
historyIndex: idx,
|
||||
design: JSON.parse(JSON.stringify(state.history[idx])), // Deep copy from history to current design
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === '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: JSON.parse(JSON.stringify(state.history[idx])), // Deep copy from history to current design
|
||||
};
|
||||
}
|
||||
|
||||
return produce(state, (draft) => {
|
||||
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);
|
||||
draft.design = action.payload;
|
||||
draft.selectedWidgetId = null;
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
console.log(
|
||||
'[State] Init design widgets:',
|
||||
draft.design.widgets.length
|
||||
);
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
case 'addWidget': {
|
||||
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
@@ -65,135 +95,159 @@ function reducer(state: AppState, action: Action): AppState {
|
||||
height: 40,
|
||||
properties: { text: action.payload.type },
|
||||
};
|
||||
const design = clone(state.design);
|
||||
design.widgets.push(w);
|
||||
const next = { ...state, design, selectedWidgetId: id };
|
||||
draft.design.widgets.push(w);
|
||||
draft.selectedWidgetId = id;
|
||||
console.log('[State] Added widget:', id, w.type);
|
||||
return pushHistory(next);
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
case 'selectWidget': {
|
||||
console.log('[State] Select widget:', action.payload.id);
|
||||
return { ...state, selectedWidgetId: action.payload.id };
|
||||
draft.selectedWidgetId = action.payload.id;
|
||||
break; // Selection change usually doesn't need a history entry
|
||||
}
|
||||
case 'updateWidget': {
|
||||
const design = clone(state.design);
|
||||
const idx = design.widgets.findIndex(
|
||||
const idx = draft.design.widgets.findIndex(
|
||||
(w) => w.id === action.payload.id
|
||||
);
|
||||
if (idx >= 0) {
|
||||
design.widgets[idx] = {
|
||||
...design.widgets[idx],
|
||||
...action.payload.patch,
|
||||
};
|
||||
Object.assign(
|
||||
draft.design.widgets[idx],
|
||||
action.payload.patch
|
||||
);
|
||||
}
|
||||
const next = { ...state, design };
|
||||
console.log('[State] Updated widget:', action.payload.id);
|
||||
return pushHistory(next);
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
case 'updateProps': {
|
||||
const design = clone(state.design);
|
||||
const idx = design.widgets.findIndex(
|
||||
const idx = draft.design.widgets.findIndex(
|
||||
(w) => w.id === action.payload.id
|
||||
);
|
||||
if (idx >= 0) {
|
||||
design.widgets[idx] = {
|
||||
...design.widgets[idx],
|
||||
properties: {
|
||||
...design.widgets[idx].properties,
|
||||
draft.design.widgets[idx].properties = {
|
||||
...draft.design.widgets[idx].properties,
|
||||
...action.payload.properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
const next = { ...state, design };
|
||||
console.log('[State] Updated properties for:', action.payload.id);
|
||||
return pushHistory(next);
|
||||
console.log(
|
||||
'[State] Updated properties for:',
|
||||
action.payload.id
|
||||
);
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
case 'deleteWidget': {
|
||||
const design = clone(state.design);
|
||||
design.widgets = design.widgets.filter(
|
||||
draft.design.widgets = draft.design.widgets.filter(
|
||||
(w) => w.id !== action.payload.id
|
||||
);
|
||||
if (!design.events) design.events = [];
|
||||
design.events = design.events.filter(
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
draft.design.events = draft.design.events.filter(
|
||||
(e) => e.widget !== action.payload.id
|
||||
);
|
||||
const next = { ...state, design, selectedWidgetId: null };
|
||||
draft.selectedWidgetId = null;
|
||||
console.log('[State] Deleted widget:', action.payload.id);
|
||||
return pushHistory(next);
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
case 'addEvent': {
|
||||
const design = clone(state.design);
|
||||
if (!design.events) design.events = [];
|
||||
const exists = design.events.find(
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
const existsIndex = draft.design.events.findIndex(
|
||||
(e) =>
|
||||
e.widget === action.payload.widget &&
|
||||
e.type === action.payload.type
|
||||
e.type === action.payload.type &&
|
||||
e.name === action.payload.name
|
||||
);
|
||||
if (existsIndex >= 0) {
|
||||
draft.design.events[existsIndex].code = action.payload.code;
|
||||
console.log(
|
||||
'[State] Updated event:',
|
||||
action.payload.type,
|
||||
action.payload.name,
|
||||
'for',
|
||||
action.payload.widget
|
||||
);
|
||||
} else {
|
||||
draft.design.events.push(action.payload);
|
||||
console.log(
|
||||
'[State] Added event:',
|
||||
action.payload.type,
|
||||
action.payload.name,
|
||||
'for',
|
||||
action.payload.widget
|
||||
);
|
||||
if (!exists) {
|
||||
design.events.push(action.payload);
|
||||
}
|
||||
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget);
|
||||
return pushHistory({ ...state, design });
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
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
|
||||
)
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
draft.design.events = draft.design.events.filter((e) => {
|
||||
const matchWidget = e.widget === action.payload.widget;
|
||||
const matchType = e.type === action.payload.type;
|
||||
const matchName = action.payload.name
|
||||
? e.name === action.payload.name
|
||||
: true;
|
||||
return !(matchWidget && matchType && matchName);
|
||||
});
|
||||
console.log(
|
||||
'[State] Removed event:',
|
||||
action.payload.type,
|
||||
action.payload.name,
|
||||
'for',
|
||||
action.payload.widget
|
||||
);
|
||||
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget);
|
||||
return pushHistory({ ...state, design });
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
case 'clear': {
|
||||
const design: DesignData = {
|
||||
form: state.design.form,
|
||||
widgets: [],
|
||||
events: [],
|
||||
};
|
||||
draft.design.widgets = [];
|
||||
draft.design.events = [];
|
||||
draft.selectedWidgetId = null;
|
||||
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]),
|
||||
};
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
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;
|
||||
if (action.payload.name)
|
||||
draft.design.form.name = action.payload.name;
|
||||
if (action.payload.title)
|
||||
draft.design.form.title = action.payload.title;
|
||||
if (action.payload.size)
|
||||
draft.design.form.size = action.payload.size;
|
||||
if (action.payload.className)
|
||||
draft.design.form.className = action.payload.className;
|
||||
console.log('[State] Set form', action.payload);
|
||||
return pushHistory({ ...state, design });
|
||||
pushHistoryDraft(draft);
|
||||
break;
|
||||
}
|
||||
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 };
|
||||
// Helper to push state to history within a draft
|
||||
function pushHistoryDraft(draft: AppState) {
|
||||
// We can't use JSON.parse/stringify on draft directly efficiently,
|
||||
// but since we are in produce, 'draft.design' tracks changes.
|
||||
// However, for history we need a snapshot.
|
||||
// immer allows us to access the current state.
|
||||
// But since 'draft' is a proxy, we need to be careful.
|
||||
// The simple way for history stack in immer is to manually manage it.
|
||||
// But we are inside 'produce' which updates 'state'.
|
||||
|
||||
// Ideally we should just push a copy of design.
|
||||
// Since we are modifying 'draft.design', we can take a snapshot of it?
|
||||
// No, 'current(draft.design)' works.
|
||||
|
||||
// BUT: The history stack itself is in the draft.
|
||||
// We slice history to current index + 1
|
||||
draft.history = draft.history.slice(0, draft.historyIndex + 1);
|
||||
// We push a copy of the current design.
|
||||
// We must clone it because 'draft.design' will change in future.
|
||||
// Using JSON parse/stringify is still the safest "deep clone" for POJO design data here.
|
||||
draft.history.push(JSON.parse(JSON.stringify(draft.design)));
|
||||
draft.historyIndex = draft.history.length - 1;
|
||||
console.log('[State] History length:', draft.history.length);
|
||||
}
|
||||
|
||||
export function useAppState() {
|
||||
@@ -217,7 +271,11 @@ export function AppProvider({
|
||||
}) {
|
||||
const initialDesign: DesignData = useMemo(
|
||||
() => ({
|
||||
form: { title: 'My App', size: { width: 800, height: 600 } },
|
||||
form: {
|
||||
name: 'Form1',
|
||||
title: 'My App',
|
||||
size: { width: 800, height: 600 },
|
||||
},
|
||||
widgets: [],
|
||||
events: [],
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type WidgetType =
|
||||
| 'Label'
|
||||
| 'Button'
|
||||
| 'Entry'
|
||||
| 'Text'
|
||||
| 'Checkbutton'
|
||||
| 'Radiobutton';
|
||||
@@ -24,8 +25,10 @@ export interface EventBinding {
|
||||
|
||||
export interface DesignData {
|
||||
form: {
|
||||
name: string;
|
||||
title: string;
|
||||
size: { width: number; height: number };
|
||||
className?: string;
|
||||
};
|
||||
widgets: Widget[];
|
||||
events: EventBinding[];
|
||||
|
||||
@@ -15,6 +15,12 @@ export function useMessaging() {
|
||||
initializedRef.current = true;
|
||||
dispatch({ type: 'init', payload: message.data });
|
||||
break;
|
||||
case 'updateFormName':
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { name: message.name },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -25,7 +31,12 @@ export function useMessaging() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializedRef.current) return;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
vscode.postMessage({ type: 'designUpdated', data: design });
|
||||
}, 200); // 200ms debounce
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [design, vscode]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+209
-23
@@ -77,12 +77,13 @@ body {
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--vscode-button-foreground);
|
||||
color: white;
|
||||
border: 1px solid var(--vscode-button-border);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
background-color: black;
|
||||
border-color: black;
|
||||
}
|
||||
|
||||
.btn-outline:disabled {
|
||||
@@ -434,22 +435,16 @@ body {
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
padding: 40px;
|
||||
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-color: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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%);
|
||||
linear-gradient(45deg, #d0d0d0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #d0d0d0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #d0d0d0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #d0d0d0 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position:
|
||||
0 0,
|
||||
@@ -458,9 +453,138 @@ body {
|
||||
-10px 0px;
|
||||
}
|
||||
|
||||
.design-canvas.drag-over {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
background-color: var(--vscode-list-dropBackground);
|
||||
.window-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #999;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.window-title-bar {
|
||||
height: 30px;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0 0 10px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.window-control {
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.window-control:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.window-control.close:hover {
|
||||
background-color: #e81123;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Minimize Icon */
|
||||
.window-control.minimize::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
/* Maximize Icon */
|
||||
.window-control.maximize::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1px solid #000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Close Icon (X) */
|
||||
.window-control.close {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window-control.close::before,
|
||||
.window-control.close::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.window-control.close::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.window-control.close::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.window-control.close:hover::before,
|
||||
.window-control.close:hover::after {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.design-canvas {
|
||||
background-color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.window-resize-handle.e {
|
||||
top: 0;
|
||||
right: -10px;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.window-resize-handle.s {
|
||||
bottom: -10px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
cursor: ns-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.window-resize-handle.se {
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
@@ -497,6 +621,71 @@ body {
|
||||
box-shadow: 0 0 0 1px var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--vscode-button-background);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.resize-handle.n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handle.s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handle.e {
|
||||
top: 50%;
|
||||
right: -5px;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handle.w {
|
||||
top: 50%;
|
||||
left: -5px;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handle.ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle.se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle.sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.canvas-widget.widget-label {
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
@@ -516,8 +705,7 @@ body {
|
||||
border: 1px solid #ced4da;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
resize: both;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-widget.widget-checkbutton {
|
||||
@@ -546,8 +734,6 @@ body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
Reference in New Issue
Block a user