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