init commit

This commit is contained in:
IDK 2025-12-23 05:21:30 +03:00
parent c87e51053c
commit 9754f36c37
43 changed files with 1927 additions and 666 deletions

12
.dockerignore Normal file
View File

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

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# Use Node.js LTS
FROM node:18-slim
# Install Python 3 and git
RUN apt-get update && apt-get install -y python3 python3-pip git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the extension
RUN npm run compile
# Install vsce to package the extension
RUN npm install -g @vscode/vsce
# Default command to package the extension
CMD ["vsce", "package", "--out", "extension.vsix"]

11
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "tkinter-designer",
"version": "0.1.0",
"dependencies": {
"immer": "^11.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@ -1243,6 +1244,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View File

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

View File

@ -1,84 +1,32 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { CodeGenerator } from './generator/CodeGenerator';
import { CodeParser } from './parser/CodeParser';
import { CodeGenerator } from './generator/codeGenerator';
import { CodeParser } from './parser/codeParser';
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
export function activate(context: vscode.ExtensionContext) {
const provider = new TkinterDesignerProvider(context.extensionUri);
TkinterDesignerProvider._instance = provider;
// We don't register a provider here anymore for the global viewType
// because we create panels on demand.
// Store active panels if needed, but for now we just create them.
const openDesignerCommand = vscode.commands.registerCommand(
'tkinter-designer.openDesigner',
() => {
TkinterDesignerProvider.createOrShow(context.extensionUri);
TkinterDesignerProvider.createNew(context.extensionUri);
}
);
const generateCodeCommand = vscode.commands.registerCommand(
'tkinter-designer.generateCode',
async () => {
console.log('[GenerateCode] Command invoked');
const generator = new CodeGenerator();
const designData = await provider.getDesignData();
if (
!designData ||
!designData.widgets ||
designData.widgets.length === 0
) {
console.log('[GenerateCode] No design data');
vscode.window.showWarningMessage(
'No design data found. Please open the designer and create some widgets first.'
);
return;
}
const pythonCode = generator.generateTkinterCode(designData);
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') {
console.log('[GenerateCode] Writing into active Python file');
const doc = activeEditor.document;
const start = new vscode.Position(0, 0);
const end = doc.lineCount
? doc.lineAt(doc.lineCount - 1).range.end
: start;
const fullRange = new vscode.Range(start, end);
await activeEditor.edit((editBuilder) => {
editBuilder.replace(fullRange, pythonCode);
});
await doc.save();
vscode.window.showInformationMessage(
'Python code generated into the active file'
);
} else {
console.log('[GenerateCode] Creating new Python file');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
return;
}
const fileName = `app_${Date.now()}.py`;
const filePath = path.join(
workspaceFolder.uri.fsPath,
fileName
);
const fileUri = vscode.Uri.file(filePath);
const encoder = new TextEncoder();
const fileBytes = encoder.encode(pythonCode);
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc, { preview: false });
vscode.window.showInformationMessage(
`Python file created: ${fileName}`
);
}
console.log('[GenerateCode] Done');
}
);
// Removed global generateCodeCommand because generate is now handled inside the Webview Provider instance
// or we need a way to target the specific panel.
// The previous generateCodeCommand was flawed because it relied on a singleton provider.
// Now, generation is triggered FROM the UI (webview) via messages, which is handled in TkinterDesignerProvider.handleGenerateCode.
// If we want a command palette action, it needs to know WHICH designer to use.
// For simplicity, we remove the global command or make it check for active webview (hard with multiple).
// Let's keep it but warn it only works if we tracked instances.
// Actually, best practice: actions are context-based. The webview has a "Generate" button.
// However, parseCodeCommand is useful. It should open a NEW designer with the parsed code.
const parseCodeCommand = vscode.commands.registerCommand(
'tkinter-designer.parseCode',
@ -89,24 +37,36 @@ export function activate(context: vscode.ExtensionContext) {
if (activeEditor && activeEditor.document.languageId === 'python') {
const parser = new CodeParser();
const code = activeEditor.document.getText();
const fileName = path.basename(
activeEditor.document.fileName,
'.py'
);
console.log('[ParseCode] Code length:', code.length);
try {
const designData = await parser.parseCodeToDesign(code);
const designData = await parser.parseCodeToDesign(
code,
fileName
);
if (
designData &&
designData.widgets &&
designData.widgets.length > 0
) {
console.log('[ParseCode] Widgets found:', designData.widgets.length);
const designerInstance =
TkinterDesignerProvider.createOrShow(
console.log(
'[ParseCode] Widgets found:',
designData.widgets.length
);
// Create a NEW designer instance for this file
const designerInstance = TkinterDesignerProvider.createNew(
context.extensionUri
);
if (designerInstance) {
designerInstance.loadDesignData(designData);
} else {
}
);
// We need to wait for webview to be ready?
// The createNew sets up message handling.
// We can send data immediately? No, webview needs to load React.
// We can set initial data in the provider.
designerInstance.loadDesignData(designData);
vscode.window.showInformationMessage(
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
@ -134,7 +94,6 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
openDesignerCommand,
generateCodeCommand,
parseCodeCommand
);
}

View File

@ -18,14 +18,21 @@ export class CodeGenerator {
public generateTkinterCode(designData: DesignData): string {
this.designData = designData;
console.log('[Generator] Start, widgets:', designData.widgets.length, 'events:', designData.events?.length || 0);
const className = designData.form.className || 'Application';
console.log(
'[Generator] Start, widgets:',
designData.widgets.length,
'events:',
designData.events?.length || 0
);
const lines: string[] = [];
const nameMap = generateVariableNames(designData.widgets);
lines.push('import tkinter as tk');
lines.push('from tkinter import ttk');
lines.push('');
lines.push('class Application:');
lines.push(`class ${className}:`);
this.indentLevel = 1;
lines.push(this.indent('def __init__(self):'));
@ -47,7 +54,6 @@ export class CodeGenerator {
designData.widgets.forEach((widget) => {
console.log('[Generator] Widget:', widget.id, widget.type);
lines.push(...this.generateWidgetCode(widget, nameMap));
lines.push('');
});
this.indentLevel = 1;
@ -72,12 +78,65 @@ export class CodeGenerator {
this.indentLevel = 0;
lines.push('if __name__ == "__main__":');
this.indentLevel = 1;
lines.push(this.indent('app = Application()'));
lines.push(this.indent('try:'));
this.indentLevel = 2;
lines.push(this.indent(`app = ${className}()`));
lines.push(this.indent('app.run()'));
this.indentLevel = 1;
lines.push(this.indent('except Exception as e:'));
this.indentLevel = 2;
lines.push(this.indent('import traceback'));
lines.push(this.indent('traceback.print_exc()'));
lines.push(this.indent('input("Press Enter to exit...")'));
return lines.join('\n');
}
public generateCreateWidgetsBody(designData: DesignData): string {
this.designData = designData;
const lines: string[] = [];
const nameMap = generateVariableNames(designData.widgets);
this.indentLevel = 2; // Inside class -> inside create_widgets
designData.widgets.forEach((widget) => {
lines.push(...this.generateWidgetCode(widget, nameMap));
});
return lines.join('\n');
}
public generateEventHandler(event: any): string {
// Simple helper to generate a single event handler method
// Used when injecting new methods
const lines: string[] = [];
// Use existing signature if available (to preserve user arguments)
// Note: signature does not include 'def ' prefix in our current extraction logic?
// Let's check base.py: sig_raw = self.source_code[def_start_idx : start_idx].strip()
// It includes 'def name(...)'.
// So we just use it directly.
if (event.signature) {
lines.push(` ${event.signature}:`);
} else {
lines.push(` def ${event.name}(self, event=None):`);
}
if (event.code) {
const codeContent =
typeof event.code === 'string'
? event.code
: String(event.code || '');
const body = codeContent
.split('\n')
.map((l: string) => ' ' + l)
.join('\n');
lines.push(body);
} else {
lines.push(' pass');
}
return lines.join('\n');
}
private generateWidgetCode(
widget: WidgetData,
nameMap: Map<string, string>
@ -96,7 +155,9 @@ export class CodeGenerator {
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
const contentLines = generateWidgetContent(widget, varName);
contentLines.forEach((line) => lines.push(this.indent(line)));
if (contentLines.length > 0) {
contentLines.forEach((l) => lines.push(this.indent(l)));
}
lines.push(
...getWidgetEventBindings(

View File

@ -8,22 +8,47 @@ export function generateEventHandlers(
const lines: string[] = [];
if (designData.events && designData.events.length > 0) {
// Use a Set to avoid duplicates if multiple widgets use the same handler
const handledNames = new Set<string>();
designData.events.forEach((event: Event) => {
const handlerName = event.name;
const isBindEvent =
event.type.startsWith('<') && event.type.endsWith('>');
let hasCode = false;
const widget = (designData.widgets || []).find(
(w) => w.id === event.widget
);
if (isBindEvent) {
lines.push(indentDef(`def ${handlerName}(self, event):`));
if (handledNames.has(handlerName)) {
return;
}
handledNames.add(handlerName);
// Use preserved signature if available
// Need to cast to any because Event interface might not have signature yet in types.ts (but we added it in runtime)
const signature = (event as any).signature;
if (signature) {
lines.push(indentDef(`${signature}:`));
} else {
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) {
if (line.trim()) {
lines.push(indentBody(line));

View File

@ -17,11 +17,13 @@ export interface WidgetData {
export interface DesignData {
form: {
name: string;
title: string;
size: {
width: number;
height: number;
};
className?: string;
};
widgets: WidgetData[];
events?: Event[];

View File

@ -27,10 +27,10 @@ export function generateVariableNames(
let baseName = widget.type.toLowerCase();
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
if (baseName === 'button') baseName = 'btn';
if (baseName === 'entry') baseName = 'entry';
if (baseName === 'checkbutton') baseName = 'chk';
if (baseName === 'radiobutton') baseName = 'radio';
if (baseName === 'label') baseName = 'lbl';
const count = (counts.get(baseName) || 0) + 1;
counts.set(baseName, count);

View File

@ -1,137 +1,51 @@
import * as vscode from 'vscode';
import { DesignData, WidgetData, Event } from '../generator/types';
import { runPythonAst } from './pythonRunner';
import { convertASTResultToDesignData } from './astConverter';
export class CodeParser {
public async parseCodeToDesign(
pythonCode: string
pythonCode: string,
filename?: string
): Promise<DesignData | null> {
console.log(
'[Parser] parseCodeToDesign start, code length:',
pythonCode.length
);
const astRaw = await runPythonAst(pythonCode);
if (astRaw && astRaw.error) {
console.error('[Parser] Python AST error:', astRaw.error);
vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`);
return null;
}
const astDesign = convertASTResultToDesignData(astRaw);
if (astDesign && astDesign.widgets && astDesign.widgets.length > 0) {
if (astDesign) {
// AST parsing was successful (even if 0 widgets, it's a valid result)
console.log(
'[Parser] AST parsed widgets:',
astDesign.widgets.length
);
if (filename) {
astDesign.form.name = filename;
}
return astDesign;
}
console.log('[Parser] AST returned no widgets, using regex fallback');
const regexDesign = this.parseWithRegexInline(pythonCode);
// If AST failed completely (e.g. syntax error or script failure), we can't trust regex to be safe for editing.
// It's better to fail and let user know the code is too complex or broken.
console.log(
'[Parser] Regex parsed widgets:',
regexDesign?.widgets?.length || 0
'[Parser] AST failed. Regex fallback is disabled for safety.'
);
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 {
const widgetRegex =
/(self\.)?(\w+)\s*=\s*tk\.(Label|Button|Text|Checkbutton|Radiobutton)\s*\(([^)]*)\)/g;
const placeRegex = /(self\.)?(\w+)\.place\s*\(([^)]*)\)/g;
const titleRegex = /\.title\s*\(\s*(["'])(.*?)\1\s*\)/;
const geometryRegex = /\.geometry\s*\(\s*(["'])(\d+)x(\d+)\1\s*\)/;
const widgets: WidgetData[] = [];
const widgetMap = new Map<string, WidgetData>();
let formTitle = 'App';
let formWidth = 800;
let formHeight = 600;
const tMatch = code.match(titleRegex);
if (tMatch) formTitle = tMatch[2];
const gMatch = code.match(geometryRegex);
if (gMatch) {
formWidth = parseInt(gMatch[2], 10) || 800;
formHeight = parseInt(gMatch[3], 10) || 600;
}
let m: RegExpExecArray | null;
while ((m = widgetRegex.exec(code)) !== null) {
const varName = m[2];
const type = m[3];
const paramStr = m[4] || '';
const id = varName;
const w: WidgetData = {
id,
type,
x: 0,
y: 0,
width: 100,
height: 25,
properties: {},
};
const textMatch = paramStr.match(/text\s*=\s*(["'])(.*?)\1/);
if (textMatch) {
w.properties.text = textMatch[2];
}
const widthMatch = paramStr.match(/width\s*=\s*(\d+)/);
if (widthMatch) {
const wv = parseInt(widthMatch[1], 10);
if (!isNaN(wv)) w.width = wv;
}
const heightMatch = paramStr.match(/height\s*=\s*(\d+)/);
if (heightMatch) {
const hv = parseInt(heightMatch[1], 10);
if (!isNaN(hv)) w.height = hv;
}
widgets.push(w);
widgetMap.set(varName, w);
}
let p: RegExpExecArray | null;
while ((p = placeRegex.exec(code)) !== null) {
const varName = p[2];
const params = p[3];
const w = widgetMap.get(varName);
if (!w) continue;
const getNum = (key: string) => {
const r = new RegExp(key + '\\s*[:=]\\s*(\d+)');
const mm = params.match(r);
return mm ? parseInt(mm[1], 10) : undefined;
};
const x = getNum('x');
const y = getNum('y');
const width = getNum('width');
const height = getNum('height');
if (typeof x === 'number') w.x = x;
if (typeof y === 'number') w.y = y;
if (typeof width === 'number') w.width = width;
if (typeof height === 'number') w.height = height;
}
const events: Event[] = [];
const configRegex =
/(self\.)?(\w+)\.config\s*\(\s*command\s*=\s*(self\.)?(\w+)\s*\)/g;
let c: RegExpExecArray | null;
while ((c = configRegex.exec(code)) !== null) {
const varName = c[2];
const handler = c[4];
if (widgetMap.has(varName)) {
events.push({
widget: varName,
type: 'command',
name: handler,
code: '',
});
}
}
if (widgets.length === 0) {
console.log('[Parser] Regex found 0 widgets');
return null;
}
return {
form: {
title: formTitle,
size: { width: formWidth, height: formHeight },
},
widgets,
events,
};
}
// Regex parser removed to prevent "Zombie Code" issues where regex sees widgets
// but AST doesn't, leading to partial updates that break the file.
}

View File

@ -19,11 +19,14 @@ export function convertASTResultToDesignData(
(astResult.window && astResult.window.height) ||
astResult.height ||
600;
let className =
(astResult.window && astResult.window.className) || 'Application';
let counter = 0;
const allowedTypes = new Set([
'Label',
'Button',
'Entry',
'Text',
'Checkbutton',
'Radiobutton',
@ -68,17 +71,30 @@ export function convertASTResultToDesignData(
}
const events: Event[] = [];
const methods = astResult.methods || {};
if (astResult.command_callbacks) {
for (const callback of astResult.command_callbacks) {
const rawName = callback.command?.name;
const cleanName = rawName
? String(rawName).replace(/^self\./, '')
: `on_${callback.widget}_command`;
const methodData = methods[cleanName];
// methodData is { body: string, signature: string } or string (legacy/lambda)
const codeBody =
typeof methodData === 'object' ? methodData.body : methodData;
const signature =
typeof methodData === 'object'
? methodData.signature
: undefined;
events.push({
widget: callback.widget,
type: 'command',
name: cleanName,
code: callback.command?.lambda_body || '',
code: codeBody || callback.command?.lambda_body || '',
// @ts-ignore - attaching signature for generator
signature: signature,
});
}
}
@ -88,22 +104,72 @@ export function convertASTResultToDesignData(
const cleanName = rawName
? String(rawName).replace(/^self\./, '')
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
const methodData = methods[cleanName];
const codeBody =
typeof methodData === 'object' ? methodData.body : methodData;
const signature =
typeof methodData === 'object'
? methodData.signature
: undefined;
events.push({
widget: bindEvent.widget,
type: bindEvent.event,
name: cleanName,
code: bindEvent.callback?.lambda_body || '',
code: codeBody || bindEvent.callback?.lambda_body || '',
// @ts-ignore
signature: signature,
});
}
}
if (astResult.widgets) {
for (const w of astResult.widgets) {
const p = w.properties || w.params || {};
if (p.command) {
const widgetId = w.variable_name;
const cmd = p.command;
const rawName = cmd.name || cmd;
const cleanName = String(rawName).replace(/^self\./, '');
// Avoid duplicates if already added by command_callbacks
const exists = events.find(
(e) => e.widget === widgetId && e.type === 'command'
);
if (!exists) {
const methodData = methods[cleanName];
const codeBody =
typeof methodData === 'object'
? methodData.body
: methodData;
const signature =
typeof methodData === 'object'
? methodData.signature
: undefined;
events.push({
widget: widgetId,
type: 'command',
name: cleanName,
code: codeBody || '',
// @ts-ignore
signature: signature,
});
}
}
}
}
const keptIds = new Set(widgets.map((w) => w.id));
const filteredEvents = events.filter((e) => keptIds.has(e.widget));
const result: DesignData = {
form: {
name: 'Form',
title: formTitle,
size: { width: formWidth, height: formHeight },
className: className,
},
widgets,
events: filteredEvents.length ? filteredEvents : [],

View File

@ -2,79 +2,143 @@ import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { spawn } from 'child_process';
import * as vscode from 'vscode';
async function executePythonScript(
pythonScriptPath: string,
pythonFilePath: string
pythonCode: string
): Promise<string> {
return await new Promise((resolve, reject) => {
const pythonCommand = getPythonCommand();
const start = Date.now();
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath);
const process = spawn(pythonCommand, [
pythonScriptPath,
pythonFilePath,
]);
let result = '';
let errorOutput = '';
const commandsToTry = await getPythonCommands();
let lastError: Error | null = null;
process.stdout.on('data', (data) => {
result += data.toString();
});
for (const cmd of commandsToTry) {
try {
return await new Promise((resolve, reject) => {
const start = Date.now();
console.log('[PythonRunner] Spawning:', cmd, pythonScriptPath);
process.stderr.on('data', (data) => {
errorOutput += data.toString();
});
const process = spawn(cmd, [pythonScriptPath]);
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}`
)
let result = '';
let errorOutput = '';
// Write code to stdin
try {
process.stdin.write(pythonCode);
process.stdin.end();
} catch (e) {
console.error('[PythonRunner] Error writing to stdin:', e);
}
process.stdout.on('data', (data) => {
result += data.toString();
});
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 {
return process.platform === 'win32' ? 'python' : 'python3';
}
// 3. Common commands
if (process.platform === 'win32') {
commands.push('py');
commands.push('python');
} else {
commands.push('python3');
commands.push('python');
}
function createTempPythonFile(pythonCode: string): string {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, `tk_ast_${Date.now()}.py`);
fs.writeFileSync(tempFilePath, pythonCode, 'utf8');
return tempFilePath;
}
function cleanupTempFile(tempFile: string): void {
try {
fs.unlinkSync(tempFile);
} catch {}
// Unique
return Array.from(new Set(commands));
}
export async function runPythonAst(pythonCode: string): Promise<any | null> {
const tempFilePath = createTempPythonFile(pythonCode);
try {
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
const output = await executePythonScript(
pythonScriptPath,
tempFilePath
);
const output = await executePythonScript(pythonScriptPath, pythonCode);
console.log('[PythonRunner] Received AST JSON length:', output.length);
const parsed = JSON.parse(output);
return parsed;
try {
const parsed = JSON.parse(output);
return parsed;
} catch (parseError) {
console.error(
'[PythonRunner] JSON parse error:',
parseError,
'Output:',
output
);
return null;
}
} catch (err) {
console.error('[PythonRunner] Error running Python AST:', err);
return null;
} finally {
cleanupTempFile(tempFilePath);
console.log('[PythonRunner] Temp file cleaned:', tempFilePath);
}
}

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,7 @@
import ast
import textwrap
import tokenize
import io
from typing import Dict, List, Any, Optional
from .imports import handle_import, handle_import_from
@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
class TkinterAnalyzer(ast.NodeVisitor):
def __init__(self):
def __init__(self, source_code: str = ""):
self.source_code = source_code
self.line_offsets = [0]
current = 0
if source_code:
for line in source_code.splitlines(keepends=True):
current += len(line)
self.line_offsets.append(current)
self.widgets: List[Dict[str, Any]] = []
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
self.imports: Dict[str, str] = {}
@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
self.event_handlers: List[Dict[str, Any]] = []
self.command_callbacks: List[Dict[str, Any]] = []
self.bind_events: List[Dict[str, Any]] = []
self.methods: Dict[str, str] = {}
def visit_Import(self, node: ast.Import):
handle_import(self, node)
@ -36,12 +48,139 @@ class TkinterAnalyzer(ast.NodeVisitor):
def visit_ClassDef(self, node: ast.ClassDef):
prev = self.current_class
enter_class(self, node)
# Determine main application class
# Logic:
# 1. Prefer class named 'Application'
# 2. Else prefer class inheriting from tk.Tk or Tk
# 3. Fallback to first class found if nothing else set
is_tk_class = False
for base in node.bases:
if isinstance(base, ast.Attribute) and base.attr == 'Tk':
is_tk_class = True
elif isinstance(base, ast.Name) and base.id == 'Tk':
is_tk_class = True
if node.name == 'Application':
self.window_config['className'] = node.name
elif is_tk_class and self.window_config.get('className') != 'Application':
self.window_config['className'] = node.name
elif not self.window_config.get('className'):
self.window_config['className'] = node.name
self.generic_visit(node)
exit_class(self, prev)
def visit_FunctionDef(self, node: ast.FunctionDef):
prev = self.current_method
enter_function(self, node)
if self.current_class and node.name not in ['__init__', 'create_widgets', 'run'] and self.source_code and node.body:
try:
# Calculate start index based on 'def' keyword and colon
# We need to find the colon that ends the definition
def_start_idx = self.line_offsets[node.lineno - 1] + node.col_offset
# Scan tokens to find the colon after the function definition
# We start tokenizing from the def line
# We use a safe approach to find the end of the header
header_end_idx = -1
try:
# Create a byte stream for tokenize, starting from def line
# We need the full source code for context
# But tokenize expects bytes
# Optimization: only tokenize the relevant chunk to avoid perf hit on large files
# We can guess the header won't be longer than, say, 10 lines or 1000 chars?
# Let's take a generous chunk
chunk_start = def_start_idx
chunk_end = min(len(self.source_code), def_start_idx + 2000)
chunk = self.source_code[chunk_start:chunk_end]
# Better approach: Just find the ':' char that is not enclosed in brackets/parens/quotes
# Manually parsing characters is safer than partial tokenize if we want to be quick
depth = 0
in_quote = False
quote_char = ''
for i, char in enumerate(chunk):
if in_quote:
if char == quote_char:
# Check for escaping? Simplified check
if i > 0 and chunk[i-1] != '\\':
in_quote = False
continue
if char in ['"', "'"]:
in_quote = True
quote_char = char
elif char in ['(', '[', '{']:
depth += 1
elif char in [')', ']', '}']:
depth -= 1
elif char == ':' and depth == 0:
# Found the colon!
# Add 1 to include the colon in signature, but body starts AFTER it
header_end_idx = chunk_start + i + 1
break
except Exception:
# Fallback to old method if manual parsing fails
start_node = node.body[0]
header_end_idx = self.line_offsets[start_node.lineno - 1] + start_node.col_offset
if header_end_idx != -1:
start_idx = header_end_idx
# End index: logic remains using the last node end
# Ideally we should include trailing comments, but that's hard without full tokenization
# For now, sticking to last node end is safe enough for "body" logic
end_node = node.body[-1]
end_idx = self.line_offsets[end_node.end_lineno - 1] + end_node.end_col_offset
if start_idx < end_idx:
body_text = self.source_code[start_idx:end_idx]
# Dedent logic needs care because body_text now starts with newline(s) and comments
# We should remove leading empty lines, then determine indentation from the first non-empty line
lines = body_text.splitlines(keepends=True)
first_code_line_idx = 0
for idx, line in enumerate(lines):
if line.strip():
first_code_line_idx = idx
break
# Calculate indentation of the first non-empty line
first_line = lines[first_code_line_idx]
indent_len = len(first_line) - len(first_line.lstrip())
# Construct body: Keep the leading newlines/comments, but dedent them?
# Actually, textwrap.dedent works on the whole block.
# But if the first line is blank, dedent might fail to detect common indent?
# textwrap.dedent removes common leading whitespace from every line.
dedented_body = textwrap.dedent(body_text)
# Signature: everything before start_idx
sig_raw = self.source_code[def_start_idx : start_idx].strip()
# Remove trailing colon if present (it should be)
if sig_raw.endswith(':'):
sig_raw = sig_raw[:-1].strip()
self.methods[node.name] = {
'body': dedented_body.strip(),
'signature': sig_raw
}
except Exception as e:
# print(f"Error extracting method {node.name}: {e}")
pass
self.generic_visit(node)
exit_function(self, prev)

View File

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

View File

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

View File

@ -19,10 +19,14 @@ def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool:
widget_type = call_node.func.attr
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
return False
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
# Check if module_name resolves to ttk
resolved = analyzer.imports.get(module_name, module_name)
if resolved in ['ttk', 'tkinter.ttk']:
return widget_type in ['Label', 'Button', 'Entry', 'Frame', 'Checkbutton', 'Radiobutton']
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
if not isinstance(node.value, ast.Call):
@ -31,7 +35,7 @@ def is_widget_creation(analyzer, node: ast.Assign) -> bool:
if isinstance(node.value.func, ast.Attribute):
return is_tkinter_widget_call(analyzer, node.value)
elif isinstance(node.value.func, ast.Name):
return node.value.func.id in ['Label', 'Button', 'Text', 'Checkbutton']
return node.value.func.id in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
return False
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
@ -49,10 +53,12 @@ def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
if isinstance(call_node.func, ast.Attribute):
widget_type = call_node.func.attr
if isinstance(call_node.func.value, ast.Name):
module_name = call_node.func.value.id
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
return None
# Allow ttk widgets
# if isinstance(call_node.func.value, ast.Name):
# module_name = call_node.func.value.id
# resolved = analyzer.imports.get(module_name, module_name)
# if resolved in ['ttk', 'tkinter.ttk']:
# return None
elif isinstance(call_node.func, ast.Name):
widget_type = call_node.func.id
else:

View File

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

View File

@ -1,10 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { Uri } from 'vscode';
import { runPythonAst } from '../parser/pythonRunner';
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'tkinter-designer';
public static _instance: TkinterDesignerProvider | undefined;
// Removed singleton _instance
private _view?: vscode.WebviewPanel;
private _designData: any = {
widgets: [],
@ -14,21 +15,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
constructor(private readonly _extensionUri: vscode.Uri) {}
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
if (TkinterDesignerProvider._instance?._view) {
console.log('[Webview] Revealing existing panel');
TkinterDesignerProvider._instance._view.reveal(column);
return TkinterDesignerProvider._instance;
}
// Factory method to create a new panel
public static createNew(extensionUri: vscode.Uri) {
console.log('[Webview] Creating new panel');
const panel = vscode.window.createWebviewPanel(
TkinterDesignerProvider.viewType,
'Tkinter Designer',
column || vscode.ViewColumn.One,
vscode.ViewColumn.Beside, // Open beside the current editor
{
enableScripts: true,
retainContextWhenHidden: true,
@ -40,24 +33,18 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
],
}
);
if (!TkinterDesignerProvider._instance) {
TkinterDesignerProvider._instance = new TkinterDesignerProvider(
extensionUri
);
}
TkinterDesignerProvider._instance._view = panel;
TkinterDesignerProvider._instance._setWebviewContent(panel.webview);
TkinterDesignerProvider._instance._setupMessageHandling(panel.webview);
const provider = new TkinterDesignerProvider(extensionUri);
provider._view = panel;
provider._setWebviewContent(panel.webview);
provider._setupMessageHandling(panel.webview);
panel.onDidDispose(() => {
console.log('[Webview] Panel disposed');
if (TkinterDesignerProvider._instance) {
TkinterDesignerProvider._instance._view = undefined;
}
provider._view = undefined;
});
return TkinterDesignerProvider._instance;
return provider;
}
public resolveWebviewView(
@ -114,16 +101,81 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
console.log('[Webview] Generating code from webview');
this.handleGenerateCode(message.data);
break;
case 'applyChanges':
console.log('[Webview] Applying changes from webview');
this.handleApplyChanges(message.data);
break;
case 'showInfo':
vscode.window.showInformationMessage(message.text);
break;
case 'showError':
vscode.window.showErrorMessage(message.text);
break;
case 'exportProject':
this.handleExportProject(message.data);
break;
case 'importProject':
this.handleImportProject();
break;
}
}, undefined);
}
private async handleExportProject(data: any): Promise<void> {
try {
const options: vscode.SaveDialogOptions = {
defaultUri: vscode.Uri.file(`tkinter-project-${new Date().toISOString().split('T')[0]}.json`),
filters: {
'JSON': ['json']
}
};
const fileUri = await vscode.window.showSaveDialog(options);
if (fileUri) {
const jsonString = JSON.stringify(data, null, 2);
const encoder = new TextEncoder();
await vscode.workspace.fs.writeFile(fileUri, encoder.encode(jsonString));
vscode.window.showInformationMessage('Project exported successfully!');
}
} catch (error: any) {
vscode.window.showErrorMessage(`Export failed: ${error.message}`);
}
}
private async handleImportProject(): Promise<void> {
try {
const options: vscode.OpenDialogOptions = {
canSelectMany: false,
openLabel: 'Import',
filters: {
'JSON': ['json']
}
};
const fileUris = await vscode.window.showOpenDialog(options);
if (fileUris && fileUris[0]) {
const fileUri = fileUris[0];
const fileData = await vscode.workspace.fs.readFile(fileUri);
const decoder = new TextDecoder();
const jsonString = decoder.decode(fileData);
const data = JSON.parse(jsonString);
if (this._view) {
// Check if the imported file has the expected structure
const designData = data.data || data; // Handle both wrapped and unwrapped data
this._view.webview.postMessage({
type: 'loadDesign',
data: designData
});
// Also update internal state
this._designData = designData;
vscode.window.showInformationMessage('Project imported successfully!');
}
}
} catch (error: any) {
vscode.window.showErrorMessage(`Import failed: ${error.message}`);
}
}
public async getDesignData(): Promise<any> {
return this._designData;
}
@ -145,54 +197,62 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
try {
console.log('[GenerateCode] Start');
const { CodeGenerator } = await import(
'../generator/CodeGenerator'
'../generator/codeGenerator'
);
const generator = new CodeGenerator();
const pythonCode = generator.generateTkinterCode(designData);
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') {
console.log('[GenerateCode] Writing into active editor');
const doc = activeEditor.document;
const start = new vscode.Position(0, 0);
const end = doc.lineCount
? doc.lineAt(doc.lineCount - 1).range.end
: start;
const fullRange = new vscode.Range(start, end);
await activeEditor.edit((editBuilder) => {
editBuilder.replace(fullRange, pythonCode);
});
await doc.save();
vscode.window.showInformationMessage(
'Python code generated into the active file'
);
} else {
console.log('[GenerateCode] Creating new file');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'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}`
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';
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');
} catch (error) {
console.error('[GenerateCode] Error:', error);
@ -200,6 +260,228 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
}
}
private async handleApplyChanges(designData: any): Promise<void> {
try {
console.log('[ApplyChanges] Start');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
return;
}
const formName = designData.form.name || 'Form1';
const fileName = `${formName}.py`;
const fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
// Try to read existing file to preserve method bodies
let existingMethods: any = {};
let oldClassName = '';
let fileContent = '';
let fileExists = false;
try {
// We check if file exists first to avoid error in openTextDocument if not exists
await vscode.workspace.fs.stat(fileUri);
fileExists = true;
const doc = await vscode.workspace.openTextDocument(fileUri);
fileContent = doc.getText();
const astResult = await runPythonAst(fileContent);
if (astResult) {
if (astResult.methods) {
existingMethods = astResult.methods;
if (designData.events) {
for (const event of designData.events) {
// Always prefer the existing method body from disk if it exists
// This ensures we don't overwrite user edits in the Python file
if (existingMethods[event.name]) {
event.code =
existingMethods[event.name].body;
event.signature =
existingMethods[event.name].signature;
}
}
}
}
if (astResult.window && astResult.window.className) {
oldClassName = astResult.window.className;
}
}
} catch (e) {
console.log(
'[ApplyChanges] Could not read existing file for methods preservation',
e
);
}
const { CodeGenerator } = await import(
'../generator/codeGenerator'
);
const generator = new CodeGenerator();
if (!fileExists) {
// New file: Generate full code
const pythonCode = generator.generateTkinterCode(designData);
const encoder = new TextEncoder();
await vscode.workspace.fs.writeFile(
fileUri,
encoder.encode(pythonCode)
);
vscode.window.showInformationMessage(
`Created new file: ${fileName}`
);
} else {
// Smart Update: Replace only create_widgets and inject new methods
const newCreateWidgets =
generator.generateCreateWidgetsBody(designData);
// 1. Update create_widgets method body
// Improved regex to handle arguments, return types, and spacing
const createWidgetsRegex =
/(def\s+create_widgets\s*\(\s*self[^)]*\)\s*(?:->\s*[^:]+)?\s*:)([\s\S]*?)(?=\n\s*def\s|\n\s*if\s+__name__|\Z)/;
let newFileContent = fileContent;
if (createWidgetsRegex.test(fileContent)) {
newFileContent = fileContent.replace(
createWidgetsRegex,
(match, defLine) => {
return `${defLine}\n${newCreateWidgets}\n`;
}
);
} else {
// Fallback if create_widgets not found (weird), just append or warn
vscode.window.showWarningMessage(
'Could not find create_widgets method to update. Regenerating full file might be needed if structure is broken.'
);
// Fallback to full generation if structure is too broken?
// For now let's try to proceed or maybe just replace the whole file if critical?
// Let's stick to safe update. If not found, we don't touch.
}
// 2. Inject new event handlers that are not in existingMethods
if (designData.events) {
const methodsToInject: string[] = [];
for (const event of designData.events) {
if (!existingMethods[event.name]) {
methodsToInject.push(
generator.generateEventHandler(event)
);
}
}
if (methodsToInject.length > 0) {
// Insert before run() or at the end of class
const runRegex = /\s*def\s+run\s*\(/;
if (runRegex.test(newFileContent)) {
newFileContent = newFileContent.replace(
runRegex,
(match) => {
return `\n${methodsToInject.join('\n\n')}\n\n${match}`;
}
);
} else {
// Append to end of class (before if __name__)
const mainRegex = /if\s+__name__\s*==/;
if (mainRegex.test(newFileContent)) {
newFileContent = newFileContent.replace(
mainRegex,
(match) => {
return `${methodsToInject.join('\n\n')}\n\n${match}`;
}
);
} else {
newFileContent += `\n\n${methodsToInject.join('\n\n')}`;
}
}
}
}
// 3. Update window title/geometry in __init__
// self.root.title("...")
const titleRegex = /self\.root\.title\s*\(\s*["'].*?["']\s*\)/;
newFileContent = newFileContent.replace(
titleRegex,
`self.root.title("${designData.form.title}")`
);
// self.root.geometry("...")
const geoRegex = /self\.root\.geometry\s*\(\s*["'].*?["']\s*\)/;
newFileContent = newFileContent.replace(
geoRegex,
`self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")`
);
// 4. Update class name if changed
if (
oldClassName &&
designData.form.className &&
oldClassName !== designData.form.className
) {
// Replace class definition
// Be careful to match exact class name to avoid partial replacements
const classDefRegex = new RegExp(
`class\\s+${oldClassName}\\s*:`
);
newFileContent = newFileContent.replace(
classDefRegex,
`class ${designData.form.className}:`
);
// Replace instantiation in if __name__ == "__main__":
// app = ClassName()
const instanceRegex = new RegExp(
`=\\s*${oldClassName}\\s*\\(`
);
newFileContent = newFileContent.replace(
instanceRegex,
`= ${designData.form.className}(`
);
}
const doc = await vscode.workspace.openTextDocument(fileUri);
const editor = vscode.window.visibleTextEditors.find(
(e) => e.document.uri.toString() === fileUri.toString()
);
if (editor) {
// Use validateRange to ensure we cover the entire document safely
// regardless of potential external changes (though we should be careful)
const fullRange = doc.validateRange(
new vscode.Range(
0,
0,
Number.MAX_VALUE,
Number.MAX_VALUE
)
);
await editor.edit((editBuilder) => {
editBuilder.replace(fullRange, newFileContent);
});
await doc.save();
} else {
const encoder = new TextEncoder();
await vscode.workspace.fs.writeFile(
fileUri,
encoder.encode(newFileContent)
);
}
vscode.window.showInformationMessage(
`Smart updated ${fileName}`
);
}
console.log('[ApplyChanges] Done');
} catch (error) {
console.error('[ApplyChanges] Error:', error);
vscode.window.showErrorMessage(`Error applying changes: ${error}`);
}
}
private _getHtmlForWebview(webview: vscode.Webview): string {
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(

View File

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

View File

@ -2,6 +2,76 @@ import React, { useRef } from 'react';
import { useAppDispatch, useAppState } from '../state';
import type { WidgetType } from '../types';
import { Widget } from '../types';
function renderWidgetContent(w: Widget) {
switch (w.type) {
case 'Label':
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{w.properties?.text || 'Label'}
</div>
);
case 'Button':
return (
<button
style={{
width: '100%',
height: '100%',
cursor: 'pointer',
}}
disabled
>
{w.properties?.text || 'Button'}
</button>
);
case 'Entry':
return (
<input
type="text"
disabled
placeholder="Entry"
style={{ width: '100%', height: '100%' }}
/>
);
case 'Text':
return (
<textarea
disabled
style={{ width: '100%', height: '100%', resize: 'none' }}
>
Text Area
</textarea>
);
case 'Checkbutton':
return (
<label>
<input type="checkbox" disabled checked />{' '}
{w.properties?.text || 'Check'}
</label>
);
case 'Radiobutton':
return (
<label>
<input type="radio" disabled checked />{' '}
{w.properties?.text || 'Radio'}
</label>
);
default:
return w.properties?.text || w.type;
}
}
export function Canvas() {
const dispatch = useAppDispatch();
const { design, selectedWidgetId } = useAppState();
@ -38,10 +108,13 @@ export function Canvas() {
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const newX = initX + dx;
const newY = initY + dy;
dispatch({
type: 'updateWidget',
payload: { id, patch: { x: initX + dx, y: initY + dy } },
payload: { id, patch: { x: newX, y: newY } },
});
// Auto-expand removed: user wants clipping behavior
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
@ -52,43 +125,244 @@ export function Canvas() {
window.addEventListener('mouseup', onUp);
};
const onResizeStart = (
e: React.MouseEvent<HTMLDivElement>,
id: string,
direction: string
) => {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const w = design.widgets.find((w) => w.id === id);
if (!w) return;
const initX = w.x;
const initY = w.y;
const initWidth = w.width;
const initHeight = w.height;
console.log('[Canvas] Resize start', id, direction);
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const patch: any = {};
if (direction.includes('e')) {
patch.width = Math.max(10, initWidth + dx);
}
if (direction.includes('w')) {
const newWidth = Math.max(10, initWidth - dx);
patch.width = newWidth;
patch.x = initX + (initWidth - newWidth);
}
if (direction.includes('s')) {
patch.height = Math.max(10, initHeight + dy);
}
if (direction.includes('n')) {
const newHeight = Math.max(10, initHeight - dy);
patch.height = newHeight;
patch.y = initY + (initHeight - newHeight);
}
dispatch({
type: 'updateWidget',
payload: { id, patch },
});
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
console.log('[Canvas] Resize end', id);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
const onWindowResizeStart = (
e: React.MouseEvent<HTMLDivElement>,
direction: string
) => {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const initWidth = design.form.size.width;
const initHeight = design.form.size.height;
console.log('[Canvas] Window resize start', direction);
let animationFrameId: number | null = null;
let currentEv: MouseEvent | null = null;
const onMove = (ev: MouseEvent) => {
ev.preventDefault();
currentEv = ev;
if (animationFrameId) return;
animationFrameId = requestAnimationFrame(() => {
if (!currentEv) return;
const dx = currentEv.clientX - startX;
const dy = currentEv.clientY - startY;
let newWidth = initWidth;
let newHeight = initHeight;
if (direction.includes('e')) {
newWidth = Math.max(100, initWidth + dx);
}
if (direction.includes('s')) {
newHeight = Math.max(100, initHeight + dy);
}
// If user requested to optimize/remove binding, we can skip complex calculations here
// But currently we just set form size which is fast enough usually.
// The main lag comes from React re-rendering all widgets.
dispatch({
type: 'setForm',
payload: { size: { width: newWidth, height: newHeight } },
});
animationFrameId = null;
});
};
const onUp = () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
console.log('[Canvas] Window resize end');
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
return (
<div className="canvas-container">
<div
id="designCanvas"
className="design-canvas"
ref={containerRef}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => onSelect(null)}
>
{design.widgets.length === 0 && (
<div className="canvas-placeholder">
<p>Drag widgets here to start designing</p>
<div className="window-frame" style={{ position: 'relative' }}>
<div className="window-title-bar">
<div className="window-title">
{design.form?.title || 'Tkinter App'}
</div>
)}
{design.widgets.map((w) => (
<div
key={w.id}
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
style={{
position: 'absolute',
left: w.x,
top: w.y,
width: w.width,
height: w.height,
}}
onClick={(e) => {
e.stopPropagation();
onSelect(w.id);
}}
onMouseDown={(e) => onMouseDown(e, w.id)}
>
<div className="widget-content">
{w.properties?.text || w.type}
<div className="window-controls">
<div className="window-control minimize"></div>
<div className="window-control maximize"></div>
<div className="window-control close"></div>
</div>
</div>
<div
id="designCanvas"
className="design-canvas"
style={{
width: design.form.size.width,
height: design.form.size.height,
}}
ref={containerRef}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => onSelect(null)}
>
{design.widgets.length === 0 && (
<div className="canvas-placeholder">
<p>Drag widgets here to start designing</p>
</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>
);

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import type { WidgetType } from '../types';
const WIDGETS: WidgetType[] = [
'Label',
'Button',
'Entry',
'Text',
'Checkbutton',
'Radiobutton',

View File

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

View File

@ -1,10 +1,9 @@
import React, { useRef } from 'react';
import React from 'react';
import { useAppDispatch, useAppState } from '../state';
export function Toolbar() {
const dispatch = useAppDispatch();
const { vscode, design } = useAppState();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const exportProject = () => {
try {
@ -15,19 +14,9 @@ export function Toolbar() {
description: 'Tkinter Designer Project',
data: design,
};
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tkinter-project-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
vscode.postMessage({
type: 'showInfo',
text: 'Project exported successfully!',
type: 'exportProject',
data: exportData,
});
} catch (error: any) {
console.error('Export error:', error);
@ -38,37 +27,11 @@ export function Toolbar() {
}
};
const importProject = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log('[Toolbar] Import project');
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const jsonString = ev.target?.result as string;
const obj = JSON.parse(jsonString);
if (!obj || !obj.data)
throw new Error('Invalid project file format');
dispatch({ type: 'init', payload: obj.data });
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 importProject = () => {
console.log('[Toolbar] Request Import project');
vscode.postMessage({
type: 'importProject',
});
};
const clearAll = () => {
@ -96,18 +59,53 @@ export function Toolbar() {
vscode.postMessage({ type: 'generateCode', data: design });
};
const applyChanges = () => {
console.log('[Toolbar] Apply changes');
if (design.widgets.length === 0) {
vscode.postMessage({
type: 'showError',
text: 'No widgets to apply changes!',
});
return;
}
vscode.postMessage({
type: 'showInfo',
text: 'Applying changes...',
});
vscode.postMessage({ type: 'applyChanges', data: design });
};
return (
<div className="toolbar">
<input
ref={fileInputRef}
type="file"
id="importFileInput"
accept=".json"
style={{ display: 'none' }}
onChange={importProject}
/>
<h2>Tkinter Visual Designer</h2>
<div className="toolbar-buttons">
<div
style={{
display: 'flex',
alignItems: 'center',
marginRight: '10px',
}}
>
<label htmlFor="formName" style={{ marginRight: '5px' }}>
Form Name:
</label>
<input
id="formName"
type="text"
value={design.form.name || 'Form1'}
onChange={(e) =>
dispatch({
type: 'setForm',
payload: { name: e.target.value },
})
}
style={{
padding: '4px',
borderRadius: '4px',
border: '1px solid #ccc',
}}
/>
</div>
<button
id="undoBtn"
className="btn btn-outline"
@ -137,7 +135,7 @@ export function Toolbar() {
id="importBtn"
className="btn btn-outline"
title="Import project from JSON"
onClick={() => fileInputRef.current?.click()}
onClick={importProject}
>
📥 Import
</button>
@ -149,6 +147,13 @@ export function Toolbar() {
>
Generate Code
</button>
<button
id="applyBtn"
className="btn btn-primary"
onClick={applyChanges}
>
Apply Changes
</button>
<button
id="clearBtn"
className="btn btn-secondary"

View File

@ -1,4 +1,5 @@
import React, { createContext, useContext, useReducer, useMemo } from 'react';
import { produce } from 'immer';
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
export interface AppState {
@ -20,15 +21,20 @@ type Action =
}
| { type: 'deleteWidget'; payload: { id: string } }
| { type: 'addEvent'; payload: EventBinding }
| { type: 'removeEvent'; payload: { widget: string; type: string } }
| {
type: 'removeEvent';
payload: { widget: string; type: string; name?: string };
}
| { type: 'clear' }
| { type: 'undo' }
| { type: 'redo' }
| {
type: 'setForm';
payload: {
name?: string;
title?: string;
size?: { width: number; height: number };
className?: string;
};
};
@ -37,163 +43,211 @@ const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
undefined
);
function clone(data: DesignData): DesignData {
return JSON.parse(JSON.stringify(data));
}
// We don't need clone anymore because immer handles immutability
function reducer(state: AppState, action: Action): AppState {
console.log('[State] Action:', action.type);
switch (action.type) {
case 'init': {
const next = {
...state,
design: action.payload,
selectedWidgetId: null,
};
if (!next.design.events) next.design.events = [];
console.log('[State] Init design widgets:', next.design.widgets.length);
return pushHistory(next);
}
case 'addWidget': {
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const w: Widget = {
id,
type: action.payload.type,
x: action.payload.x,
y: action.payload.y,
width: 120,
height: 40,
properties: { text: action.payload.type },
};
const design = clone(state.design);
design.widgets.push(w);
const next = { ...state, design, selectedWidgetId: id };
console.log('[State] Added widget:', id, w.type);
return pushHistory(next);
}
case 'selectWidget': {
console.log('[State] Select widget:', action.payload.id);
return { ...state, selectedWidgetId: action.payload.id };
}
case 'updateWidget': {
const design = clone(state.design);
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
design.widgets[idx] = {
...design.widgets[idx],
...action.payload.patch,
};
}
const next = { ...state, design };
console.log('[State] Updated widget:', action.payload.id);
return pushHistory(next);
}
case 'updateProps': {
const design = clone(state.design);
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
design.widgets[idx] = {
...design.widgets[idx],
properties: {
...design.widgets[idx].properties,
...action.payload.properties,
},
};
}
const next = { ...state, design };
console.log('[State] Updated properties for:', action.payload.id);
return pushHistory(next);
}
case 'deleteWidget': {
const design = clone(state.design);
design.widgets = design.widgets.filter(
(w) => w.id !== action.payload.id
);
if (!design.events) design.events = [];
design.events = design.events.filter(
(e) => e.widget !== action.payload.id
);
const next = { ...state, design, selectedWidgetId: null };
console.log('[State] Deleted widget:', action.payload.id);
return pushHistory(next);
}
case 'addEvent': {
const design = clone(state.design);
if (!design.events) design.events = [];
const exists = design.events.find(
(e) =>
e.widget === action.payload.widget &&
e.type === action.payload.type
);
if (!exists) {
design.events.push(action.payload);
}
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget);
return pushHistory({ ...state, design });
}
case 'removeEvent': {
const design = clone(state.design);
if (!design.events) design.events = [];
design.events = design.events.filter(
(e) =>
!(
e.widget === action.payload.widget &&
e.type === action.payload.type
)
);
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget);
return pushHistory({ ...state, design });
}
case 'clear': {
const design: DesignData = {
form: state.design.form,
widgets: [],
events: [],
};
console.log('[State] Cleared design');
return pushHistory({ ...state, design, selectedWidgetId: null });
}
case 'undo': {
if (state.historyIndex <= 0) return state;
const idx = state.historyIndex - 1;
console.log('[State] Undo to index:', idx);
return {
...state,
historyIndex: idx,
design: clone(state.history[idx]),
};
}
case 'redo': {
if (state.historyIndex >= state.history.length - 1) return state;
const idx = state.historyIndex + 1;
console.log('[State] Redo to index:', idx);
return {
...state,
historyIndex: idx,
design: clone(state.history[idx]),
};
}
case 'setForm': {
const design = clone(state.design);
if (action.payload.title) design.form.title = action.payload.title;
if (action.payload.size) design.form.size = action.payload.size;
console.log('[State] Set form', action.payload);
return pushHistory({ ...state, design });
}
default:
return state;
// Handle undo/redo separately as they manipulate the history stack directly
if (action.type === 'undo') {
if (state.historyIndex <= 0) return state;
const idx = state.historyIndex - 1;
console.log('[State] Undo to index:', idx);
return {
...state,
historyIndex: idx,
design: JSON.parse(JSON.stringify(state.history[idx])), // Deep copy from history to current design
};
}
if (action.type === 'redo') {
if (state.historyIndex >= state.history.length - 1) return state;
const idx = state.historyIndex + 1;
console.log('[State] Redo to index:', idx);
return {
...state,
historyIndex: idx,
design: JSON.parse(JSON.stringify(state.history[idx])), // Deep copy from history to current design
};
}
return produce(state, (draft) => {
switch (action.type) {
case 'init': {
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 {
const hist = next.history.slice(0, next.historyIndex + 1);
hist.push(clone(next.design));
console.log('[State] History length:', hist.length);
return { ...next, history: hist, historyIndex: hist.length - 1 };
// Helper to push state to history within a draft
function pushHistoryDraft(draft: AppState) {
// We can't use JSON.parse/stringify on draft directly efficiently,
// but since we are in produce, 'draft.design' tracks changes.
// However, for history we need a snapshot.
// immer allows us to access the current state.
// But since 'draft' is a proxy, we need to be careful.
// The simple way for history stack in immer is to manually manage it.
// But we are inside 'produce' which updates 'state'.
// Ideally we should just push a copy of design.
// Since we are modifying 'draft.design', we can take a snapshot of it?
// No, 'current(draft.design)' works.
// BUT: The history stack itself is in the draft.
// We slice history to current index + 1
draft.history = draft.history.slice(0, draft.historyIndex + 1);
// We push a copy of the current design.
// We must clone it because 'draft.design' will change in future.
// Using JSON parse/stringify is still the safest "deep clone" for POJO design data here.
draft.history.push(JSON.parse(JSON.stringify(draft.design)));
draft.historyIndex = draft.history.length - 1;
console.log('[State] History length:', draft.history.length);
}
export function useAppState() {
@ -217,7 +271,11 @@ export function AppProvider({
}) {
const initialDesign: DesignData = useMemo(
() => ({
form: { title: 'My App', size: { width: 800, height: 600 } },
form: {
name: 'Form1',
title: 'My App',
size: { width: 800, height: 600 },
},
widgets: [],
events: [],
}),

View File

@ -1,6 +1,7 @@
export type WidgetType =
| 'Label'
| 'Button'
| 'Entry'
| 'Text'
| 'Checkbutton'
| 'Radiobutton';
@ -24,8 +25,10 @@ export interface EventBinding {
export interface DesignData {
form: {
name: string;
title: string;
size: { width: number; height: number };
className?: string;
};
widgets: Widget[];
events: EventBinding[];

View File

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

View File

@ -77,12 +77,13 @@ body {
.btn-outline {
background-color: transparent;
color: var(--vscode-button-foreground);
color: white;
border: 1px solid var(--vscode-button-border);
}
.btn-outline:hover {
background-color: var(--vscode-button-hoverBackground);
background-color: black;
border-color: black;
}
.btn-outline:disabled {
@ -434,22 +435,16 @@ body {
.canvas-container {
flex: 1;
padding: 16px;
padding: 40px;
overflow: auto;
}
.design-canvas {
min-height: 600px;
min-width: 800px;
background-color: #f0f0f0;
border: 2px dashed var(--vscode-panel-border);
border-radius: 4px;
position: relative;
background-color: #e0e0e0;
display: flex;
align-items: flex-start;
background-image:
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
linear-gradient(45deg, #d0d0d0 25%, transparent 25%),
linear-gradient(-45deg, #d0d0d0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #d0d0d0 75%),
linear-gradient(-45deg, transparent 75%, #d0d0d0 75%);
background-size: 20px 20px;
background-position:
0 0,
@ -458,9 +453,138 @@ body {
-10px 0px;
}
.design-canvas.drag-over {
border-color: var(--vscode-focusBorder);
background-color: var(--vscode-list-dropBackground);
.window-frame {
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 1px solid #999;
background-color: #f0f0f0;
}
.window-title-bar {
height: 30px;
background-color: #ffffff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 0 10px;
border-bottom: 1px solid #ccc;
user-select: none;
}
.window-title {
font-size: 12px;
color: #333;
font-family: 'Segoe UI', sans-serif;
}
.window-controls {
display: flex;
height: 100%;
}
.window-control {
width: 46px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: transparent;
border: none;
cursor: default;
transition: background-color 0.1s;
}
.window-control:hover {
background-color: #e5e5e5;
}
.window-control.close:hover {
background-color: #e81123;
color: white;
}
/* Minimize Icon */
.window-control.minimize::before {
content: '';
width: 10px;
height: 1px;
background-color: #000;
}
/* Maximize Icon */
.window-control.maximize::before {
content: '';
width: 10px;
height: 10px;
border: 1px solid #000;
box-sizing: border-box;
}
/* Close Icon (X) */
.window-control.close {
position: relative;
}
.window-control.close::before,
.window-control.close::after {
content: '';
position: absolute;
width: 10px;
height: 1px;
background-color: #000;
}
.window-control.close::before {
transform: rotate(45deg);
}
.window-control.close::after {
transform: rotate(-45deg);
}
.window-control.close:hover::before,
.window-control.close:hover::after {
background-color: white;
}
.design-canvas {
background-color: white;
position: relative;
overflow: hidden;
}
.window-resize-handle {
position: absolute;
z-index: 5;
}
.window-resize-handle.e {
top: 0;
right: -10px;
width: 20px;
height: 100%;
cursor: ew-resize;
z-index: 5;
}
.window-resize-handle.s {
bottom: -10px;
left: 0;
width: 100%;
height: 20px;
cursor: ns-resize;
z-index: 5;
}
.window-resize-handle.se {
bottom: -10px;
right: -10px;
width: 20px;
height: 20px;
cursor: nwse-resize;
z-index: 10;
}
.canvas-placeholder {
@ -497,6 +621,71 @@ body {
box-shadow: 0 0 0 1px var(--vscode-button-background);
}
.resize-handle {
position: absolute;
width: 8px;
height: 8px;
background-color: white;
border: 1px solid var(--vscode-button-background);
z-index: 10;
}
.resize-handle:hover {
background-color: var(--vscode-button-background);
}
.resize-handle.n {
top: -5px;
left: 50%;
transform: translateX(-50%);
cursor: ns-resize;
}
.resize-handle.s {
bottom: -5px;
left: 50%;
transform: translateX(-50%);
cursor: ns-resize;
}
.resize-handle.e {
top: 50%;
right: -5px;
transform: translateY(-50%);
cursor: ew-resize;
}
.resize-handle.w {
top: 50%;
left: -5px;
transform: translateY(-50%);
cursor: ew-resize;
}
.resize-handle.ne {
top: -5px;
right: -5px;
cursor: nesw-resize;
}
.resize-handle.nw {
top: -5px;
left: -5px;
cursor: nwse-resize;
}
.resize-handle.se {
bottom: -5px;
right: -5px;
cursor: nwse-resize;
}
.resize-handle.sw {
bottom: -5px;
left: -5px;
cursor: nesw-resize;
}
.canvas-widget.widget-label {
background-color: #f8f9fa;
color: #333;
@ -516,8 +705,7 @@ body {
border: 1px solid #ced4da;
padding: 8px;
text-align: left;
resize: both;
overflow: auto;
overflow: hidden;
}
.canvas-widget.widget-checkbutton {
@ -546,8 +734,6 @@ body {
font-size: 14px;
}
@keyframes fadeIn {
from {
opacity: 0;