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",
|
"name": "tkinter-designer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"immer": "^11.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -1243,6 +1244,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"immer": "^11.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+36
-77
@@ -1,84 +1,32 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { CodeGenerator } from './generator/CodeGenerator';
|
import { CodeGenerator } from './generator/codeGenerator';
|
||||||
import { CodeParser } from './parser/CodeParser';
|
import { CodeParser } from './parser/codeParser';
|
||||||
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
|
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
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(
|
const openDesignerCommand = vscode.commands.registerCommand(
|
||||||
'tkinter-designer.openDesigner',
|
'tkinter-designer.openDesigner',
|
||||||
() => {
|
() => {
|
||||||
TkinterDesignerProvider.createOrShow(context.extensionUri);
|
TkinterDesignerProvider.createNew(context.extensionUri);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateCodeCommand = vscode.commands.registerCommand(
|
// Removed global generateCodeCommand because generate is now handled inside the Webview Provider instance
|
||||||
'tkinter-designer.generateCode',
|
// or we need a way to target the specific panel.
|
||||||
async () => {
|
// The previous generateCodeCommand was flawed because it relied on a singleton provider.
|
||||||
console.log('[GenerateCode] Command invoked');
|
// Now, generation is triggered FROM the UI (webview) via messages, which is handled in TkinterDesignerProvider.handleGenerateCode.
|
||||||
const generator = new CodeGenerator();
|
// If we want a command palette action, it needs to know WHICH designer to use.
|
||||||
const designData = await provider.getDesignData();
|
// For simplicity, we remove the global command or make it check for active webview (hard with multiple).
|
||||||
if (
|
// Let's keep it but warn it only works if we tracked instances.
|
||||||
!designData ||
|
// Actually, best practice: actions are context-based. The webview has a "Generate" button.
|
||||||
!designData.widgets ||
|
|
||||||
designData.widgets.length === 0
|
|
||||||
) {
|
|
||||||
console.log('[GenerateCode] No design data');
|
|
||||||
vscode.window.showWarningMessage(
|
|
||||||
'No design data found. Please open the designer and create some widgets first.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pythonCode = generator.generateTkinterCode(designData);
|
// However, parseCodeCommand is useful. It should open a NEW designer with the parsed code.
|
||||||
const activeEditor = vscode.window.activeTextEditor;
|
|
||||||
|
|
||||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
|
||||||
console.log('[GenerateCode] Writing into active Python file');
|
|
||||||
const doc = activeEditor.document;
|
|
||||||
const start = new vscode.Position(0, 0);
|
|
||||||
const end = doc.lineCount
|
|
||||||
? doc.lineAt(doc.lineCount - 1).range.end
|
|
||||||
: start;
|
|
||||||
const fullRange = new vscode.Range(start, end);
|
|
||||||
await activeEditor.edit((editBuilder) => {
|
|
||||||
editBuilder.replace(fullRange, pythonCode);
|
|
||||||
});
|
|
||||||
await doc.save();
|
|
||||||
vscode.window.showInformationMessage(
|
|
||||||
'Python code generated into the active file'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log('[GenerateCode] Creating new Python file');
|
|
||||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
|
||||||
if (!workspaceFolder) {
|
|
||||||
vscode.window.showErrorMessage(
|
|
||||||
'No workspace folder is open. Please open a folder first.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fileName = `app_${Date.now()}.py`;
|
|
||||||
const filePath = path.join(
|
|
||||||
workspaceFolder.uri.fsPath,
|
|
||||||
fileName
|
|
||||||
);
|
|
||||||
const fileUri = vscode.Uri.file(filePath);
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const fileBytes = encoder.encode(pythonCode);
|
|
||||||
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
|
||||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
|
||||||
await vscode.window.showTextDocument(doc, { preview: false });
|
|
||||||
vscode.window.showInformationMessage(
|
|
||||||
`Python file created: ${fileName}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log('[GenerateCode] Done');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const parseCodeCommand = vscode.commands.registerCommand(
|
const parseCodeCommand = vscode.commands.registerCommand(
|
||||||
'tkinter-designer.parseCode',
|
'tkinter-designer.parseCode',
|
||||||
@@ -89,24 +37,36 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
if (activeEditor && activeEditor.document.languageId === 'python') {
|
||||||
const parser = new CodeParser();
|
const parser = new CodeParser();
|
||||||
const code = activeEditor.document.getText();
|
const code = activeEditor.document.getText();
|
||||||
|
const fileName = path.basename(
|
||||||
|
activeEditor.document.fileName,
|
||||||
|
'.py'
|
||||||
|
);
|
||||||
console.log('[ParseCode] Code length:', code.length);
|
console.log('[ParseCode] Code length:', code.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const designData = await parser.parseCodeToDesign(code);
|
const designData = await parser.parseCodeToDesign(
|
||||||
|
code,
|
||||||
|
fileName
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
designData &&
|
designData &&
|
||||||
designData.widgets &&
|
designData.widgets &&
|
||||||
designData.widgets.length > 0
|
designData.widgets.length > 0
|
||||||
) {
|
) {
|
||||||
console.log('[ParseCode] Widgets found:', designData.widgets.length);
|
console.log(
|
||||||
const designerInstance =
|
'[ParseCode] Widgets found:',
|
||||||
TkinterDesignerProvider.createOrShow(
|
designData.widgets.length
|
||||||
|
);
|
||||||
|
// Create a NEW designer instance for this file
|
||||||
|
const designerInstance = TkinterDesignerProvider.createNew(
|
||||||
context.extensionUri
|
context.extensionUri
|
||||||
);
|
);
|
||||||
if (designerInstance) {
|
|
||||||
designerInstance.loadDesignData(designData);
|
// We need to wait for webview to be ready?
|
||||||
} else {
|
// 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);
|
||||||
|
|
||||||
vscode.window.showInformationMessage(
|
vscode.window.showInformationMessage(
|
||||||
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
|
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
|
||||||
@@ -134,7 +94,6 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
openDesignerCommand,
|
openDesignerCommand,
|
||||||
generateCodeCommand,
|
|
||||||
parseCodeCommand
|
parseCodeCommand
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,21 @@ export class CodeGenerator {
|
|||||||
|
|
||||||
public generateTkinterCode(designData: DesignData): string {
|
public generateTkinterCode(designData: DesignData): string {
|
||||||
this.designData = designData;
|
this.designData = designData;
|
||||||
console.log('[Generator] Start, widgets:', designData.widgets.length, 'events:', designData.events?.length || 0);
|
const className = designData.form.className || 'Application';
|
||||||
|
console.log(
|
||||||
|
'[Generator] Start, widgets:',
|
||||||
|
designData.widgets.length,
|
||||||
|
'events:',
|
||||||
|
designData.events?.length || 0
|
||||||
|
);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const nameMap = generateVariableNames(designData.widgets);
|
const nameMap = generateVariableNames(designData.widgets);
|
||||||
|
|
||||||
lines.push('import tkinter as tk');
|
lines.push('import tkinter as tk');
|
||||||
|
lines.push('from tkinter import ttk');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
lines.push('class Application:');
|
lines.push(`class ${className}:`);
|
||||||
this.indentLevel = 1;
|
this.indentLevel = 1;
|
||||||
|
|
||||||
lines.push(this.indent('def __init__(self):'));
|
lines.push(this.indent('def __init__(self):'));
|
||||||
@@ -47,7 +54,6 @@ export class CodeGenerator {
|
|||||||
designData.widgets.forEach((widget) => {
|
designData.widgets.forEach((widget) => {
|
||||||
console.log('[Generator] Widget:', widget.id, widget.type);
|
console.log('[Generator] Widget:', widget.id, widget.type);
|
||||||
lines.push(...this.generateWidgetCode(widget, nameMap));
|
lines.push(...this.generateWidgetCode(widget, nameMap));
|
||||||
lines.push('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.indentLevel = 1;
|
this.indentLevel = 1;
|
||||||
@@ -72,12 +78,65 @@ export class CodeGenerator {
|
|||||||
this.indentLevel = 0;
|
this.indentLevel = 0;
|
||||||
lines.push('if __name__ == "__main__":');
|
lines.push('if __name__ == "__main__":');
|
||||||
this.indentLevel = 1;
|
this.indentLevel = 1;
|
||||||
lines.push(this.indent('app = Application()'));
|
lines.push(this.indent('try:'));
|
||||||
|
this.indentLevel = 2;
|
||||||
|
lines.push(this.indent(`app = ${className}()`));
|
||||||
lines.push(this.indent('app.run()'));
|
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');
|
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(
|
private generateWidgetCode(
|
||||||
widget: WidgetData,
|
widget: WidgetData,
|
||||||
nameMap: Map<string, string>
|
nameMap: Map<string, string>
|
||||||
@@ -96,7 +155,9 @@ export class CodeGenerator {
|
|||||||
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
|
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
|
||||||
|
|
||||||
const contentLines = generateWidgetContent(widget, varName);
|
const contentLines = generateWidgetContent(widget, varName);
|
||||||
contentLines.forEach((line) => lines.push(this.indent(line)));
|
if (contentLines.length > 0) {
|
||||||
|
contentLines.forEach((l) => lines.push(this.indent(l)));
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
...getWidgetEventBindings(
|
...getWidgetEventBindings(
|
||||||
|
|||||||
@@ -8,22 +8,47 @@ export function generateEventHandlers(
|
|||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
if (designData.events && designData.events.length > 0) {
|
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) => {
|
designData.events.forEach((event: Event) => {
|
||||||
const handlerName = event.name;
|
const handlerName = event.name;
|
||||||
const isBindEvent =
|
|
||||||
event.type.startsWith('<') && event.type.endsWith('>');
|
|
||||||
|
|
||||||
let hasCode = false;
|
if (handledNames.has(handlerName)) {
|
||||||
const widget = (designData.widgets || []).find(
|
return;
|
||||||
(w) => w.id === event.widget
|
}
|
||||||
);
|
handledNames.add(handlerName);
|
||||||
if (isBindEvent) {
|
|
||||||
lines.push(indentDef(`def ${handlerName}(self, event):`));
|
// 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 {
|
} else {
|
||||||
lines.push(indentDef(`def ${handlerName}(self):`));
|
const isBindEvent =
|
||||||
|
event.type.startsWith('<') && event.type.endsWith('>');
|
||||||
|
|
||||||
|
if (isBindEvent) {
|
||||||
|
lines.push(
|
||||||
|
indentDef(`def ${handlerName}(self, event=None):`)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(indentDef(`def ${handlerName}(self):`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeLines = (event.code || '').split('\n');
|
// 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) {
|
for (const line of codeLines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
lines.push(indentBody(line));
|
lines.push(indentBody(line));
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export interface WidgetData {
|
|||||||
|
|
||||||
export interface DesignData {
|
export interface DesignData {
|
||||||
form: {
|
form: {
|
||||||
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
size: {
|
size: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
widgets: WidgetData[];
|
widgets: WidgetData[];
|
||||||
events?: Event[];
|
events?: Event[];
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ export function generateVariableNames(
|
|||||||
let baseName = widget.type.toLowerCase();
|
let baseName = widget.type.toLowerCase();
|
||||||
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
|
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
|
||||||
if (baseName === 'button') baseName = 'btn';
|
if (baseName === 'button') baseName = 'btn';
|
||||||
|
if (baseName === 'entry') baseName = 'entry';
|
||||||
if (baseName === 'checkbutton') baseName = 'chk';
|
if (baseName === 'checkbutton') baseName = 'chk';
|
||||||
if (baseName === 'radiobutton') baseName = 'radio';
|
if (baseName === 'radiobutton') baseName = 'radio';
|
||||||
if (baseName === 'label') baseName = 'lbl';
|
if (baseName === 'label') baseName = 'lbl';
|
||||||
|
|
||||||
|
|
||||||
const count = (counts.get(baseName) || 0) + 1;
|
const count = (counts.get(baseName) || 0) + 1;
|
||||||
counts.set(baseName, count);
|
counts.set(baseName, count);
|
||||||
names.set(widget.id, `${baseName}${count}`);
|
names.set(widget.id, `${baseName}${count}`);
|
||||||
|
|||||||
+27
-113
@@ -1,137 +1,51 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
import { DesignData, WidgetData, Event } from '../generator/types';
|
import { DesignData, WidgetData, Event } from '../generator/types';
|
||||||
import { runPythonAst } from './pythonRunner';
|
import { runPythonAst } from './pythonRunner';
|
||||||
import { convertASTResultToDesignData } from './astConverter';
|
import { convertASTResultToDesignData } from './astConverter';
|
||||||
|
|
||||||
export class CodeParser {
|
export class CodeParser {
|
||||||
public async parseCodeToDesign(
|
public async parseCodeToDesign(
|
||||||
pythonCode: string
|
pythonCode: string,
|
||||||
|
filename?: string
|
||||||
): Promise<DesignData | null> {
|
): Promise<DesignData | null> {
|
||||||
console.log(
|
console.log(
|
||||||
'[Parser] parseCodeToDesign start, code length:',
|
'[Parser] parseCodeToDesign start, code length:',
|
||||||
pythonCode.length
|
pythonCode.length
|
||||||
);
|
);
|
||||||
const astRaw = await runPythonAst(pythonCode);
|
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);
|
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(
|
console.log(
|
||||||
'[Parser] AST parsed widgets:',
|
'[Parser] AST parsed widgets:',
|
||||||
astDesign.widgets.length
|
astDesign.widgets.length
|
||||||
);
|
);
|
||||||
|
if (filename) {
|
||||||
|
astDesign.form.name = filename;
|
||||||
|
}
|
||||||
return astDesign;
|
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(
|
console.log(
|
||||||
'[Parser] Regex parsed widgets:',
|
'[Parser] AST failed. Regex fallback is disabled for safety.'
|
||||||
regexDesign?.widgets?.length || 0
|
|
||||||
);
|
);
|
||||||
return regexDesign;
|
vscode.window.showErrorMessage(
|
||||||
|
'Could not parse Python code safely. Please ensure your code has no syntax errors and follows the standard structure (class-based Tkinter app).'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseWithRegexInline(code: string): DesignData | null {
|
// Regex parser removed to prevent "Zombie Code" issues where regex sees widgets
|
||||||
const widgetRegex =
|
// but AST doesn't, leading to partial updates that break the file.
|
||||||
/(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ export function convertASTResultToDesignData(
|
|||||||
(astResult.window && astResult.window.height) ||
|
(astResult.window && astResult.window.height) ||
|
||||||
astResult.height ||
|
astResult.height ||
|
||||||
600;
|
600;
|
||||||
|
let className =
|
||||||
|
(astResult.window && astResult.window.className) || 'Application';
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
const allowedTypes = new Set([
|
const allowedTypes = new Set([
|
||||||
'Label',
|
'Label',
|
||||||
'Button',
|
'Button',
|
||||||
|
'Entry',
|
||||||
'Text',
|
'Text',
|
||||||
'Checkbutton',
|
'Checkbutton',
|
||||||
'Radiobutton',
|
'Radiobutton',
|
||||||
@@ -68,17 +71,30 @@ export function convertASTResultToDesignData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const events: Event[] = [];
|
const events: Event[] = [];
|
||||||
|
const methods = astResult.methods || {};
|
||||||
if (astResult.command_callbacks) {
|
if (astResult.command_callbacks) {
|
||||||
for (const callback of astResult.command_callbacks) {
|
for (const callback of astResult.command_callbacks) {
|
||||||
const rawName = callback.command?.name;
|
const rawName = callback.command?.name;
|
||||||
const cleanName = rawName
|
const cleanName = rawName
|
||||||
? String(rawName).replace(/^self\./, '')
|
? String(rawName).replace(/^self\./, '')
|
||||||
: `on_${callback.widget}_command`;
|
: `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({
|
events.push({
|
||||||
widget: callback.widget,
|
widget: callback.widget,
|
||||||
type: 'command',
|
type: 'command',
|
||||||
name: cleanName,
|
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
|
const cleanName = rawName
|
||||||
? String(rawName).replace(/^self\./, '')
|
? String(rawName).replace(/^self\./, '')
|
||||||
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
|
: `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({
|
events.push({
|
||||||
widget: bindEvent.widget,
|
widget: bindEvent.widget,
|
||||||
type: bindEvent.event,
|
type: bindEvent.event,
|
||||||
name: cleanName,
|
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 keptIds = new Set(widgets.map((w) => w.id));
|
||||||
const filteredEvents = events.filter((e) => keptIds.has(e.widget));
|
const filteredEvents = events.filter((e) => keptIds.has(e.widget));
|
||||||
|
|
||||||
const result: DesignData = {
|
const result: DesignData = {
|
||||||
form: {
|
form: {
|
||||||
|
name: 'Form',
|
||||||
title: formTitle,
|
title: formTitle,
|
||||||
size: { width: formWidth, height: formHeight },
|
size: { width: formWidth, height: formHeight },
|
||||||
|
className: className,
|
||||||
},
|
},
|
||||||
widgets,
|
widgets,
|
||||||
events: filteredEvents.length ? filteredEvents : [],
|
events: filteredEvents.length ? filteredEvents : [],
|
||||||
|
|||||||
+118
-54
@@ -2,79 +2,143 @@ import * as path from 'path';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
async function executePythonScript(
|
async function executePythonScript(
|
||||||
pythonScriptPath: string,
|
pythonScriptPath: string,
|
||||||
pythonFilePath: string
|
pythonCode: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await new Promise((resolve, reject) => {
|
const commandsToTry = await getPythonCommands();
|
||||||
const pythonCommand = getPythonCommand();
|
let lastError: Error | null = null;
|
||||||
const start = Date.now();
|
|
||||||
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath);
|
|
||||||
const process = spawn(pythonCommand, [
|
|
||||||
pythonScriptPath,
|
|
||||||
pythonFilePath,
|
|
||||||
]);
|
|
||||||
let result = '';
|
|
||||||
let errorOutput = '';
|
|
||||||
|
|
||||||
process.stdout.on('data', (data) => {
|
for (const cmd of commandsToTry) {
|
||||||
result += data.toString();
|
try {
|
||||||
});
|
return await new Promise((resolve, reject) => {
|
||||||
|
const start = Date.now();
|
||||||
|
console.log('[PythonRunner] Spawning:', cmd, pythonScriptPath);
|
||||||
|
|
||||||
process.stderr.on('data', (data) => {
|
const process = spawn(cmd, [pythonScriptPath]);
|
||||||
errorOutput += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('close', (code) => {
|
let result = '';
|
||||||
const ms = Date.now() - start;
|
let errorOutput = '';
|
||||||
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms);
|
|
||||||
if (code === 0) {
|
// Write code to stdin
|
||||||
resolve(result);
|
try {
|
||||||
} else {
|
process.stdin.write(pythonCode);
|
||||||
reject(
|
process.stdin.end();
|
||||||
new Error(
|
} catch (e) {
|
||||||
`Python script failed with code ${code}: ${errorOutput}`
|
console.error('[PythonRunner] Error writing to stdin:', e);
|
||||||
)
|
}
|
||||||
|
|
||||||
|
process.stdout.on('data', (data) => {
|
||||||
|
result += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr.on('data', (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
const ms = Date.now() - start;
|
||||||
|
console.log(
|
||||||
|
'[PythonRunner] Exit code:',
|
||||||
|
code,
|
||||||
|
'time(ms):',
|
||||||
|
ms
|
||||||
|
);
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(result);
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Python script failed with code ${code}: ${errorOutput}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPythonCommands(): Promise<string[]> {
|
||||||
|
const commands: string[] = [];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPythonCommand(): string {
|
// 3. Common commands
|
||||||
return process.platform === 'win32' ? 'python' : 'python3';
|
if (process.platform === 'win32') {
|
||||||
}
|
commands.push('py');
|
||||||
|
commands.push('python');
|
||||||
|
} else {
|
||||||
|
commands.push('python3');
|
||||||
|
commands.push('python');
|
||||||
|
}
|
||||||
|
|
||||||
function createTempPythonFile(pythonCode: string): string {
|
// Unique
|
||||||
const tempDir = os.tmpdir();
|
return Array.from(new Set(commands));
|
||||||
const tempFilePath = path.join(tempDir, `tk_ast_${Date.now()}.py`);
|
|
||||||
fs.writeFileSync(tempFilePath, pythonCode, 'utf8');
|
|
||||||
return tempFilePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupTempFile(tempFile: string): void {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(tempFile);
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPythonAst(pythonCode: string): Promise<any | null> {
|
export async function runPythonAst(pythonCode: string): Promise<any | null> {
|
||||||
const tempFilePath = createTempPythonFile(pythonCode);
|
|
||||||
try {
|
try {
|
||||||
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
|
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
|
||||||
const output = await executePythonScript(
|
const output = await executePythonScript(pythonScriptPath, pythonCode);
|
||||||
pythonScriptPath,
|
|
||||||
tempFilePath
|
|
||||||
);
|
|
||||||
console.log('[PythonRunner] Received AST JSON length:', output.length);
|
console.log('[PythonRunner] Received AST JSON length:', output.length);
|
||||||
const parsed = JSON.parse(output);
|
try {
|
||||||
return parsed;
|
const parsed = JSON.parse(output);
|
||||||
|
return parsed;
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(
|
||||||
|
'[PythonRunner] JSON parse error:',
|
||||||
|
parseError,
|
||||||
|
'Output:',
|
||||||
|
output
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[PythonRunner] Error running Python AST:', err);
|
console.error('[PythonRunner] Error running Python AST:', err);
|
||||||
return null;
|
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 ast
|
||||||
|
import textwrap
|
||||||
|
import tokenize
|
||||||
|
import io
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
from .imports import handle_import, handle_import_from
|
from .imports import handle_import, handle_import_from
|
||||||
@@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
|
|||||||
|
|
||||||
class TkinterAnalyzer(ast.NodeVisitor):
|
class TkinterAnalyzer(ast.NodeVisitor):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, source_code: str = ""):
|
||||||
|
self.source_code = source_code
|
||||||
|
self.line_offsets = [0]
|
||||||
|
current = 0
|
||||||
|
if source_code:
|
||||||
|
for line in source_code.splitlines(keepends=True):
|
||||||
|
current += len(line)
|
||||||
|
self.line_offsets.append(current)
|
||||||
|
|
||||||
self.widgets: List[Dict[str, Any]] = []
|
self.widgets: List[Dict[str, Any]] = []
|
||||||
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
|
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
|
||||||
self.imports: Dict[str, str] = {}
|
self.imports: Dict[str, str] = {}
|
||||||
@@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
|||||||
self.event_handlers: List[Dict[str, Any]] = []
|
self.event_handlers: List[Dict[str, Any]] = []
|
||||||
self.command_callbacks: List[Dict[str, Any]] = []
|
self.command_callbacks: List[Dict[str, Any]] = []
|
||||||
self.bind_events: List[Dict[str, Any]] = []
|
self.bind_events: List[Dict[str, Any]] = []
|
||||||
|
self.methods: Dict[str, str] = {}
|
||||||
|
|
||||||
def visit_Import(self, node: ast.Import):
|
def visit_Import(self, node: ast.Import):
|
||||||
handle_import(self, node)
|
handle_import(self, node)
|
||||||
@@ -36,12 +48,139 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
|||||||
def visit_ClassDef(self, node: ast.ClassDef):
|
def visit_ClassDef(self, node: ast.ClassDef):
|
||||||
prev = self.current_class
|
prev = self.current_class
|
||||||
enter_class(self, node)
|
enter_class(self, node)
|
||||||
|
|
||||||
|
# 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)
|
self.generic_visit(node)
|
||||||
exit_class(self, prev)
|
exit_class(self, prev)
|
||||||
|
|
||||||
def visit_FunctionDef(self, node: ast.FunctionDef):
|
def visit_FunctionDef(self, node: ast.FunctionDef):
|
||||||
prev = self.current_method
|
prev = self.current_method
|
||||||
enter_function(self, node)
|
enter_function(self, node)
|
||||||
|
|
||||||
|
if self.current_class and node.name not in ['__init__', 'create_widgets', 'run'] and self.source_code and node.body:
|
||||||
|
try:
|
||||||
|
# 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)
|
self.generic_visit(node)
|
||||||
exit_function(self, prev)
|
exit_function(self, prev)
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,11 @@ def handle_method_call(analyzer, node: ast.Call):
|
|||||||
arg0 = node.args[0]
|
arg0 = node.args[0]
|
||||||
if isinstance(arg0, ast.Constant):
|
if isinstance(arg0, ast.Constant):
|
||||||
analyzer.window_config['title'] = arg0.value
|
analyzer.window_config['title'] = arg0.value
|
||||||
elif isinstance(arg0, ast.Str):
|
|
||||||
analyzer.window_config['title'] = arg0.s
|
|
||||||
|
|
||||||
elif method_name == 'geometry' and node.args:
|
elif method_name == 'geometry' and node.args:
|
||||||
arg0 = node.args[0]
|
arg0 = node.args[0]
|
||||||
if isinstance(arg0, ast.Constant):
|
if isinstance(arg0, ast.Constant):
|
||||||
geometry = arg0.value
|
geometry = arg0.value
|
||||||
elif isinstance(arg0, ast.Str):
|
|
||||||
geometry = arg0.s
|
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
if 'x' in str(geometry):
|
if 'x' in str(geometry):
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ def get_variable_name(node: ast.AST) -> str:
|
|||||||
def extract_value(node: ast.AST) -> Any:
|
def extract_value(node: ast.AST) -> Any:
|
||||||
if isinstance(node, ast.Constant):
|
if isinstance(node, ast.Constant):
|
||||||
return node.value
|
return node.value
|
||||||
elif isinstance(node, ast.Str):
|
|
||||||
return node.s
|
|
||||||
elif isinstance(node, ast.Num):
|
|
||||||
return node.n
|
|
||||||
elif isinstance(node, ast.Name):
|
elif isinstance(node, ast.Name):
|
||||||
return f"${node.id}"
|
return f"${node.id}"
|
||||||
elif isinstance(node, ast.Attribute):
|
elif isinstance(node, ast.Attribute):
|
||||||
@@ -65,7 +61,7 @@ def get_operator_symbol(op_node: ast.AST) -> str:
|
|||||||
|
|
||||||
def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
||||||
body = lambda_node.body
|
body = lambda_node.body
|
||||||
if isinstance(body, (ast.Constant, ast.Str, ast.Num)):
|
if isinstance(body, ast.Constant):
|
||||||
return 'simple'
|
return 'simple'
|
||||||
elif isinstance(body, (ast.Name, ast.Attribute)):
|
elif isinstance(body, (ast.Name, ast.Attribute)):
|
||||||
return 'simple'
|
return 'simple'
|
||||||
@@ -76,11 +72,9 @@ def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
|||||||
|
|
||||||
def extract_lambda_body(body_node: ast.AST) -> str:
|
def extract_lambda_body(body_node: ast.AST) -> str:
|
||||||
if isinstance(body_node, ast.Constant):
|
if isinstance(body_node, ast.Constant):
|
||||||
|
if isinstance(body_node.value, str):
|
||||||
|
return f'"{body_node.value}"'
|
||||||
return str(body_node.value)
|
return str(body_node.value)
|
||||||
elif isinstance(body_node, ast.Str):
|
|
||||||
return f'"{body_node.s}"'
|
|
||||||
elif isinstance(body_node, ast.Num):
|
|
||||||
return str(body_node.n)
|
|
||||||
elif isinstance(body_node, ast.Name):
|
elif isinstance(body_node, ast.Name):
|
||||||
return body_node.id
|
return body_node.id
|
||||||
elif isinstance(body_node, ast.Attribute):
|
elif isinstance(body_node, ast.Attribute):
|
||||||
@@ -138,8 +132,6 @@ def extract_lambda_body(body_node: ast.AST) -> str:
|
|||||||
for value in body_node.values:
|
for value in body_node.values:
|
||||||
if isinstance(value, ast.Constant):
|
if isinstance(value, ast.Constant):
|
||||||
parts.append(str(value.value))
|
parts.append(str(value.value))
|
||||||
elif isinstance(value, ast.Str):
|
|
||||||
parts.append(value.s)
|
|
||||||
else:
|
else:
|
||||||
parts.append(f"{{{extract_lambda_body(value)}}}")
|
parts.append(f"{{{extract_lambda_body(value)}}}")
|
||||||
return f"f\"{''.join(parts)}\""
|
return f"f\"{''.join(parts)}\""
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool:
|
|||||||
widget_type = call_node.func.attr
|
widget_type = call_node.func.attr
|
||||||
|
|
||||||
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
|
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
|
||||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
|
|
||||||
return False
|
# Check if module_name resolves to ttk
|
||||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
resolved = analyzer.imports.get(module_name, module_name)
|
||||||
|
if resolved in ['ttk', 'tkinter.ttk']:
|
||||||
|
return widget_type in ['Label', 'Button', 'Entry', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
|
|
||||||
|
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
|
|
||||||
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
||||||
if not isinstance(node.value, ast.Call):
|
if not isinstance(node.value, ast.Call):
|
||||||
@@ -31,7 +35,7 @@ def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
|||||||
if isinstance(node.value.func, ast.Attribute):
|
if isinstance(node.value.func, ast.Attribute):
|
||||||
return is_tkinter_widget_call(analyzer, node.value)
|
return is_tkinter_widget_call(analyzer, node.value)
|
||||||
elif isinstance(node.value.func, ast.Name):
|
elif isinstance(node.value.func, ast.Name):
|
||||||
return node.value.func.id in ['Label', 'Button', 'Text', 'Checkbutton']
|
return node.value.func.id in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
||||||
@@ -49,10 +53,12 @@ def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
|||||||
|
|
||||||
if isinstance(call_node.func, ast.Attribute):
|
if isinstance(call_node.func, ast.Attribute):
|
||||||
widget_type = call_node.func.attr
|
widget_type = call_node.func.attr
|
||||||
if isinstance(call_node.func.value, ast.Name):
|
# Allow ttk widgets
|
||||||
module_name = call_node.func.value.id
|
# if isinstance(call_node.func.value, ast.Name):
|
||||||
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
|
# module_name = call_node.func.value.id
|
||||||
return None
|
# resolved = analyzer.imports.get(module_name, module_name)
|
||||||
|
# if resolved in ['ttk', 'tkinter.ttk']:
|
||||||
|
# return None
|
||||||
elif isinstance(call_node.func, ast.Name):
|
elif isinstance(call_node.func, ast.Name):
|
||||||
widget_type = call_node.func.id
|
widget_type = call_node.func.id
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from tk_ast.grid_layout import GridLayoutAnalyzer
|
|||||||
def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
tree = ast.parse(code)
|
tree = ast.parse(code)
|
||||||
analyzer = TkinterAnalyzer()
|
analyzer = TkinterAnalyzer(code)
|
||||||
analyzer.visit(tree)
|
analyzer.visit(tree)
|
||||||
grid_analyzer = GridLayoutAnalyzer()
|
grid_analyzer = GridLayoutAnalyzer()
|
||||||
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
|
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
|
||||||
@@ -18,6 +18,7 @@ def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
|||||||
'widgets': widgets,
|
'widgets': widgets,
|
||||||
'command_callbacks': analyzer.command_callbacks,
|
'command_callbacks': analyzer.command_callbacks,
|
||||||
'bind_events': analyzer.bind_events,
|
'bind_events': analyzer.bind_events,
|
||||||
|
'methods': analyzer.methods,
|
||||||
'success': True
|
'success': True
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { Uri } from 'vscode';
|
import { Uri } from 'vscode';
|
||||||
|
import { runPythonAst } from '../parser/pythonRunner';
|
||||||
|
|
||||||
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||||
public static readonly viewType = 'tkinter-designer';
|
public static readonly viewType = 'tkinter-designer';
|
||||||
public static _instance: TkinterDesignerProvider | undefined;
|
// Removed singleton _instance
|
||||||
private _view?: vscode.WebviewPanel;
|
private _view?: vscode.WebviewPanel;
|
||||||
private _designData: any = {
|
private _designData: any = {
|
||||||
widgets: [],
|
widgets: [],
|
||||||
@@ -14,21 +15,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
|
|
||||||
constructor(private readonly _extensionUri: vscode.Uri) {}
|
constructor(private readonly _extensionUri: vscode.Uri) {}
|
||||||
|
|
||||||
public static createOrShow(extensionUri: vscode.Uri) {
|
// Factory method to create a new panel
|
||||||
const column = vscode.window.activeTextEditor
|
public static createNew(extensionUri: vscode.Uri) {
|
||||||
? vscode.window.activeTextEditor.viewColumn
|
|
||||||
: undefined;
|
|
||||||
if (TkinterDesignerProvider._instance?._view) {
|
|
||||||
console.log('[Webview] Revealing existing panel');
|
|
||||||
TkinterDesignerProvider._instance._view.reveal(column);
|
|
||||||
return TkinterDesignerProvider._instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Webview] Creating new panel');
|
console.log('[Webview] Creating new panel');
|
||||||
const panel = vscode.window.createWebviewPanel(
|
const panel = vscode.window.createWebviewPanel(
|
||||||
TkinterDesignerProvider.viewType,
|
TkinterDesignerProvider.viewType,
|
||||||
'Tkinter Designer',
|
'Tkinter Designer',
|
||||||
column || vscode.ViewColumn.One,
|
vscode.ViewColumn.Beside, // Open beside the current editor
|
||||||
{
|
{
|
||||||
enableScripts: true,
|
enableScripts: true,
|
||||||
retainContextWhenHidden: 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;
|
const provider = new TkinterDesignerProvider(extensionUri);
|
||||||
TkinterDesignerProvider._instance._setWebviewContent(panel.webview);
|
provider._view = panel;
|
||||||
TkinterDesignerProvider._instance._setupMessageHandling(panel.webview);
|
provider._setWebviewContent(panel.webview);
|
||||||
|
provider._setupMessageHandling(panel.webview);
|
||||||
|
|
||||||
panel.onDidDispose(() => {
|
panel.onDidDispose(() => {
|
||||||
console.log('[Webview] Panel disposed');
|
console.log('[Webview] Panel disposed');
|
||||||
if (TkinterDesignerProvider._instance) {
|
provider._view = undefined;
|
||||||
TkinterDesignerProvider._instance._view = undefined;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return TkinterDesignerProvider._instance;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveWebviewView(
|
public resolveWebviewView(
|
||||||
@@ -114,16 +101,81 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
console.log('[Webview] Generating code from webview');
|
console.log('[Webview] Generating code from webview');
|
||||||
this.handleGenerateCode(message.data);
|
this.handleGenerateCode(message.data);
|
||||||
break;
|
break;
|
||||||
|
case 'applyChanges':
|
||||||
|
console.log('[Webview] Applying changes from webview');
|
||||||
|
this.handleApplyChanges(message.data);
|
||||||
|
break;
|
||||||
case 'showInfo':
|
case 'showInfo':
|
||||||
vscode.window.showInformationMessage(message.text);
|
vscode.window.showInformationMessage(message.text);
|
||||||
break;
|
break;
|
||||||
case 'showError':
|
case 'showError':
|
||||||
vscode.window.showErrorMessage(message.text);
|
vscode.window.showErrorMessage(message.text);
|
||||||
break;
|
break;
|
||||||
|
case 'exportProject':
|
||||||
|
this.handleExportProject(message.data);
|
||||||
|
break;
|
||||||
|
case 'importProject':
|
||||||
|
this.handleImportProject();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, undefined);
|
}, 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> {
|
public async getDesignData(): Promise<any> {
|
||||||
return this._designData;
|
return this._designData;
|
||||||
}
|
}
|
||||||
@@ -145,54 +197,62 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
try {
|
try {
|
||||||
console.log('[GenerateCode] Start');
|
console.log('[GenerateCode] Start');
|
||||||
const { CodeGenerator } = await import(
|
const { CodeGenerator } = await import(
|
||||||
'../generator/CodeGenerator'
|
'../generator/codeGenerator'
|
||||||
);
|
);
|
||||||
const generator = new CodeGenerator();
|
const generator = new CodeGenerator();
|
||||||
const pythonCode = generator.generateTkinterCode(designData);
|
const pythonCode = generator.generateTkinterCode(designData);
|
||||||
|
|
||||||
const activeEditor = vscode.window.activeTextEditor;
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
if (!workspaceFolder) {
|
||||||
console.log('[GenerateCode] Writing into active editor');
|
vscode.window.showErrorMessage(
|
||||||
const doc = activeEditor.document;
|
'No workspace folder is open. Please open a folder first.'
|
||||||
const start = new vscode.Position(0, 0);
|
|
||||||
const end = doc.lineCount
|
|
||||||
? doc.lineAt(doc.lineCount - 1).range.end
|
|
||||||
: start;
|
|
||||||
const fullRange = new vscode.Range(start, end);
|
|
||||||
await activeEditor.edit((editBuilder) => {
|
|
||||||
editBuilder.replace(fullRange, pythonCode);
|
|
||||||
});
|
|
||||||
await doc.save();
|
|
||||||
vscode.window.showInformationMessage(
|
|
||||||
'Python code generated into the active file'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log('[GenerateCode] Creating new file');
|
|
||||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
|
||||||
if (!workspaceFolder) {
|
|
||||||
vscode.window.showErrorMessage(
|
|
||||||
'No workspace folder is open. Please open a folder first.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fileName = `app_${Date.now()}.py`;
|
|
||||||
const filePath = path.join(
|
|
||||||
workspaceFolder.uri.fsPath,
|
|
||||||
fileName
|
|
||||||
);
|
|
||||||
const fileUri = Uri.file(filePath);
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const fileBytes = encoder.encode(pythonCode);
|
|
||||||
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
|
||||||
|
|
||||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
|
||||||
await vscode.window.showTextDocument(doc, {
|
|
||||||
preview: false,
|
|
||||||
});
|
|
||||||
vscode.window.showInformationMessage(
|
|
||||||
`Python file created: ${fileName}`
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formName = designData.form.name || 'Form1';
|
||||||
|
let fileName = `${formName}.py`;
|
||||||
|
let fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
const exists = async (u: vscode.Uri) => {
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(u);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (await exists(fileUri)) {
|
||||||
|
fileName = `${formName}_${counter}.py`;
|
||||||
|
fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const fileBytes = encoder.encode(pythonCode);
|
||||||
|
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
||||||
|
|
||||||
|
const doc = await vscode.workspace.openTextDocument(fileUri);
|
||||||
|
await vscode.window.showTextDocument(doc, {
|
||||||
|
preview: false,
|
||||||
|
});
|
||||||
|
vscode.window.showInformationMessage(
|
||||||
|
`Python file created: ${fileName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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');
|
console.log('[GenerateCode] Done');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GenerateCode] Error:', 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 {
|
private _getHtmlForWebview(webview: vscode.Webview): string {
|
||||||
const styleUri = webview.asWebviewUri(
|
const styleUri = webview.asWebviewUri(
|
||||||
vscode.Uri.joinPath(
|
vscode.Uri.joinPath(
|
||||||
|
|||||||
+15
-12
@@ -5,23 +5,26 @@ import { PropertiesPanel } from './components/PropertiesPanel';
|
|||||||
import { EventsPanel } from './components/EventsPanel';
|
import { EventsPanel } from './components/EventsPanel';
|
||||||
import { Canvas } from './components/Canvas';
|
import { Canvas } from './components/Canvas';
|
||||||
import { useMessaging } from './useMessaging';
|
import { useMessaging } from './useMessaging';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useMessaging();
|
useMessaging();
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<ErrorBoundary>
|
||||||
<Toolbar />
|
<div className="container">
|
||||||
<div className="main-content">
|
<Toolbar />
|
||||||
<div className="sidebar">
|
<div className="main-content">
|
||||||
<h3>Widgets</h3>
|
<div className="sidebar">
|
||||||
<Palette />
|
<h3>Widgets</h3>
|
||||||
<PropertiesPanel />
|
<Palette />
|
||||||
<EventsPanel />
|
<PropertiesPanel />
|
||||||
</div>
|
<EventsPanel />
|
||||||
<div className="design-area">
|
</div>
|
||||||
<Canvas />
|
<div className="design-area">
|
||||||
|
<Canvas />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,76 @@ import React, { useRef } from 'react';
|
|||||||
import { useAppDispatch, useAppState } from '../state';
|
import { useAppDispatch, useAppState } from '../state';
|
||||||
import type { WidgetType } from '../types';
|
import type { WidgetType } from '../types';
|
||||||
|
|
||||||
|
import { Widget } from '../types';
|
||||||
|
|
||||||
|
function renderWidgetContent(w: Widget) {
|
||||||
|
switch (w.type) {
|
||||||
|
case 'Label':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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() {
|
export function Canvas() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { design, selectedWidgetId } = useAppState();
|
const { design, selectedWidgetId } = useAppState();
|
||||||
@@ -38,10 +108,13 @@ export function Canvas() {
|
|||||||
const onMove = (ev: MouseEvent) => {
|
const onMove = (ev: MouseEvent) => {
|
||||||
const dx = ev.clientX - startX;
|
const dx = ev.clientX - startX;
|
||||||
const dy = ev.clientY - startY;
|
const dy = ev.clientY - startY;
|
||||||
|
const newX = initX + dx;
|
||||||
|
const newY = initY + dy;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'updateWidget',
|
type: 'updateWidget',
|
||||||
payload: { id, patch: { x: initX + dx, y: initY + dy } },
|
payload: { id, patch: { x: newX, y: newY } },
|
||||||
});
|
});
|
||||||
|
// Auto-expand removed: user wants clipping behavior
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
@@ -52,43 +125,244 @@ export function Canvas() {
|
|||||||
window.addEventListener('mouseup', onUp);
|
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 (
|
return (
|
||||||
<div className="canvas-container">
|
<div className="canvas-container">
|
||||||
<div
|
<div className="window-frame" style={{ position: 'relative' }}>
|
||||||
id="designCanvas"
|
<div className="window-title-bar">
|
||||||
className="design-canvas"
|
<div className="window-title">
|
||||||
ref={containerRef}
|
{design.form?.title || 'Tkinter App'}
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onClick={() => onSelect(null)}
|
|
||||||
>
|
|
||||||
{design.widgets.length === 0 && (
|
|
||||||
<div className="canvas-placeholder">
|
|
||||||
<p>Drag widgets here to start designing</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="window-controls">
|
||||||
{design.widgets.map((w) => (
|
<div className="window-control minimize"></div>
|
||||||
<div
|
<div className="window-control maximize"></div>
|
||||||
key={w.id}
|
<div className="window-control close"></div>
|
||||||
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
|
</div>
|
||||||
style={{
|
</div>
|
||||||
position: 'absolute',
|
<div
|
||||||
left: w.x,
|
id="designCanvas"
|
||||||
top: w.y,
|
className="design-canvas"
|
||||||
width: w.width,
|
style={{
|
||||||
height: w.height,
|
width: design.form.size.width,
|
||||||
}}
|
height: design.form.size.height,
|
||||||
onClick={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
ref={containerRef}
|
||||||
onSelect(w.id);
|
onDragOver={onDragOver}
|
||||||
}}
|
onDrop={onDrop}
|
||||||
onMouseDown={(e) => onMouseDown(e, w.id)}
|
onClick={() => onSelect(null)}
|
||||||
>
|
>
|
||||||
<div className="widget-content">
|
{design.widgets.length === 0 && (
|
||||||
{w.properties?.text || w.type}
|
<div className="canvas-placeholder">
|
||||||
|
<p>Drag widgets here to start designing</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
{design.widgets.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.id}
|
||||||
|
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: w.x,
|
||||||
|
top: w.y,
|
||||||
|
width: w.width,
|
||||||
|
height: w.height,
|
||||||
|
// 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();
|
||||||
|
onSelect(w.id);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => onMouseDown(e, w.id)}
|
||||||
|
>
|
||||||
|
<div className="widget-content">
|
||||||
|
{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>
|
||||||
</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 dispatch = useAppDispatch();
|
||||||
const { design, selectedWidgetId, vscode } = useAppState();
|
const { design, selectedWidgetId, vscode } = useAppState();
|
||||||
const [eventType, setEventType] = useState('command');
|
const [eventType, setEventType] = useState('command');
|
||||||
const [eventName, setEventName] = useState('onClick');
|
const [eventName, setEventName] = useState('on_click');
|
||||||
const [eventCode, setEventCode] = useState('print("clicked")');
|
|
||||||
|
|
||||||
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||||
const widgetEvents = (id: string | undefined) =>
|
const widgetEvents = (id: string | undefined) =>
|
||||||
@@ -20,10 +19,17 @@ export function EventsPanel() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!eventType || !eventName || !eventCode) {
|
if (!eventName) {
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showError',
|
type: 'showError',
|
||||||
text: 'Please fill in all fields: event type, name, and code',
|
text: 'Please fill in handler name',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!eventType) {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'showError',
|
||||||
|
text: 'Please fill in event type',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -33,21 +39,39 @@ export function EventsPanel() {
|
|||||||
widget: w.id,
|
widget: w.id,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
name: eventName,
|
name: eventName,
|
||||||
code: eventCode,
|
code: 'pass', // Default body
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showInfo',
|
type: 'showInfo',
|
||||||
text: `Event added: ${eventType} -> ${eventName}`,
|
text: `Event added: ${eventName}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = (type: string) => {
|
const remove = (type: string, name: string) => {
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
dispatch({ type: 'removeEvent', payload: { widget: w.id, type } });
|
dispatch({
|
||||||
|
type: 'removeEvent',
|
||||||
|
payload: { widget: w.id, type, name },
|
||||||
|
});
|
||||||
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
|
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commonEvents = [
|
||||||
|
'command',
|
||||||
|
'<Button-1>',
|
||||||
|
'<Button-2>',
|
||||||
|
'<Button-3>',
|
||||||
|
'<Double-Button-1>',
|
||||||
|
'<Enter>',
|
||||||
|
'<Leave>',
|
||||||
|
'<FocusIn>',
|
||||||
|
'<FocusOut>',
|
||||||
|
'<Return>',
|
||||||
|
'<Key>',
|
||||||
|
'<Configure>',
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="events-panel">
|
<div className="events-panel">
|
||||||
<h3>Events & Commands</h3>
|
<h3>Events & Commands</h3>
|
||||||
@@ -60,12 +84,42 @@ export function EventsPanel() {
|
|||||||
<label className="property-label">
|
<label className="property-label">
|
||||||
Event Type:
|
Event Type:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div style={{ marginBottom: '8px' }}>
|
||||||
className="event-type-select"
|
<select
|
||||||
type="text"
|
className="event-handler-input"
|
||||||
value={eventType}
|
value={
|
||||||
onChange={(e) => setEventType(e.target.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-handler-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter custom event type"
|
||||||
|
value={eventType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEventType(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<label className="property-label">
|
<label className="property-label">
|
||||||
Handler Name:
|
Handler Name:
|
||||||
</label>
|
</label>
|
||||||
@@ -75,14 +129,6 @@ export function EventsPanel() {
|
|||||||
value={eventName}
|
value={eventName}
|
||||||
onChange={(e) => setEventName(e.target.value)}
|
onChange={(e) => setEventName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<label className="property-label">
|
|
||||||
Handler Code:
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="event-handler-input"
|
|
||||||
value={eventCode}
|
|
||||||
onChange={(e) => setEventCode(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="event-buttons">
|
<div className="event-buttons">
|
||||||
<button className="event-btn primary" onClick={add}>
|
<button className="event-btn primary" onClick={add}>
|
||||||
@@ -97,23 +143,28 @@ export function EventsPanel() {
|
|||||||
widgetEvents(w.id).map((ev) => (
|
widgetEvents(w.id).map((ev) => (
|
||||||
<div
|
<div
|
||||||
className="event-item"
|
className="event-item"
|
||||||
key={`${ev.widget}-${ev.type}`}
|
key={`${ev.widget}-${ev.type}-${ev.name}`}
|
||||||
>
|
>
|
||||||
<div className="event-info">
|
<div className="event-info">
|
||||||
<span className="event-name">
|
<span
|
||||||
|
className="event-name"
|
||||||
|
style={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{ev.type}:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="event-name"
|
||||||
|
style={{ marginLeft: '5px' }}
|
||||||
|
>
|
||||||
{ev.name}
|
{ev.name}
|
||||||
</span>{' '}
|
|
||||||
<span className="event-handler">
|
|
||||||
{ev.type}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="event-code">
|
|
||||||
{ev.code}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="event-actions">
|
<div className="event-actions">
|
||||||
<button
|
<button
|
||||||
className="event-btn secondary"
|
className="event-btn secondary"
|
||||||
onClick={() => remove(ev.type)}
|
onClick={() =>
|
||||||
|
remove(ev.type, ev.name)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { WidgetType } from '../types';
|
|||||||
const WIDGETS: WidgetType[] = [
|
const WIDGETS: WidgetType[] = [
|
||||||
'Label',
|
'Label',
|
||||||
'Button',
|
'Button',
|
||||||
|
'Entry',
|
||||||
'Text',
|
'Text',
|
||||||
'Checkbutton',
|
'Checkbutton',
|
||||||
'Radiobutton',
|
'Radiobutton',
|
||||||
|
|||||||
@@ -4,6 +4,73 @@ import { useAppDispatch, useAppState } from '../state';
|
|||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { design, selectedWidgetId } = useAppState();
|
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 w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||||
|
|
||||||
const update = (patch: Partial<typeof w>) => {
|
const update = (patch: Partial<typeof w>) => {
|
||||||
@@ -27,7 +94,7 @@ export function PropertiesPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="properties-panel">
|
<div className="properties-panel">
|
||||||
<h3>Properties</h3>
|
<h3>Widget Properties</h3>
|
||||||
<div id="propertiesContent">
|
<div id="propertiesContent">
|
||||||
{!w ? (
|
{!w ? (
|
||||||
<p>Select a widget to edit properties</p>
|
<p>Select a widget to edit properties</p>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { useRef } from 'react';
|
import React from 'react';
|
||||||
import { useAppDispatch, useAppState } from '../state';
|
import { useAppDispatch, useAppState } from '../state';
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { vscode, design } = useAppState();
|
const { vscode, design } = useAppState();
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const exportProject = () => {
|
const exportProject = () => {
|
||||||
try {
|
try {
|
||||||
@@ -15,19 +14,9 @@ export function Toolbar() {
|
|||||||
description: 'Tkinter Designer Project',
|
description: 'Tkinter Designer Project',
|
||||||
data: design,
|
data: design,
|
||||||
};
|
};
|
||||||
const jsonString = JSON.stringify(exportData, null, 2);
|
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `tkinter-project-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showInfo',
|
type: 'exportProject',
|
||||||
text: 'Project exported successfully!',
|
data: exportData,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Export error:', error);
|
console.error('Export error:', error);
|
||||||
@@ -38,37 +27,11 @@ export function Toolbar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importProject = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const importProject = () => {
|
||||||
console.log('[Toolbar] Import project');
|
console.log('[Toolbar] Request Import project');
|
||||||
const file = e.target.files?.[0];
|
vscode.postMessage({
|
||||||
if (!file) return;
|
type: 'importProject',
|
||||||
const reader = new FileReader();
|
});
|
||||||
reader.onload = (ev) => {
|
|
||||||
try {
|
|
||||||
const jsonString = ev.target?.result as string;
|
|
||||||
const obj = JSON.parse(jsonString);
|
|
||||||
if (!obj || !obj.data)
|
|
||||||
throw new Error('Invalid project file format');
|
|
||||||
dispatch({ type: 'init', payload: obj.data });
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'showInfo',
|
|
||||||
text: 'Project imported successfully!',
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Import error:', error);
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'showError',
|
|
||||||
text: `Import failed: ${error.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () =>
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'showError',
|
|
||||||
text: 'Failed to read file',
|
|
||||||
});
|
|
||||||
reader.readAsText(file);
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
@@ -96,18 +59,53 @@ export function Toolbar() {
|
|||||||
vscode.postMessage({ type: 'generateCode', data: design });
|
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 (
|
return (
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
id="importFileInput"
|
|
||||||
accept=".json"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={importProject}
|
|
||||||
/>
|
|
||||||
<h2>Tkinter Visual Designer</h2>
|
<h2>Tkinter Visual Designer</h2>
|
||||||
<div className="toolbar-buttons">
|
<div className="toolbar-buttons">
|
||||||
|
<div
|
||||||
|
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
|
<button
|
||||||
id="undoBtn"
|
id="undoBtn"
|
||||||
className="btn btn-outline"
|
className="btn btn-outline"
|
||||||
@@ -137,7 +135,7 @@ export function Toolbar() {
|
|||||||
id="importBtn"
|
id="importBtn"
|
||||||
className="btn btn-outline"
|
className="btn btn-outline"
|
||||||
title="Import project from JSON"
|
title="Import project from JSON"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={importProject}
|
||||||
>
|
>
|
||||||
📥 Import
|
📥 Import
|
||||||
</button>
|
</button>
|
||||||
@@ -149,6 +147,13 @@ export function Toolbar() {
|
|||||||
>
|
>
|
||||||
Generate Code
|
Generate Code
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="applyBtn"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={applyChanges}
|
||||||
|
>
|
||||||
|
Apply Changes
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
id="clearBtn"
|
id="clearBtn"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|||||||
+211
-153
@@ -1,4 +1,5 @@
|
|||||||
import React, { createContext, useContext, useReducer, useMemo } from 'react';
|
import React, { createContext, useContext, useReducer, useMemo } from 'react';
|
||||||
|
import { produce } from 'immer';
|
||||||
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
|
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
@@ -20,15 +21,20 @@ type Action =
|
|||||||
}
|
}
|
||||||
| { type: 'deleteWidget'; payload: { id: string } }
|
| { type: 'deleteWidget'; payload: { id: string } }
|
||||||
| { type: 'addEvent'; payload: EventBinding }
|
| { type: 'addEvent'; payload: EventBinding }
|
||||||
| { type: 'removeEvent'; payload: { widget: string; type: string } }
|
| {
|
||||||
|
type: 'removeEvent';
|
||||||
|
payload: { widget: string; type: string; name?: string };
|
||||||
|
}
|
||||||
| { type: 'clear' }
|
| { type: 'clear' }
|
||||||
| { type: 'undo' }
|
| { type: 'undo' }
|
||||||
| { type: 'redo' }
|
| { type: 'redo' }
|
||||||
| {
|
| {
|
||||||
type: 'setForm';
|
type: 'setForm';
|
||||||
payload: {
|
payload: {
|
||||||
|
name?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: { width: number; height: number };
|
size?: { width: number; height: number };
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,163 +43,211 @@ const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
|
|||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
function clone(data: DesignData): DesignData {
|
// We don't need clone anymore because immer handles immutability
|
||||||
return JSON.parse(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function reducer(state: AppState, action: Action): AppState {
|
function reducer(state: AppState, action: Action): AppState {
|
||||||
console.log('[State] Action:', action.type);
|
console.log('[State] Action:', action.type);
|
||||||
switch (action.type) {
|
|
||||||
case 'init': {
|
// Handle undo/redo separately as they manipulate the history stack directly
|
||||||
const next = {
|
if (action.type === 'undo') {
|
||||||
...state,
|
if (state.historyIndex <= 0) return state;
|
||||||
design: action.payload,
|
const idx = state.historyIndex - 1;
|
||||||
selectedWidgetId: null,
|
console.log('[State] Undo to index:', idx);
|
||||||
};
|
return {
|
||||||
if (!next.design.events) next.design.events = [];
|
...state,
|
||||||
console.log('[State] Init design widgets:', next.design.widgets.length);
|
historyIndex: idx,
|
||||||
return pushHistory(next);
|
design: JSON.parse(JSON.stringify(state.history[idx])), // Deep copy from history to current design
|
||||||
}
|
};
|
||||||
case 'addWidget': {
|
|
||||||
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
||||||
const w: Widget = {
|
|
||||||
id,
|
|
||||||
type: action.payload.type,
|
|
||||||
x: action.payload.x,
|
|
||||||
y: action.payload.y,
|
|
||||||
width: 120,
|
|
||||||
height: 40,
|
|
||||||
properties: { text: action.payload.type },
|
|
||||||
};
|
|
||||||
const design = clone(state.design);
|
|
||||||
design.widgets.push(w);
|
|
||||||
const next = { ...state, design, selectedWidgetId: id };
|
|
||||||
console.log('[State] Added widget:', id, w.type);
|
|
||||||
return pushHistory(next);
|
|
||||||
}
|
|
||||||
case 'selectWidget': {
|
|
||||||
console.log('[State] Select widget:', action.payload.id);
|
|
||||||
return { ...state, selectedWidgetId: action.payload.id };
|
|
||||||
}
|
|
||||||
case 'updateWidget': {
|
|
||||||
const design = clone(state.design);
|
|
||||||
const idx = design.widgets.findIndex(
|
|
||||||
(w) => w.id === action.payload.id
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
design.widgets[idx] = {
|
|
||||||
...design.widgets[idx],
|
|
||||||
...action.payload.patch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const next = { ...state, design };
|
|
||||||
console.log('[State] Updated widget:', action.payload.id);
|
|
||||||
return pushHistory(next);
|
|
||||||
}
|
|
||||||
case 'updateProps': {
|
|
||||||
const design = clone(state.design);
|
|
||||||
const idx = design.widgets.findIndex(
|
|
||||||
(w) => w.id === action.payload.id
|
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
design.widgets[idx] = {
|
|
||||||
...design.widgets[idx],
|
|
||||||
properties: {
|
|
||||||
...design.widgets[idx].properties,
|
|
||||||
...action.payload.properties,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const next = { ...state, design };
|
|
||||||
console.log('[State] Updated properties for:', action.payload.id);
|
|
||||||
return pushHistory(next);
|
|
||||||
}
|
|
||||||
case 'deleteWidget': {
|
|
||||||
const design = clone(state.design);
|
|
||||||
design.widgets = design.widgets.filter(
|
|
||||||
(w) => w.id !== action.payload.id
|
|
||||||
);
|
|
||||||
if (!design.events) design.events = [];
|
|
||||||
design.events = design.events.filter(
|
|
||||||
(e) => e.widget !== action.payload.id
|
|
||||||
);
|
|
||||||
const next = { ...state, design, selectedWidgetId: null };
|
|
||||||
console.log('[State] Deleted widget:', action.payload.id);
|
|
||||||
return pushHistory(next);
|
|
||||||
}
|
|
||||||
case 'addEvent': {
|
|
||||||
const design = clone(state.design);
|
|
||||||
if (!design.events) design.events = [];
|
|
||||||
const exists = design.events.find(
|
|
||||||
(e) =>
|
|
||||||
e.widget === action.payload.widget &&
|
|
||||||
e.type === action.payload.type
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
design.events.push(action.payload);
|
|
||||||
}
|
|
||||||
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget);
|
|
||||||
return pushHistory({ ...state, design });
|
|
||||||
}
|
|
||||||
case 'removeEvent': {
|
|
||||||
const design = clone(state.design);
|
|
||||||
if (!design.events) design.events = [];
|
|
||||||
design.events = design.events.filter(
|
|
||||||
(e) =>
|
|
||||||
!(
|
|
||||||
e.widget === action.payload.widget &&
|
|
||||||
e.type === action.payload.type
|
|
||||||
)
|
|
||||||
);
|
|
||||||
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget);
|
|
||||||
return pushHistory({ ...state, design });
|
|
||||||
}
|
|
||||||
case 'clear': {
|
|
||||||
const design: DesignData = {
|
|
||||||
form: state.design.form,
|
|
||||||
widgets: [],
|
|
||||||
events: [],
|
|
||||||
};
|
|
||||||
console.log('[State] Cleared design');
|
|
||||||
return pushHistory({ ...state, design, selectedWidgetId: null });
|
|
||||||
}
|
|
||||||
case 'undo': {
|
|
||||||
if (state.historyIndex <= 0) return state;
|
|
||||||
const idx = state.historyIndex - 1;
|
|
||||||
console.log('[State] Undo to index:', idx);
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
historyIndex: idx,
|
|
||||||
design: clone(state.history[idx]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'redo': {
|
|
||||||
if (state.historyIndex >= state.history.length - 1) return state;
|
|
||||||
const idx = state.historyIndex + 1;
|
|
||||||
console.log('[State] Redo to index:', idx);
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
historyIndex: idx,
|
|
||||||
design: clone(state.history[idx]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'setForm': {
|
|
||||||
const design = clone(state.design);
|
|
||||||
if (action.payload.title) design.form.title = action.payload.title;
|
|
||||||
if (action.payload.size) design.form.size = action.payload.size;
|
|
||||||
console.log('[State] Set form', action.payload);
|
|
||||||
return pushHistory({ ...state, design });
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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': {
|
||||||
|
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)}`;
|
||||||
|
const w: Widget = {
|
||||||
|
id,
|
||||||
|
type: action.payload.type,
|
||||||
|
x: action.payload.x,
|
||||||
|
y: action.payload.y,
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
properties: { text: action.payload.type },
|
||||||
|
};
|
||||||
|
draft.design.widgets.push(w);
|
||||||
|
draft.selectedWidgetId = id;
|
||||||
|
console.log('[State] Added widget:', id, w.type);
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'selectWidget': {
|
||||||
|
console.log('[State] Select widget:', action.payload.id);
|
||||||
|
draft.selectedWidgetId = action.payload.id;
|
||||||
|
break; // Selection change usually doesn't need a history entry
|
||||||
|
}
|
||||||
|
case 'updateWidget': {
|
||||||
|
const idx = draft.design.widgets.findIndex(
|
||||||
|
(w) => w.id === action.payload.id
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
Object.assign(
|
||||||
|
draft.design.widgets[idx],
|
||||||
|
action.payload.patch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('[State] Updated widget:', action.payload.id);
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'updateProps': {
|
||||||
|
const idx = draft.design.widgets.findIndex(
|
||||||
|
(w) => w.id === action.payload.id
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
draft.design.widgets[idx].properties = {
|
||||||
|
...draft.design.widgets[idx].properties,
|
||||||
|
...action.payload.properties,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'[State] Updated properties for:',
|
||||||
|
action.payload.id
|
||||||
|
);
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'deleteWidget': {
|
||||||
|
draft.design.widgets = draft.design.widgets.filter(
|
||||||
|
(w) => w.id !== action.payload.id
|
||||||
|
);
|
||||||
|
if (!draft.design.events) draft.design.events = [];
|
||||||
|
draft.design.events = draft.design.events.filter(
|
||||||
|
(e) => e.widget !== action.payload.id
|
||||||
|
);
|
||||||
|
draft.selectedWidgetId = null;
|
||||||
|
console.log('[State] Deleted widget:', action.payload.id);
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'addEvent': {
|
||||||
|
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.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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'removeEvent': {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'clear': {
|
||||||
|
draft.design.widgets = [];
|
||||||
|
draft.design.events = [];
|
||||||
|
draft.selectedWidgetId = null;
|
||||||
|
console.log('[State] Cleared design');
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'setForm': {
|
||||||
|
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);
|
||||||
|
pushHistoryDraft(draft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushHistory(next: AppState): AppState {
|
// Helper to push state to history within a draft
|
||||||
const hist = next.history.slice(0, next.historyIndex + 1);
|
function pushHistoryDraft(draft: AppState) {
|
||||||
hist.push(clone(next.design));
|
// We can't use JSON.parse/stringify on draft directly efficiently,
|
||||||
console.log('[State] History length:', hist.length);
|
// but since we are in produce, 'draft.design' tracks changes.
|
||||||
return { ...next, history: hist, historyIndex: hist.length - 1 };
|
// 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() {
|
export function useAppState() {
|
||||||
@@ -217,7 +271,11 @@ export function AppProvider({
|
|||||||
}) {
|
}) {
|
||||||
const initialDesign: DesignData = useMemo(
|
const initialDesign: DesignData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
form: { title: 'My App', size: { width: 800, height: 600 } },
|
form: {
|
||||||
|
name: 'Form1',
|
||||||
|
title: 'My App',
|
||||||
|
size: { width: 800, height: 600 },
|
||||||
|
},
|
||||||
widgets: [],
|
widgets: [],
|
||||||
events: [],
|
events: [],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type WidgetType =
|
export type WidgetType =
|
||||||
| 'Label'
|
| 'Label'
|
||||||
| 'Button'
|
| 'Button'
|
||||||
|
| 'Entry'
|
||||||
| 'Text'
|
| 'Text'
|
||||||
| 'Checkbutton'
|
| 'Checkbutton'
|
||||||
| 'Radiobutton';
|
| 'Radiobutton';
|
||||||
@@ -24,8 +25,10 @@ export interface EventBinding {
|
|||||||
|
|
||||||
export interface DesignData {
|
export interface DesignData {
|
||||||
form: {
|
form: {
|
||||||
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
size: { width: number; height: number };
|
size: { width: number; height: number };
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
widgets: Widget[];
|
widgets: Widget[];
|
||||||
events: EventBinding[];
|
events: EventBinding[];
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export function useMessaging() {
|
|||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
dispatch({ type: 'init', payload: message.data });
|
dispatch({ type: 'init', payload: message.data });
|
||||||
break;
|
break;
|
||||||
|
case 'updateFormName':
|
||||||
|
dispatch({
|
||||||
|
type: 'setForm',
|
||||||
|
payload: { name: message.name },
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -25,7 +31,12 @@ export function useMessaging() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initializedRef.current) return;
|
if (!initializedRef.current) return;
|
||||||
vscode.postMessage({ type: 'designUpdated', data: design });
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
vscode.postMessage({ type: 'designUpdated', data: design });
|
||||||
|
}, 200); // 200ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
}, [design, vscode]);
|
}, [design, vscode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+209
-23
@@ -77,12 +77,13 @@ body {
|
|||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--vscode-button-foreground);
|
color: white;
|
||||||
border: 1px solid var(--vscode-button-border);
|
border: 1px solid var(--vscode-button-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:hover {
|
.btn-outline:hover {
|
||||||
background-color: var(--vscode-button-hoverBackground);
|
background-color: black;
|
||||||
|
border-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:disabled {
|
.btn-outline:disabled {
|
||||||
@@ -434,22 +435,16 @@ body {
|
|||||||
|
|
||||||
.canvas-container {
|
.canvas-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px;
|
padding: 40px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
background-color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
.design-canvas {
|
align-items: flex-start;
|
||||||
min-height: 600px;
|
|
||||||
min-width: 800px;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
border: 2px dashed var(--vscode-panel-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
position: relative;
|
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
linear-gradient(45deg, #d0d0d0 25%, transparent 25%),
|
||||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
linear-gradient(-45deg, #d0d0d0 25%, transparent 25%),
|
||||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
linear-gradient(45deg, transparent 75%, #d0d0d0 75%),
|
||||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
|
linear-gradient(-45deg, transparent 75%, #d0d0d0 75%);
|
||||||
background-size: 20px 20px;
|
background-size: 20px 20px;
|
||||||
background-position:
|
background-position:
|
||||||
0 0,
|
0 0,
|
||||||
@@ -458,9 +453,138 @@ body {
|
|||||||
-10px 0px;
|
-10px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-canvas.drag-over {
|
.window-frame {
|
||||||
border-color: var(--vscode-focusBorder);
|
display: flex;
|
||||||
background-color: var(--vscode-list-dropBackground);
|
flex-direction: column;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.canvas-placeholder {
|
||||||
@@ -497,6 +621,71 @@ body {
|
|||||||
box-shadow: 0 0 0 1px var(--vscode-button-background);
|
box-shadow: 0 0 0 1px var(--vscode-button-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid var(--vscode-button-background);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover {
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.n {
|
||||||
|
top: -5px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.s {
|
||||||
|
bottom: -5px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.e {
|
||||||
|
top: 50%;
|
||||||
|
right: -5px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.w {
|
||||||
|
top: 50%;
|
||||||
|
left: -5px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.ne {
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.nw {
|
||||||
|
top: -5px;
|
||||||
|
left: -5px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.se {
|
||||||
|
bottom: -5px;
|
||||||
|
right: -5px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.sw {
|
||||||
|
bottom: -5px;
|
||||||
|
left: -5px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-widget.widget-label {
|
.canvas-widget.widget-label {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -516,8 +705,7 @@ body {
|
|||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
resize: both;
|
overflow: hidden;
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-widget.widget-checkbutton {
|
.canvas-widget.widget-checkbutton {
|
||||||
@@ -546,8 +734,6 @@ body {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user