This commit is contained in:
IDK 2025-12-26 19:28:22 +03:00
parent c87e51053c
commit d8b0c738c9
51 changed files with 2270 additions and 837 deletions

12
.dockerignore Normal file
View File

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

1
.gitignore vendored
View File

@ -2,5 +2,4 @@
out/
docs/
node_modules/
README.md
examples/

25
Dockerfile Normal file
View File

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

83
README.md Normal file
View File

@ -0,0 +1,83 @@
## Tkinter Visual Designer (VS Code Extension)
Tkinter Visual Designer — это расширение для Visual Studio Code, которое превращает
разработку Tkinterинтерфейсов в наглядный процесс: вместо ручного написания
координат и вызовов `.place()` / `.grid()` вы работаете с визуальным конструктором.
Основная идея — **разводить логику и разметку**:
- разметка окна (виджеты, их положение и свойства) проектируется в визуальном редакторе;
- логика приложения (обработчики событий, бизнес‑код) остаётся в Pythonфайле.
### Что умеет приложение
- Визуальное редактирование формы:
- перетаскивание виджетов (Label, Button, Entry, Text, Checkbutton, Radiobutton) на холст;
- изменение размера и положения виджетов мышью;
- настройка свойств (текст, размеры, позиция) через панель свойств.
- Работа с событиями:
- привязка обработчиков к виджетам (например, `command` у кнопки);
- просмотр уже существующих привязок.
- Генерация кода:
- создание Pythonкода по текущему дизайну;
- экспорт/импорт проекта в JSON.
- Разбор существующего кода:
- парсер анализирует ваш Tkinterкод и восстанавливает дизайн формы;
- поддерживаются классические приложения с классом‑обёрткой и методами `__init__`, `create_widgets` и т.п.
### Типичный сценарий работы
1. Открыть или создать Pythonфайл с Tkinterприложением.
2. Запустить визуальный дизайнер (см. раздел «Запуск приложения» ниже).
3. В дизайнере:
- перетаскивать виджеты из палитры на холст;
- настраивать свойства и события в боковой панели;
- при необходимости импортировать существующий дизайн из кода.
4. Сгенерировать обновлённый Pythonкод и сохранить его.
5. Запустить приложение Python уже с новым интерфейсом.
### Требования
- Visual Studio Code ≥ 1.74
- Node.js и npm
- Python 3 (доступный в системе как `python`, `python3`, `py` или указанный в настройках VS Code)
### Установка зависимостей
```bash
npm install
```
### Сборка расширения и веб‑интерфейса
```bash
npm run compile
```
### Запуск приложения (расширения)
1. Откройте папку проекта в VS Code.
2. Выполните `npm install` и `npm run compile`, если ещё не делали это.
3. Нажмите `F5` или выберите в меню:
- Run → Start Debugging (Запуск → Начать отладку).
4. Откроется новое окно VS Code с установленным в нём расширением.
В этом окне вы можете:
- Открыть любой `.py` файл с Tkinterкодом.
- Открыть визуальный дизайнер:
- через командную палитру (`Ctrl+Shift+P` → `Tkinter: Open Tkinter Designer`), или
- через контекстное меню в проводнике: правый клик по `.py``Open Tkinter Designer`.
### Основные команды
Расширение регистрирует следующие команды:
- `Tkinter: Open Tkinter Designer` — открыть визуальный дизайнер для текущего Pythonфайла.
- `Tkinter: Generate Python Code` — сгенерировать Pythonкод из текущего дизайна.
- `Tkinter: Parse Tkinter Code` — разобрать существующий Tkinterкод и загрузить его в дизайнер.
### Примечания
- Для корректной работы парсера обязательно наличие установленного Python 3.
- Генерация и разбор кода основаны на стандартной структуре Tkinterприложений (классы, методы `__init__`, `create_widgets`, и т.п.).

11
package-lock.json generated
View File

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

View File

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

50
src/constants.ts Normal file
View File

@ -0,0 +1,50 @@
export enum WidgetType {
Label = 'Label',
Button = 'Button',
Entry = 'Entry',
Text = 'Text',
Checkbutton = 'Checkbutton',
Radiobutton = 'Radiobutton',
Frame = 'Frame',
Widget = 'Widget',
}
export const DEFAULT_FORM_CONFIG = {
TITLE: 'Parsed App',
WIDTH: 800,
HEIGHT: 600,
CLASS_NAME: 'Application',
FORM_NAME: 'Form',
};
export const WIDGET_DIMENSIONS = {
[WidgetType.Label]: { width: 100, height: 25 },
[WidgetType.Button]: { width: 80, height: 30 },
[WidgetType.Entry]: { width: 150, height: 25 },
[WidgetType.Text]: { width: 200, height: 100 },
[WidgetType.Checkbutton]: { width: 100, height: 25 },
[WidgetType.Radiobutton]: { width: 100, height: 25 },
[WidgetType.Frame]: { width: 200, height: 200 },
[WidgetType.Widget]: { width: 100, height: 25 },
DEFAULT: { width: 100, height: 25 },
};
export const EVENT_TYPES = {
COMMAND: 'command',
};
export const PYTHON_CODE = {
IMPORTS: {
TKINTER: 'import tkinter as tk',
TTK: 'from tkinter import ttk',
},
METHODS: {
INIT: '__init__',
CREATE_WIDGETS: 'create_widgets',
RUN: 'run',
},
VARIABLES: {
ROOT: 'self.root',
APP: 'app',
}
};

View File

@ -1,130 +1,59 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { CodeGenerator } from './generator/CodeGenerator';
import { CodeParser } from './parser/CodeParser';
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
export function activate(context: vscode.ExtensionContext) {
const provider = new TkinterDesignerProvider(context.extensionUri);
TkinterDesignerProvider._instance = provider;
import { CodeParser } from './parser/codeParser';
import { TkinterDesignerProvider } from './webview/tkinterDesignerProvider';
export function activate(context: vscode.ExtensionContext) {3
const openDesignerCommand = vscode.commands.registerCommand(
'tkinter-designer.openDesigner',
() => {
TkinterDesignerProvider.createOrShow(context.extensionUri);
}
);
const generateCodeCommand = vscode.commands.registerCommand(
'tkinter-designer.generateCode',
async () => {
console.log('[GenerateCode] Command invoked');
const generator = new CodeGenerator();
const designData = await provider.getDesignData();
if (
!designData ||
!designData.widgets ||
designData.widgets.length === 0
) {
console.log('[GenerateCode] No design data');
vscode.window.showWarningMessage(
'No design data found. Please open the designer and create some widgets first.'
);
return;
}
const pythonCode = generator.generateTkinterCode(designData);
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') {
console.log('[GenerateCode] Writing into active Python file');
const doc = activeEditor.document;
const start = new vscode.Position(0, 0);
const end = doc.lineCount
? doc.lineAt(doc.lineCount - 1).range.end
: start;
const fullRange = new vscode.Range(start, end);
await activeEditor.edit((editBuilder) => {
editBuilder.replace(fullRange, pythonCode);
});
await doc.save();
vscode.window.showInformationMessage(
'Python code generated into the active file'
);
} else {
console.log('[GenerateCode] Creating new Python file');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
return;
}
const fileName = `app_${Date.now()}.py`;
const filePath = path.join(
workspaceFolder.uri.fsPath,
fileName
);
const fileUri = vscode.Uri.file(filePath);
const encoder = new TextEncoder();
const fileBytes = encoder.encode(pythonCode);
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc, { preview: false });
vscode.window.showInformationMessage(
`Python file created: ${fileName}`
);
}
console.log('[GenerateCode] Done');
TkinterDesignerProvider.createNew(context.extensionUri);
}
);
const parseCodeCommand = vscode.commands.registerCommand(
'tkinter-designer.parseCode',
async () => {
console.log('[ParseCode] Command invoked');
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'python') {
const parser = new CodeParser();
const code = activeEditor.document.getText();
console.log('[ParseCode] Code length:', code.length);
const fileName = path.basename(
activeEditor.document.fileName,
'.py'
);
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(
const designerInstance = TkinterDesignerProvider.createNew(
context.extensionUri
);
if (designerInstance) {
designerInstance.loadDesignData(designData);
} else {
}
);
designerInstance.loadDesignData(designData);
vscode.window.showInformationMessage(
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
);
} else {
console.log('[ParseCode] No widgets found');
vscode.window.showWarningMessage(
'No tkinter widgets found in the code. Make sure your code contains tkinter widget creation statements like tk.Label(), tk.Button(), etc.'
);
}
} catch (error) {
console.error('[ParseCode] Error:', error);
vscode.window.showErrorMessage(
`Error parsing code: ${error}`
);
}
} else {
console.log('[ParseCode] No active Python editor');
vscode.window.showErrorMessage(
'Please open a Python file with tkinter code'
);
@ -134,7 +63,6 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
openDesignerCommand,
generateCodeCommand,
parseCodeCommand
);
}

View File

@ -1,9 +1,10 @@
import { DesignData, WidgetData } from './types';
import { DesignData, WidgetData, DesignEvent } from './types';
import {
getVariableName,
generateVariableNames,
getWidgetTypeForGeneration,
indentText,
escapeString,
} from './utils';
import {
getWidgetParameters,
@ -11,6 +12,7 @@ import {
generateWidgetContent,
} from './widgetHelpers';
import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers';
import { DEFAULT_FORM_CONFIG, PYTHON_CODE } from '../constants';
export class CodeGenerator {
private indentLevel = 0;
@ -18,48 +20,46 @@ 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 || DEFAULT_FORM_CONFIG.CLASS_NAME;
const lines: string[] = [];
const nameMap = generateVariableNames(designData.widgets);
lines.push('import tkinter as tk');
lines.push(PYTHON_CODE.IMPORTS.TKINTER);
lines.push(PYTHON_CODE.IMPORTS.TTK);
lines.push('');
lines.push('class Application:');
lines.push(`class ${className}:`);
this.indentLevel = 1;
lines.push(this.indent('def __init__(self):'));
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.INIT}(self):`));
this.indentLevel = 2;
lines.push(this.indent('self.root = tk.Tk()'));
lines.push(this.indent(`self.root.title("${designData.form.title}")`));
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT} = tk.Tk()`));
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.title("${escapeString(designData.form.title)}")`));
lines.push(
this.indent(
`self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")`
`${PYTHON_CODE.VARIABLES.ROOT}.geometry("${designData.form.size.width}x${designData.form.size.height}")`
)
);
lines.push(this.indent('self.create_widgets()'));
lines.push(this.indent(`self.${PYTHON_CODE.METHODS.CREATE_WIDGETS}()`));
lines.push('');
this.indentLevel = 1;
lines.push(this.indent('def create_widgets(self):'));
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.CREATE_WIDGETS}(self):`));
this.indentLevel = 2;
designData.widgets.forEach((widget) => {
console.log('[Generator] Widget:', widget.id, widget.type);
lines.push(...this.generateWidgetCode(widget, nameMap));
lines.push('');
});
this.indentLevel = 1;
lines.push(this.indent('def run(self):'));
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.RUN}(self):`));
this.indentLevel = 2;
lines.push(this.indent('self.root.mainloop()'));
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.mainloop()`));
lines.push('');
const hasEvents = designData.events && designData.events.length > 0;
if (hasEvents) {
console.log('[Generator] Generating event handlers');
lines.push(
...generateEventHandlers(
designData,
@ -72,12 +72,58 @@ export class CodeGenerator {
this.indentLevel = 0;
lines.push('if __name__ == "__main__":');
this.indentLevel = 1;
lines.push(this.indent('app = Application()'));
lines.push(this.indent('app.run()'));
lines.push(this.indent('try:'));
this.indentLevel = 2;
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.APP} = ${className}()`));
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.APP}.${PYTHON_CODE.METHODS.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;
designData.widgets.forEach((widget) => {
lines.push(...this.generateWidgetCode(widget, nameMap));
});
return lines.join('\n');
}
public generateEventHandler(event: DesignEvent): string {
const lines: string[] = [];
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) => indentText(2, l))
.join('\n');
lines.push(body);
} else {
lines.push(' pass');
}
return lines.join('\n');
}
private generateWidgetCode(
widget: WidgetData,
nameMap: Map<string, string>
@ -96,7 +142,9 @@ export class CodeGenerator {
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
const contentLines = generateWidgetContent(widget, varName);
contentLines.forEach((line) => lines.push(this.indent(line)));
if (contentLines.length > 0) {
contentLines.forEach((l) => lines.push(this.indent(l)));
}
lines.push(
...getWidgetEventBindings(

View File

@ -1,4 +1,4 @@
import { DesignData, Event, WidgetData } from './types';
import { DesignData, DesignEvent, WidgetData } from './types';
export function generateEventHandlers(
designData: DesignData,
@ -8,22 +8,39 @@ export function generateEventHandlers(
const lines: string[] = [];
if (designData.events && designData.events.length > 0) {
designData.events.forEach((event: Event) => {
const handlerName = event.name;
const isBindEvent =
event.type.startsWith('<') && event.type.endsWith('>');
const handledNames = new Set<string>();
let hasCode = false;
const widget = (designData.widgets || []).find(
(w) => w.id === event.widget
);
if (isBindEvent) {
lines.push(indentDef(`def ${handlerName}(self, event):`));
designData.events.forEach((event: DesignEvent) => {
const handlerName = event.name;
if (handledNames.has(handlerName)) {
return;
}
handledNames.add(handlerName);
const signature = event.signature;
if (signature) {
lines.push(indentDef(`${signature}:`));
} else {
lines.push(indentDef(`def ${handlerName}(self):`));
const isBindEvent =
event.type.startsWith('<') && event.type.endsWith('>');
if (isBindEvent) {
lines.push(
indentDef(`def ${handlerName}(self, event=None):`)
);
} else {
lines.push(indentDef(`def ${handlerName}(self):`));
}
}
const codeLines = (event.code || '').split('\n');
let hasCode = false;
const codeContent =
typeof event.code === 'string'
? event.code
: String(event.code || '');
const codeLines = codeContent.split('\n');
for (const line of codeLines) {
if (line.trim()) {
lines.push(indentBody(line));

View File

@ -1,8 +1,21 @@
export interface Event {
export interface DesignEvent {
widget: string;
type: string;
name: string;
code: string;
signature?: string;
}
export interface WidgetProperties {
text?: string;
command?: string | { name?: string; lambda_body?: string };
variable?: string;
orient?: string;
from_?: number;
to?: number;
width?: number;
height?: number;
[key: string]: any;
}
export interface WidgetData {
@ -12,17 +25,19 @@ export interface WidgetData {
y: number;
width: number;
height: number;
properties: { [key: string]: any };
properties: WidgetProperties;
}
export interface DesignData {
form: {
name: string;
title: string;
size: {
width: number;
height: number;
};
className?: string;
};
widgets: WidgetData[];
events?: Event[];
events?: DesignEvent[];
}

View File

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

View File

@ -1,137 +1,33 @@
import { DesignData, WidgetData, Event } from '../generator/types';
import * as vscode from 'vscode';
import { DesignData, WidgetData, DesignEvent } from '../generator/types';
import { runPythonAst } from './pythonRunner';
import { convertASTResultToDesignData } from './astConverter';
import { ASTResult } from './astTypes';
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);
const astDesign = convertASTResultToDesignData(astRaw);
if (astDesign && astDesign.widgets && astDesign.widgets.length > 0) {
console.log(
'[Parser] AST parsed widgets:',
astDesign.widgets.length
);
return astDesign;
}
console.log('[Parser] AST returned no widgets, using regex fallback');
const regexDesign = this.parseWithRegexInline(pythonCode);
console.log(
'[Parser] Regex parsed widgets:',
regexDesign?.widgets?.length || 0
);
return regexDesign;
}
private parseWithRegexInline(code: string): DesignData | null {
const widgetRegex =
/(self\.)?(\w+)\s*=\s*tk\.(Label|Button|Text|Checkbutton|Radiobutton)\s*\(([^)]*)\)/g;
const placeRegex = /(self\.)?(\w+)\.place\s*\(([^)]*)\)/g;
const titleRegex = /\.title\s*\(\s*(["'])(.*?)\1\s*\)/;
const geometryRegex = /\.geometry\s*\(\s*(["'])(\d+)x(\d+)\1\s*\)/;
const widgets: WidgetData[] = [];
const widgetMap = new Map<string, WidgetData>();
let formTitle = 'App';
let formWidth = 800;
let formHeight = 600;
const tMatch = code.match(titleRegex);
if (tMatch) formTitle = tMatch[2];
const gMatch = code.match(geometryRegex);
if (gMatch) {
formWidth = parseInt(gMatch[2], 10) || 800;
formHeight = parseInt(gMatch[3], 10) || 600;
}
let m: RegExpExecArray | null;
while ((m = widgetRegex.exec(code)) !== null) {
const varName = m[2];
const type = m[3];
const paramStr = m[4] || '';
const id = varName;
const w: WidgetData = {
id,
type,
x: 0,
y: 0,
width: 100,
height: 25,
properties: {},
};
const textMatch = paramStr.match(/text\s*=\s*(["'])(.*?)\1/);
if (textMatch) {
w.properties.text = textMatch[2];
}
const widthMatch = paramStr.match(/width\s*=\s*(\d+)/);
if (widthMatch) {
const wv = parseInt(widthMatch[1], 10);
if (!isNaN(wv)) w.width = wv;
}
const heightMatch = paramStr.match(/height\s*=\s*(\d+)/);
if (heightMatch) {
const hv = parseInt(heightMatch[1], 10);
if (!isNaN(hv)) w.height = hv;
}
widgets.push(w);
widgetMap.set(varName, w);
}
let p: RegExpExecArray | null;
while ((p = placeRegex.exec(code)) !== null) {
const varName = p[2];
const params = p[3];
const w = widgetMap.get(varName);
if (!w) continue;
const getNum = (key: string) => {
const r = new RegExp(key + '\\s*[:=]\\s*(\d+)');
const mm = params.match(r);
return mm ? parseInt(mm[1], 10) : undefined;
};
const x = getNum('x');
const y = getNum('y');
const width = getNum('width');
const height = getNum('height');
if (typeof x === 'number') w.x = x;
if (typeof y === 'number') w.y = y;
if (typeof width === 'number') w.width = width;
if (typeof height === 'number') w.height = height;
}
const events: Event[] = [];
const configRegex =
/(self\.)?(\w+)\.config\s*\(\s*command\s*=\s*(self\.)?(\w+)\s*\)/g;
let c: RegExpExecArray | null;
while ((c = configRegex.exec(code)) !== null) {
const varName = c[2];
const handler = c[4];
if (widgetMap.has(varName)) {
events.push({
widget: varName,
type: 'command',
name: handler,
code: '',
});
}
}
if (widgets.length === 0) {
console.log('[Parser] Regex found 0 widgets');
if (astRaw && astRaw.error) {
vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`);
return null;
}
return {
form: {
title: formTitle,
size: { width: formWidth, height: formHeight },
},
widgets,
events,
};
const astDesign = convertASTResultToDesignData(astRaw as ASTResult);
if (astDesign) {
if (filename) {
astDesign.form.name = filename;
}
return astDesign;
}
vscode.window.showErrorMessage(
'Could not parse Python code safely. Please ensure your code has no syntax errors and follows the standard structure (class-based Tkinter app).'
);
return null;
}
}

View File

@ -1,10 +1,15 @@
import { DesignData, WidgetData, Event } from '../generator/types';
import { DesignData, WidgetData, DesignEvent, WidgetProperties } from '../generator/types';
import { ASTResult, ASTWidget, ASTMethodData } from './astTypes';
import { getDefaultWidth, getDefaultHeight } from './utils';
import { WidgetType, DEFAULT_FORM_CONFIG, EVENT_TYPES } from '../constants';
export function convertASTResultToDesignData(
astResult: any
): DesignData | null {
if (!astResult || !astResult.widgets || astResult.widgets.length === 0) {
export function convertASTResultToDesignData(astResult: ASTResult): DesignData | null {
if (!astResult) {
return null;
}
if (!astResult.window && !astResult.widgets) {
return null;
}
@ -12,48 +17,44 @@ export function convertASTResultToDesignData(
let formTitle =
(astResult.window && astResult.window.title) ||
astResult.title ||
'Parsed App';
DEFAULT_FORM_CONFIG.TITLE;
let formWidth =
(astResult.window && astResult.window.width) || astResult.width || 800;
(astResult.window && astResult.window.width) || astResult.width || DEFAULT_FORM_CONFIG.WIDTH;
let formHeight =
(astResult.window && astResult.window.height) ||
astResult.height ||
600;
DEFAULT_FORM_CONFIG.HEIGHT;
let className =
(astResult.window && astResult.window.className) || DEFAULT_FORM_CONFIG.CLASS_NAME;
let counter = 0;
const allowedTypes = new Set([
'Label',
'Button',
'Text',
'Checkbutton',
'Radiobutton',
WidgetType.Label,
WidgetType.Button,
WidgetType.Entry,
WidgetType.Text,
WidgetType.Checkbutton,
WidgetType.Radiobutton,
]);
for (const w of astResult.widgets) {
const inputWidgets = astResult.widgets || [];
for (const w of inputWidgets) {
counter++;
const type = w.type || 'Widget';
if (!allowedTypes.has(type)) {
const type = w.type || WidgetType.Widget;
if (!allowedTypes.has(type as WidgetType)) {
continue;
}
const place = w.placement || {};
const x = place.x !== undefined ? place.x : w.x !== undefined ? w.x : 0;
const y = place.y !== undefined ? place.y : w.y !== undefined ? w.y : 0;
const p = (w.properties || w.params || {}) as any;
const width =
place.width !== undefined
? place.width
: w.width !== undefined
? w.width
: p.width !== undefined
? p.width
: getDefaultWidth(type);
const height =
place.height !== undefined
? place.height
: w.height !== undefined
? w.height
: p.height !== undefined
? p.height
: getDefaultHeight(type);
const p: Record<string, any> = w.properties || w.params || {};
const width = [place.width, w.width, p.width].find(v => v !== undefined) ?? getDefaultWidth(type);
const height = [place.height, w.height, p.height].find(v => v !== undefined) ?? getDefaultHeight(type);
const id = w.variable_name || `ast_widget_${counter}`;
const widget: WidgetData = {
id,
@ -67,33 +68,58 @@ export function convertASTResultToDesignData(
widgets.push(widget);
}
const events: Event[] = [];
const events: DesignEvent[] = [];
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`;
events.push({
widget: callback.widget,
type: 'command',
name: cleanName,
code: callback.command?.lambda_body || '',
});
addDesignEvent(
events,
methods,
callback.widget,
EVENT_TYPES.COMMAND,
callback.command?.name,
() => `on_${callback.widget}_command`,
callback.command?.lambda_body
);
}
}
if (astResult.bind_events) {
for (const bindEvent of astResult.bind_events) {
const rawName = bindEvent.callback?.name;
const cleanName = rawName
? String(rawName).replace(/^self\./, '')
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
events.push({
widget: bindEvent.widget,
type: bindEvent.event,
name: cleanName,
code: bindEvent.callback?.lambda_body || '',
});
addDesignEvent(
events,
methods,
bindEvent.widget,
bindEvent.event,
bindEvent.callback?.name,
() => `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`,
bindEvent.callback?.lambda_body
);
}
}
if (astResult.widgets) {
for (const w of astResult.widgets) {
const p: Record<string, any> = w.properties || w.params || {};
if (p.command) {
const widgetId = w.variable_name;
if (!widgetId) continue;
const cmd = p.command;
const rawName = cmd.name || cmd;
addDesignEvent(
events,
methods,
widgetId,
EVENT_TYPES.COMMAND,
rawName,
() => `on_${widgetId}_command`,
undefined,
true
);
}
}
}
@ -102,8 +128,10 @@ export function convertASTResultToDesignData(
const result: DesignData = {
form: {
name: DEFAULT_FORM_CONFIG.FORM_NAME,
title: formTitle,
size: { width: formWidth, height: formHeight },
className: className,
},
widgets,
events: filteredEvents.length ? filteredEvents : [],
@ -111,8 +139,8 @@ export function convertASTResultToDesignData(
return result;
}
function extractWidgetPropertiesFromAST(w: any): any {
const props: any = {};
function extractWidgetPropertiesFromAST(w: ASTWidget): WidgetProperties {
const props: WidgetProperties = {};
if (!w) return props;
const p = w.properties || w.params || {};
if (p.text) props.text = p.text;
@ -125,3 +153,43 @@ function extractWidgetPropertiesFromAST(w: any): any {
if (p.height !== undefined) props.height = p.height;
return props;
}
function addDesignEvent(
events: DesignEvent[],
methods: Record<string, any>,
widgetId: string,
eventType: string,
rawName: string | undefined | null,
defaultNameGen: () => string,
lambdaBody: string | undefined,
checkExists: boolean = false
) {
const cleanName = rawName
? String(rawName).replace(/^self\./, '')
: defaultNameGen();
if (checkExists) {
const exists = events.find(
(e) => e.widget === widgetId && e.type === eventType
);
if (exists) return;
}
const methodData = methods[cleanName];
const codeBody =
typeof methodData === 'object' && methodData !== null
? (methodData as ASTMethodData).body
: (methodData as string);
const signature =
typeof methodData === 'object' && methodData !== null
? (methodData as ASTMethodData).signature
: undefined;
events.push({
widget: widgetId,
type: eventType,
name: cleanName,
code: codeBody || lambdaBody || '',
signature: signature,
});
}

60
src/parser/astTypes.ts Normal file
View File

@ -0,0 +1,60 @@
import { WidgetProperties } from '../generator/types';
export interface ASTPlacement {
x?: number;
y?: number;
width?: number;
height?: number;
}
export interface ASTWidget {
type?: string;
variable_name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
placement?: ASTPlacement;
properties?: Record<string, any>;
params?: Record<string, any>;
}
export interface ASTCommandCallback {
widget: string;
command?: {
name?: string;
lambda_body?: string;
};
}
export interface ASTBindEvent {
widget: string;
event: string;
callback?: {
name?: string;
lambda_body?: string;
};
}
export interface ASTMethodData {
body: string;
signature?: string;
}
export interface ASTResult {
window?: {
title?: string;
width?: number;
height?: number;
className?: string;
};
title?: string;
width?: number;
height?: number;
widgets: ASTWidget[];
command_callbacks?: ASTCommandCallback[];
bind_events?: ASTBindEvent[];
methods?: { [key: string]: string | ASTMethodData };
success?: boolean;
error?: string;
}

View File

@ -2,79 +2,178 @@ 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';
import { ASTResult } from './astTypes';
async function executePythonScript(
pythonScriptPath: string,
pythonFilePath: string
): Promise<string> {
return await new Promise((resolve, reject) => {
const pythonCommand = getPythonCommand();
const start = Date.now();
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath);
const process = spawn(pythonCommand, [
pythonScriptPath,
pythonFilePath,
]);
let result = '';
let errorOutput = '';
class PythonExecutor {
private static instance: PythonExecutor;
private cachedPythonPath: string | null = null;
process.stdout.on('data', (data) => {
result += data.toString();
private constructor() {}
public static getInstance(): PythonExecutor {
if (!PythonExecutor.instance) {
PythonExecutor.instance = new PythonExecutor();
}
return PythonExecutor.instance;
}
public async getPythonPath(): Promise<string> {
if (this.cachedPythonPath) {
return this.cachedPythonPath;
}
const commands = await this.findPythonCommands();
for (const cmd of commands) {
try {
await this.verifyPython(cmd);
this.cachedPythonPath = cmd;
return cmd;
} catch (e) {
}
}
throw new Error('No suitable Python interpreter found. Please install Python or configure "python.defaultInterpreterPath" in VS Code.');
}
private async verifyPython(cmd: string): Promise<void> {
return new Promise((resolve, reject) => {
const process = spawn(cmd, ['--version'], { shell: false });
process.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Python check failed with code ${code}`));
}
});
process.on('error', (err) => {
reject(err);
});
});
}
process.stderr.on('data', (data) => {
errorOutput += data.toString();
});
private async findPythonCommands(): Promise<string[]> {
const commands: string[] = [];
process.on('close', (code) => {
const ms = Date.now() - start;
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms);
if (code === 0) {
resolve(result);
} else {
reject(
new Error(
`Python script failed with code ${code}: ${errorOutput}`
)
);
const extension = vscode.extensions.getExtension('ms-python.python');
if (extension) {
if (!extension.isActive) {
await extension.activate();
}
}
const config = vscode.workspace.getConfiguration('python');
let pythonPath =
config.get<string>('defaultInterpreterPath') ||
config.get<string>('pythonPath');
if (pythonPath && pythonPath !== 'python') {
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);
}
if (process.platform === 'win32') {
commands.push('py');
commands.push('python');
} else {
commands.push('python3');
commands.push('python');
}
return Array.from(new Set(commands));
}
public async executeScript(scriptPath: string, args: string[] = [], stdinInput?: string): Promise<string> {
const pythonPath = await this.getPythonPath();
const allArgs = [scriptPath, ...args];
return new Promise((resolve, reject) => {
const process = spawn(pythonPath, allArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr || `Process exited with code ${code}`));
} else {
resolve(stdout);
}
});
process.on('error', (err) => {
reject(new Error(`Failed to spawn python process: ${err.message}`));
});
if (stdinInput) {
try {
process.stdin.write(stdinInput);
process.stdin.end();
} catch (e: any) {
reject(new Error(`Failed to write to stdin: ${e.message}`));
}
}
});
});
}
function getPythonCommand(): string {
return process.platform === 'win32' ? 'python' : 'python3';
}
function createTempPythonFile(pythonCode: string): string {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, `tk_ast_${Date.now()}.py`);
fs.writeFileSync(tempFilePath, pythonCode, 'utf8');
return tempFilePath;
}
function cleanupTempFile(tempFile: string): void {
try {
fs.unlinkSync(tempFile);
} catch {}
}
export async function runPythonAst(pythonCode: string): Promise<any | null> {
const tempFilePath = createTempPythonFile(pythonCode);
try {
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
const output = await executePythonScript(
pythonScriptPath,
tempFilePath
);
console.log('[PythonRunner] Received AST JSON length:', output.length);
const parsed = JSON.parse(output);
return parsed;
} catch (err) {
console.error('[PythonRunner] Error running Python AST:', err);
return null;
} finally {
cleanupTempFile(tempFilePath);
console.log('[PythonRunner] Temp file cleaned:', tempFilePath);
}
}
export async function executePythonScript(
pythonScriptPath: string,
pythonCode: string
): Promise<string> {
try {
const executor = PythonExecutor.getInstance();
return await executor.executeScript(pythonScriptPath, [], pythonCode);
} catch (error: any) {
throw error;
}
}
export async function runPythonAst(code: string): Promise<ASTResult | { error: string }> {
try {
const executor = PythonExecutor.getInstance();
const scriptPath = path.join(
__dirname,
'..',
'..',
'src',
'parser',
'tkinter_ast_parser.py'
);
const output = await executor.executeScript(scriptPath, [], code);
try {
return JSON.parse(output) as ASTResult;
} catch (e) {
return { error: 'Failed to parse JSON output: ' + e };
}
} catch (error: any) {
return { error: error.message };
}
}

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,7 @@
import ast
import textwrap
import tokenize
import io
from typing import Dict, List, Any, Optional
from .imports import handle_import, handle_import_from
@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
class TkinterAnalyzer(ast.NodeVisitor):
def __init__(self):
def __init__(self, source_code: str = ""):
self.source_code = source_code
self.line_offsets = [0]
current = 0
if source_code:
for line in source_code.splitlines(keepends=True):
current += len(line)
self.line_offsets.append(current)
self.widgets: List[Dict[str, Any]] = []
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
self.imports: Dict[str, str] = {}
@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
self.event_handlers: List[Dict[str, Any]] = []
self.command_callbacks: List[Dict[str, Any]] = []
self.bind_events: List[Dict[str, Any]] = []
self.methods: Dict[str, str] = {}
def visit_Import(self, node: ast.Import):
handle_import(self, node)
@ -36,12 +48,119 @@ class TkinterAnalyzer(ast.NodeVisitor):
def visit_ClassDef(self, node: ast.ClassDef):
prev = self.current_class
enter_class(self, node)
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:
start_line = node.lineno
end_line = getattr(node, 'end_lineno', None)
if end_line is None:
end_line = node.body[-1].lineno
lines = self.source_code.splitlines(keepends=True)
func_lines = lines[start_line-1 : end_line]
func_text = "".join(func_lines)
body_start_idx = -1
try:
tokens = list(tokenize.tokenize(io.BytesIO(func_text.encode('utf-8')).readline))
nesting = 0
colon_token = None
for tok in tokens:
if tok.type == tokenize.OP:
if tok.string in '([{':
nesting += 1
elif tok.string in ')]}':
nesting -= 1
elif tok.string == ':' and nesting == 0:
colon_token = tok
break
if colon_token:
colon_line_idx = colon_token.end[0] - 1
body_lines = func_lines[colon_line_idx + 1:]
colon_line = func_lines[colon_line_idx]
after_colon = colon_line[colon_token.end[1]:]
if after_colon.strip():
body_lines.insert(0, after_colon)
body_text = "".join(body_lines)
dedented_body = textwrap.dedent(body_text)
sig_lines = func_lines[:colon_line_idx]
sig_lines.append(colon_line[:colon_token.end[1]])
sig_raw = "".join(sig_lines).strip()
if sig_raw.endswith(':'):
sig_raw = sig_raw[:-1].strip()
self.methods[node.name] = {
'body': dedented_body.strip(),
'signature': sig_raw
}
except tokenize.TokenError:
pass
except Exception as e:
pass
self.generic_visit(node)
exit_function(self, prev)
@ -101,9 +220,3 @@ class TkinterAnalyzer(ast.NodeVisitor):
def analyze_widget_creation_commands(self, node: ast.Assign):
return analyze_widget_creation_commands(self, node)
def create_widget_handler_connections(self, widgets: List[Dict[str, Any]]) -> Dict[str, Any]:
return create_widget_handler_connections(self, widgets)
def is_interactive_widget(self, widget_type: str) -> bool:
return is_interactive_widget(widget_type)

View File

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

View File

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

View File

@ -19,10 +19,13 @@ 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']
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 +34,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 +52,6 @@ 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
elif isinstance(call_node.func, ast.Name):
widget_type = call_node.func.id
else:

View File

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

View File

@ -1,12 +1,16 @@
#!/usr/bin/env python3
import sys
import json
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from tk_ast.parser import parse_tkinter_code, parse_file
except Exception as e:
except ImportError as e:
raise RuntimeError(
f"Failed to import tk_ast package: {e}. Ensure 'tk_ast' exists next to this script."
)
@ -14,8 +18,8 @@ except Exception as e:
if __name__ == '__main__':
if len(sys.argv) > 1:
print(parse_file(sys.argv[1]))
sys.stdout.write(str(parse_file(sys.argv[1])) + '\n')
else:
code = sys.stdin.read()
result = parse_tkinter_code(code)
print(json.dumps(result, indent=2, ensure_ascii=False))
sys.stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + '\n')

View File

@ -1,21 +1,9 @@
import { WidgetType, WIDGET_DIMENSIONS } from '../constants';
export function getDefaultWidth(type: string): number {
const defaults: { [key: string]: number } = {
Label: 100,
Button: 80,
Text: 200,
Checkbutton: 100,
Radiobutton: 100,
};
return defaults[type] || 100;
return WIDGET_DIMENSIONS[type as WidgetType]?.width || WIDGET_DIMENSIONS.DEFAULT.width;
}
export function getDefaultHeight(type: string): number {
const defaults: { [key: string]: number } = {
Label: 25,
Button: 30,
Text: 100,
Checkbutton: 25,
Radiobutton: 25,
};
return defaults[type] || 25;
return WIDGET_DIMENSIONS[type as WidgetType]?.height || WIDGET_DIMENSIONS.DEFAULT.height;
}

View File

@ -1,34 +1,26 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { Uri } from 'vscode';
import { runPythonAst } from '../parser/pythonRunner';
import { DesignData } from '../generator/types';
import { WebviewMessage } from './react/types';
import { ProjectIO } from './projectIO';
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'tkinter-designer';
public static _instance: TkinterDesignerProvider | undefined;
private _view?: vscode.WebviewPanel;
private _designData: any = {
private _view?: vscode.WebviewPanel | vscode.WebviewView;
private _designData: DesignData = {
widgets: [],
events: [],
form: { title: 'My App', size: { width: 800, height: 600 } },
form: { name: 'App', title: 'My App', size: { width: 800, height: 600 } },
};
constructor(private readonly _extensionUri: vscode.Uri) {}
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
if (TkinterDesignerProvider._instance?._view) {
console.log('[Webview] Revealing existing panel');
TkinterDesignerProvider._instance._view.reveal(column);
return TkinterDesignerProvider._instance;
}
console.log('[Webview] Creating new panel');
public static createNew(extensionUri: vscode.Uri): TkinterDesignerProvider {
const panel = vscode.window.createWebviewPanel(
TkinterDesignerProvider.viewType,
'Tkinter Designer',
column || vscode.ViewColumn.One,
vscode.ViewColumn.Beside,
{
enableScripts: true,
retainContextWhenHidden: true,
@ -40,24 +32,17 @@ 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(
@ -65,7 +50,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken
) {
this._view = webviewView as any;
this._view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [
@ -85,8 +70,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
}
private _setupMessageHandling(webview: vscode.Webview) {
webview.onDidReceiveMessage((message) => {
console.log('[Webview] Message:', message.type);
webview.onDidReceiveMessage(async (message) => {
switch (message.type) {
case 'designUpdated':
this._designData = message.data;
@ -107,12 +91,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
type: 'loadDesign',
data: this._designData,
});
console.log('[Webview] Sent loadDesign');
}
break;
case 'generateCode':
console.log('[Webview] Generating code from webview');
this.handleGenerateCode(message.data);
await this.handleGenerateCode(message.data);
break;
case 'applyChanges':
await this.handleApplyChanges(message.data);
break;
case 'showInfo':
vscode.window.showInformationMessage(message.text);
@ -120,6 +105,15 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
case 'showError':
vscode.window.showErrorMessage(message.text);
break;
case 'exportProject':
await ProjectIO.exportProject(message.data);
break;
case 'importProject':
const data = await ProjectIO.importProject();
if (data) {
this.loadDesignData(data);
}
break;
}
}, undefined);
}
@ -136,67 +130,260 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
type: 'loadDesign',
data: this._designData,
});
console.log('[Webview] loadDesign posted');
} else {
}
}
private async handleGenerateCode(designData: any): Promise<void> {
try {
console.log('[GenerateCode] Start');
const { CodeGenerator } = await import(
'../generator/CodeGenerator'
'../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'
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
} else {
console.log('[GenerateCode] Creating new file');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
return;
return;
}
const formName = designData.form.name || 'Form1';
let fileName = `${formName}.py`;
let fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
let counter = 1;
const exists = async (u: vscode.Uri) => {
try {
await vscode.workspace.fs.stat(u);
return true;
} catch {
return false;
}
const fileName = `app_${Date.now()}.py`;
const filePath = path.join(
workspaceFolder.uri.fsPath,
fileName
};
while (await exists(fileUri)) {
fileName = `${formName}_${counter}.py`;
fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
counter++;
}
const encoder = new TextEncoder();
const fileBytes = encoder.encode(pythonCode);
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc, {
preview: false,
});
vscode.window.showInformationMessage(
`Python file created: ${fileName}`
);
const newFormName = path.basename(fileName, '.py');
if (newFormName !== formName) {
if (this._view) {
const webview = (this._view as any).webview || this._view;
webview.postMessage({
type: 'updateFormName',
name: newFormName,
});
}
}
} catch (error) {
vscode.window.showErrorMessage(`Error generating code: ${error}`);
}
}
private async handleApplyChanges(designData: DesignData): Promise<void> {
try {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage(
'No workspace folder is open. Please open a folder first.'
);
const fileUri = Uri.file(filePath);
const encoder = new TextEncoder();
const fileBytes = encoder.encode(pythonCode);
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
return;
}
const formName = designData.form.name || 'Form1';
const fileName = `${formName}.py`;
const fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
let existingMethods: any = {};
let oldClassName = '';
let fileContent = '';
let fileExists = false;
try {
await vscode.workspace.fs.stat(fileUri);
fileExists = true;
const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc, {
preview: false,
});
fileContent = doc.getText();
const astResult = await runPythonAst(fileContent);
if (astResult && !('error' in astResult)) {
if (astResult.methods) {
existingMethods = astResult.methods as Record<string, any>;
if (designData.events) {
for (const event of designData.events) {
if (existingMethods[event.name]) {
const methodData = existingMethods[event.name];
if (typeof methodData === 'object' && methodData !== null) {
event.code = methodData.body;
event.signature = methodData.signature;
} else if (typeof methodData === 'string') {
event.code = methodData;
}
}
}
}
}
if (astResult.window && astResult.window.className) {
oldClassName = astResult.window.className;
}
}
} catch (e) {
}
const { CodeGenerator } = await import(
'../generator/codeGenerator'
);
const generator = new CodeGenerator();
if (!fileExists) {
const pythonCode = generator.generateTkinterCode(designData);
const encoder = new TextEncoder();
await vscode.workspace.fs.writeFile(
fileUri,
encoder.encode(pythonCode)
);
vscode.window.showInformationMessage(
`Python file created: ${fileName}`
`Created new file: ${fileName}`
);
} else {
const newCreateWidgets =
generator.generateCreateWidgetsBody(designData);
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 {
vscode.window.showWarningMessage(
'Could not find create_widgets method to update. Regenerating full file might be needed if structure is broken.'
);
}
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) {
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 {
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')}`;
}
}
}
}
const titleRegex = /self\.root\.title\s*\(\s*["'].*?["']\s*\)/;
newFileContent = newFileContent.replace(
titleRegex,
`self.root.title("${designData.form.title}")`
);
const geoRegex = /self\.root\.geometry\s*\(\s*["'].*?["']\s*\)/;
newFileContent = newFileContent.replace(
geoRegex,
`self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")`
);
if (
oldClassName &&
designData.form.className &&
oldClassName !== designData.form.className
) {
const classDefRegex = new RegExp(
`class\\s+${oldClassName}\\s*:`
);
newFileContent = newFileContent.replace(
classDefRegex,
`class ${designData.form.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) {
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('[GenerateCode] Done');
} catch (error) {
console.error('[GenerateCode] Error:', error);
vscode.window.showErrorMessage(`Error generating code: ${error}`);
vscode.window.showErrorMessage(`Error applying changes: ${error}`);
}
}

95
src/webview/projectIO.ts Normal file
View File

@ -0,0 +1,95 @@
import * as vscode from 'vscode';
import { DesignData } from '../generator/types';
export class ProjectIO {
public static async exportProject(data: DesignData): 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: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
vscode.window.showErrorMessage(`Export failed: ${errorMessage}`);
}
}
public static async importProject(): Promise<DesignData | null> {
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);
const designData = data.data || data;
if (ProjectIO.validateDesignData(designData)) {
vscode.window.showInformationMessage('Project imported successfully!');
return designData;
} else {
vscode.window.showErrorMessage('Invalid project file format.');
return null;
}
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
vscode.window.showErrorMessage(`Import failed: ${errorMessage}`);
}
return null;
}
private static validateDesignData(data: any): data is DesignData {
if (!data || typeof data !== 'object') {
return false;
}
if (!data.form || typeof data.form !== 'object') {
return false;
}
if (typeof data.form.name !== 'string' || typeof data.form.title !== 'string') {
return false;
}
if (!data.form.size || typeof data.form.size.width !== 'number' || typeof data.form.size.height !== 'number') {
return false;
}
if (!Array.isArray(data.widgets)) {
return false;
}
for (const widget of data.widgets) {
if (!widget.id || typeof widget.id !== 'string') {
return false;
}
if (!widget.type || typeof widget.type !== 'string') {
return false;
}
if (typeof widget.x !== 'number' || typeof widget.y !== 'number') {
return false;
}
}
return true;
}
}

View File

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

View File

@ -2,6 +2,69 @@ 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 className="widget-label-content">
{w.properties?.text || 'Label'}
</div>
);
case 'Button':
return (
<div className="widget-button-content">
{w.properties?.text || 'Button'}
</div>
);
case 'Entry':
return (
<input
type="text"
disabled
placeholder="Entry"
className="widget-entry-content"
/>
);
case 'Text':
return (
<textarea
disabled
className="widget-text-content"
>
Text Area
</textarea>
);
case 'Checkbutton':
return (
<div className="widget-check-content">
<input
type="checkbox"
disabled
checked
className="widget-check-input"
/>
<span className="widget-check-text">{w.properties?.text || 'Check'}</span>
</div>
);
case 'Radiobutton':
return (
<div className="widget-radio-content">
<input
type="radio"
disabled
checked
className="widget-check-input"
/>
<span className="widget-check-text">{w.properties?.text || 'Radio'}</span>
</div>
);
default:
return w.properties?.text || w.type;
}
}
export function Canvas() {
const dispatch = useAppDispatch();
const { design, selectedWidgetId } = useAppState();
@ -17,12 +80,10 @@ export function Canvas() {
const rect = containerRef.current?.getBoundingClientRect();
const x = e.clientX - (rect?.left || 0);
const y = e.clientY - (rect?.top || 0);
console.log('[Canvas] Drop widget', type, 'at', x, y);
if (type) dispatch({ type: 'addWidget', payload: { type, x, y } });
};
const onSelect = (id: string | null) => {
console.log('[Canvas] Select widget', id);
dispatch({ type: 'selectWidget', payload: { id } });
};
@ -34,19 +95,126 @@ export function Canvas() {
if (!w) return;
const initX = w.x;
const initY = w.y;
console.log('[Canvas] Drag start', id, 'at', initX, initY);
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
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 } },
});
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
console.log('[Canvas] Drag end', id);
dispatch({ type: 'pushHistory' });
};
window.addEventListener('mousemove', onMove);
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;
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const patch: Partial<Widget> = {};
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);
dispatch({ type: 'pushHistory' });
};
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;
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);
}
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);
dispatch({ type: 'pushHistory' });
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
@ -54,41 +222,123 @@ export function Canvas() {
return (
<div className="canvas-container">
<div
id="designCanvas"
className="design-canvas"
ref={containerRef}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => onSelect(null)}
>
{design.widgets.length === 0 && (
<div className="canvas-placeholder">
<p>Drag widgets here to start designing</p>
<div className="window-frame">
<div className="window-title-bar">
<div className="window-title">
{design.form?.title || 'Tkinter App'}
</div>
)}
{design.widgets.map((w) => (
<div
key={w.id}
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
style={{
position: 'absolute',
left: w.x,
top: w.y,
width: w.width,
height: w.height,
}}
onClick={(e) => {
e.stopPropagation();
onSelect(w.id);
}}
onMouseDown={(e) => onMouseDown(e, w.id)}
>
<div className="widget-content">
{w.properties?.text || w.type}
<div className="window-controls">
<div className="window-control minimize"></div>
<div className="window-control maximize"></div>
<div className="window-control close"></div>
</div>
</div>
<div
id="designCanvas"
className="design-canvas"
style={{
'--canvas-width': `${design.form.size.width}px`,
'--canvas-height': `${design.form.size.height}px`,
} as React.CSSProperties}
ref={containerRef}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => onSelect(null)}
>
{design.widgets.length === 0 && (
<div className="canvas-placeholder">
<p>Drag widgets here to start designing</p>
</div>
</div>
))}
)}
{design.widgets.map((w) => (
<div
key={w.id}
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
style={{
'--widget-x': `${w.x}px`,
'--widget-y': `${w.y}px`,
'--widget-width': `${w.width}px`,
'--widget-height': `${w.height}px`,
} as React.CSSProperties}
onClick={(e) => {
e.stopPropagation();
onSelect(w.id);
}}
onMouseDown={(e) => onMouseDown(e, w.id)}
>
<div className="widget-content">
{renderWidgetContent(w)}
</div>
{selectedWidgetId === w.id && (
<>
<div
className="resize-handle n"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'n')
}
/>
<div
className="resize-handle s"
onMouseDown={(e) =>
onResizeStart(e, w.id, 's')
}
/>
<div
className="resize-handle e"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'e')
}
/>
<div
className="resize-handle w"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'w')
}
/>
<div
className="resize-handle ne"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'ne')
}
/>
<div
className="resize-handle nw"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'nw')
}
/>
<div
className="resize-handle se"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'se')
}
/>
<div
className="resize-handle sw"
onMouseDown={(e) =>
onResizeStart(e, w.id, 'sw')
}
/>
</>
)}
</div>
))}
</div>
<div
className="window-resize-handle e"
onMouseDown={(e) => onWindowResizeStart(e, 'e')}
onDragStart={(e) => e.preventDefault()}
/>
<div
className="window-resize-handle s"
onMouseDown={(e) => onWindowResizeStart(e, 's')}
onDragStart={(e) => e.preventDefault()}
/>
<div
className="window-resize-handle se"
onMouseDown={(e) => onWindowResizeStart(e, 'se')}
onDragStart={(e) => e.preventDefault()}
/>
</div>
</div>
);

View File

@ -0,0 +1,45 @@
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) {
}
public render() {
if (this.state.hasError) {
return (
<div style={{ padding: '20px', color: 'red', backgroundColor: '#ffe6e6', border: '1px solid red', borderRadius: '4px' }}>
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
</details>
<button
style={{ marginTop: '10px', padding: '5px 10px', cursor: 'pointer' }}
onClick={() => this.setState({ hasError: false })}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -5,8 +5,7 @@ export function EventsPanel() {
const dispatch = useAppDispatch();
const { design, selectedWidgetId, vscode } = useAppState();
const [eventType, setEventType] = useState('command');
const [eventName, setEventName] = useState('onClick');
const [eventCode, setEventCode] = useState('print("clicked")');
const [eventName, setEventName] = useState('on_click');
const w = design.widgets.find((x) => x.id === selectedWidgetId);
const widgetEvents = (id: string | undefined) =>
@ -20,10 +19,17 @@ export function EventsPanel() {
});
return;
}
if (!eventType || !eventName || !eventCode) {
if (!eventName) {
vscode.postMessage({
type: 'showError',
text: 'Please fill in all fields: event type, name, and code',
text: 'Please fill in handler name',
});
return;
}
if (!eventType) {
vscode.postMessage({
type: 'showError',
text: 'Please fill in event type',
});
return;
}
@ -33,21 +39,39 @@ export function EventsPanel() {
widget: w.id,
type: eventType,
name: eventName,
code: eventCode,
code: 'pass',
},
});
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,41 @@ export function EventsPanel() {
<label className="property-label">
Event Type:
</label>
<input
className="event-type-select"
type="text"
value={eventType}
onChange={(e) => setEventType(e.target.value)}
/>
<div className="mb-8">
<select
className="event-handler-input mb-4"
value={
commonEvents.includes(eventType)
? eventType
: 'custom'
}
onChange={(e) => {
if (e.target.value !== 'custom') {
setEventType(e.target.value);
} else {
setEventType('');
}
}}
>
{commonEvents.map((evt) => (
<option key={evt} value={evt}>
{evt}
</option>
))}
<option value="custom">Custom...</option>
</select>
{!commonEvents.includes(eventType) && (
<input
className="event-handler-input"
type="text"
placeholder="Enter custom event type"
value={eventType}
onChange={(e) =>
setEventType(e.target.value)
}
/>
)}
</div>
<label className="property-label">
Handler Name:
</label>
@ -75,14 +128,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 +142,26 @@ 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 event-label-bold"
>
{ev.type}:
</span>
<span
className="event-name event-value-margin"
>
{ev.name}
</span>
<div className="event-code">
{ev.code}
</div>
</div>
<div className="event-actions">
<button
className="event-btn secondary"
onClick={() => remove(ev.type)}
onClick={() =>
remove(ev.type, ev.name)
}
>
Remove
</button>

View File

@ -5,6 +5,7 @@ import type { WidgetType } from '../types';
const WIDGETS: WidgetType[] = [
'Label',
'Button',
'Entry',
'Text',
'Checkbutton',
'Radiobutton',
@ -15,7 +16,6 @@ export function Palette() {
e: React.DragEvent<HTMLDivElement>,
type: WidgetType
) => {
console.log('[Palette] Drag start', type);
e.dataTransfer.setData('text/plain', type);
};
return (

View File

@ -1,19 +1,88 @@
import React from 'react';
import { useAppDispatch, useAppState } from '../state';
import { Widget } from '../types';
export function PropertiesPanel() {
const dispatch = useAppDispatch();
const { design, selectedWidgetId } = useAppState();
const updateForm = <K extends keyof typeof design.form>(key: K, value: typeof design.form[K]) => {
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>) => {
const update = (patch: Partial<Widget>) => {
if (!w) return;
dispatch({
type: 'updateWidget',
payload: { id: w.id, patch: patch as any },
payload: { id: w.id, patch },
});
};
const updateProp = (key: string, value: any) => {
const updateProp = (key: string, value: string | number | boolean) => {
if (!w) return;
dispatch({
type: 'updateProps',
@ -27,7 +96,7 @@ export function PropertiesPanel() {
return (
<div className="properties-panel">
<h3>Properties</h3>
<h3>Widget Properties</h3>
<div id="propertiesContent">
{!w ? (
<p>Select a widget to edit properties</p>

View File

@ -1,36 +1,23 @@
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 {
console.log('[Toolbar] Export project');
const exportData = {
version: '1.0',
created: new Date().toISOString(),
description: 'Tkinter Designer Project',
data: design,
};
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tkinter-project-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
vscode.postMessage({
type: 'showInfo',
text: 'Project exported successfully!',
type: 'exportProject',
data: exportData,
});
} catch (error: any) {
console.error('Export error:', error);
vscode.postMessage({
type: 'showError',
text: `Export failed: ${error.message}`,
@ -38,41 +25,13 @@ export function Toolbar() {
}
};
const importProject = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log('[Toolbar] Import project');
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const jsonString = ev.target?.result as string;
const obj = JSON.parse(jsonString);
if (!obj || !obj.data)
throw new Error('Invalid project file format');
dispatch({ type: 'init', payload: obj.data });
vscode.postMessage({
type: 'showInfo',
text: 'Project imported successfully!',
});
} catch (error: any) {
console.error('Import error:', error);
vscode.postMessage({
type: 'showError',
text: `Import failed: ${error.message}`,
});
}
};
reader.onerror = () =>
vscode.postMessage({
type: 'showError',
text: 'Failed to read file',
});
reader.readAsText(file);
e.target.value = '';
const importProject = () => {
vscode.postMessage({
type: 'importProject',
});
};
const clearAll = () => {
console.log('[Toolbar] Clear all');
dispatch({ type: 'clear' });
vscode.postMessage({
type: 'showInfo',
@ -81,7 +40,6 @@ export function Toolbar() {
};
const generateCode = () => {
console.log('[Toolbar] Generate code');
if (design.widgets.length === 0) {
vscode.postMessage({
type: 'showError',
@ -96,18 +54,42 @@ export function Toolbar() {
vscode.postMessage({ type: 'generateCode', data: design });
};
const applyChanges = () => {
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 className="toolbar-group">
<label htmlFor="formName" className="toolbar-label">
Form Name:
</label>
<input
id="formName"
type="text"
value={design.form.name || 'Form1'}
onChange={(e) =>
dispatch({
type: 'setForm',
payload: { name: e.target.value },
})
}
className="toolbar-input"
/>
</div>
<button
id="undoBtn"
className="btn btn-outline"
@ -137,7 +119,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 +131,13 @@ export function Toolbar() {
>
Generate Code
</button>
<button
id="applyBtn"
className="btn btn-primary"
onClick={applyChanges}
>
Apply Changes
</button>
<button
id="clearBtn"
className="btn btn-secondary"

View File

@ -12,7 +12,7 @@ declare global {
const vscode =
typeof window.acquireVsCodeApi === 'function'
? window.acquireVsCodeApi()
: { postMessage: (msg: any) => console.log('[Webview message]', msg) };
: { postMessage: (msg: any) => {} };
const container = document.getElementById('root');
if (container) {

View File

@ -1,4 +1,5 @@
import React, { createContext, useContext, useReducer, useMemo } from 'react';
import { produce } from 'immer';
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
export interface AppState {
@ -20,180 +21,223 @@ 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;
};
};
}
| { type: 'pushHistory' };
const AppStateContext = createContext<AppState | undefined>(undefined);
const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
undefined
);
function clone(data: DesignData): DesignData {
return JSON.parse(JSON.stringify(data));
// Helper to check if two design states are effectively different to warrant a history entry
// This helps prevent spamming history with tiny drag updates if we were to push every frame (though we usually push on mouse up)
function hasDesignChanged(prev: DesignData, next: DesignData): boolean {
return JSON.stringify(prev) !== JSON.stringify(next);
}
function reducer(state: AppState, action: Action): AppState {
console.log('[State] Action:', action.type);
switch (action.type) {
case 'init': {
const next = {
...state,
design: action.payload,
selectedWidgetId: null,
};
if (!next.design.events) next.design.events = [];
console.log('[State] Init design widgets:', next.design.widgets.length);
return pushHistory(next);
}
case 'addWidget': {
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const w: Widget = {
id,
type: action.payload.type,
x: action.payload.x,
y: action.payload.y,
width: 120,
height: 40,
properties: { text: action.payload.type },
};
const design = clone(state.design);
design.widgets.push(w);
const next = { ...state, design, selectedWidgetId: id };
console.log('[State] Added widget:', id, w.type);
return pushHistory(next);
}
case 'selectWidget': {
console.log('[State] Select widget:', action.payload.id);
return { ...state, selectedWidgetId: action.payload.id };
}
case 'updateWidget': {
const design = clone(state.design);
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
design.widgets[idx] = {
...design.widgets[idx],
...action.payload.patch,
};
}
const next = { ...state, design };
console.log('[State] Updated widget:', action.payload.id);
return pushHistory(next);
}
case 'updateProps': {
const design = clone(state.design);
const idx = design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
design.widgets[idx] = {
...design.widgets[idx],
properties: {
...design.widgets[idx].properties,
...action.payload.properties,
},
};
}
const next = { ...state, design };
console.log('[State] Updated properties for:', action.payload.id);
return pushHistory(next);
}
case 'deleteWidget': {
const design = clone(state.design);
design.widgets = design.widgets.filter(
(w) => w.id !== action.payload.id
);
if (!design.events) design.events = [];
design.events = design.events.filter(
(e) => e.widget !== action.payload.id
);
const next = { ...state, design, selectedWidgetId: null };
console.log('[State] Deleted widget:', action.payload.id);
return pushHistory(next);
}
case 'addEvent': {
const design = clone(state.design);
if (!design.events) design.events = [];
const exists = design.events.find(
(e) =>
e.widget === action.payload.widget &&
e.type === action.payload.type
);
if (!exists) {
design.events.push(action.payload);
}
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget);
return pushHistory({ ...state, design });
}
case 'removeEvent': {
const design = clone(state.design);
if (!design.events) design.events = [];
design.events = design.events.filter(
(e) =>
!(
e.widget === action.payload.widget &&
e.type === action.payload.type
)
);
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget);
return pushHistory({ ...state, design });
}
case 'clear': {
const design: DesignData = {
form: state.design.form,
widgets: [],
events: [],
};
console.log('[State] Cleared design');
return pushHistory({ ...state, design, selectedWidgetId: null });
}
case 'undo': {
if (state.historyIndex <= 0) return state;
const idx = state.historyIndex - 1;
console.log('[State] Undo to index:', idx);
return {
...state,
historyIndex: idx,
design: clone(state.history[idx]),
};
}
case 'redo': {
if (state.historyIndex >= state.history.length - 1) return state;
const idx = state.historyIndex + 1;
console.log('[State] Redo to index:', idx);
return {
...state,
historyIndex: idx,
design: clone(state.history[idx]),
};
}
case 'setForm': {
const design = clone(state.design);
if (action.payload.title) design.form.title = action.payload.title;
if (action.payload.size) design.form.size = action.payload.size;
console.log('[State] Set form', action.payload);
return pushHistory({ ...state, design });
}
default:
return state;
if (action.type === 'undo') {
if (state.historyIndex <= 0) return state;
const idx = state.historyIndex - 1;
return {
...state,
historyIndex: idx,
design: JSON.parse(JSON.stringify(state.history[idx])),
};
}
if (action.type === 'redo') {
if (state.historyIndex >= state.history.length - 1) return state;
const idx = state.historyIndex + 1;
return {
...state,
historyIndex: idx,
design: JSON.parse(JSON.stringify(state.history[idx])),
};
}
if (action.type === 'pushHistory') {
// This action is explicitly called when an operation is "finished" (e.g. onMouseUp after drag)
if (state.historyIndex >= 0 && !hasDesignChanged(state.history[state.historyIndex], state.design)) {
return state;
}
return produce(state, (draft) => {
pushHistoryDraft(draft);
});
}
// For other actions, we update the state but DO NOT push to history automatically
// unless it's a discrete action like 'addWidget' or 'deleteWidget'.
// Continuous actions like 'updateWidget' (drag) should only update state,
// and rely on a subsequent 'pushHistory' or explicit logic to save state.
// However, to keep backward compatibility with existing components that might not call pushHistory,
// we will still push history for "one-off" actions, but we should be careful with high-frequency updates.
// In the previous implementation, every update pushed history.
// We will optimize this by only pushing history for significant actions,
// or letting the UI trigger history pushes for drag operations.
return produce(state, (draft) => {
let shouldPushHistory = false;
switch (action.type) {
case 'init': {
draft.design = action.payload;
draft.selectedWidgetId = null;
if (!draft.design.events) draft.design.events = [];
// Init resets history usually
draft.history = [JSON.parse(JSON.stringify(draft.design))];
draft.historyIndex = 0;
break;
}
case 'addWidget': {
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const w: Widget = {
id,
type: action.payload.type,
x: action.payload.x,
y: action.payload.y,
width: 120,
height: 40,
properties: { text: action.payload.type },
};
draft.design.widgets.push(w);
draft.selectedWidgetId = id;
shouldPushHistory = true;
break;
}
case 'selectWidget': {
draft.selectedWidgetId = action.payload.id;
// Selection change doesn't need history
break;
}
case 'updateWidget': {
const idx = draft.design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
Object.assign(
draft.design.widgets[idx],
action.payload.patch
);
}
// Don't push history here for every drag frame.
// Components should dispatch 'pushHistory' on drag end.
break;
}
case 'updateProps': {
const idx = draft.design.widgets.findIndex(
(w) => w.id === action.payload.id
);
if (idx >= 0) {
draft.design.widgets[idx].properties = {
...draft.design.widgets[idx].properties,
...action.payload.properties,
};
}
shouldPushHistory = true;
break;
}
case 'deleteWidget': {
draft.design.widgets = draft.design.widgets.filter(
(w) => w.id !== action.payload.id
);
if (!draft.design.events) draft.design.events = [];
draft.design.events = draft.design.events.filter(
(e) => e.widget !== action.payload.id
);
draft.selectedWidgetId = null;
shouldPushHistory = true;
break;
}
case 'addEvent': {
if (!draft.design.events) draft.design.events = [];
const existsIndex = draft.design.events.findIndex(
(e) =>
e.widget === action.payload.widget &&
e.type === action.payload.type &&
e.name === action.payload.name
);
if (existsIndex >= 0) {
draft.design.events[existsIndex].code = action.payload.code;
} else {
draft.design.events.push(action.payload);
}
shouldPushHistory = true;
break;
}
case 'removeEvent': {
if (!draft.design.events) draft.design.events = [];
draft.design.events = draft.design.events.filter((e) => {
const matchWidget = e.widget === action.payload.widget;
const matchType = e.type === action.payload.type;
const matchName = action.payload.name
? e.name === action.payload.name
: true;
return !(matchWidget && matchType && matchName);
});
shouldPushHistory = true;
break;
}
case 'clear': {
draft.design.widgets = [];
draft.design.events = [];
draft.selectedWidgetId = null;
shouldPushHistory = true;
break;
}
case 'setForm': {
if (action.payload.name)
draft.design.form.name = action.payload.name;
if (action.payload.title)
draft.design.form.title = action.payload.title;
if (action.payload.size)
draft.design.form.size = action.payload.size;
if (action.payload.className)
draft.design.form.className = action.payload.className;
shouldPushHistory = true;
break;
}
}
if (shouldPushHistory) {
pushHistoryDraft(draft);
}
});
}
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 };
function pushHistoryDraft(draft: AppState) {
// Limit history stack size to prevent memory issues
const MAX_HISTORY = 50;
// Remove future history if we were in the middle of the stack
if (draft.historyIndex < draft.history.length - 1) {
draft.history = draft.history.slice(0, draft.historyIndex + 1);
}
draft.history.push(JSON.parse(JSON.stringify(draft.design)));
if (draft.history.length > MAX_HISTORY) {
draft.history.shift(); // Remove oldest
}
draft.historyIndex = draft.history.length - 1;
}
export function useAppState() {
@ -217,7 +261,11 @@ export function AppProvider({
}) {
const initialDesign: DesignData = useMemo(
() => ({
form: { title: 'My App', size: { width: 800, height: 600 } },
form: {
name: 'Form1',
title: 'My App',
size: { width: 800, height: 600 },
},
widgets: [],
events: [],
}),

View File

@ -1,6 +1,7 @@
export type WidgetType =
| 'Label'
| 'Button'
| 'Entry'
| 'Text'
| 'Checkbutton'
| 'Radiobutton';
@ -24,9 +25,19 @@ export interface EventBinding {
export interface DesignData {
form: {
name: string;
title: string;
size: { width: number; height: number };
className?: string;
};
widgets: Widget[];
events: EventBinding[];
}
export type WebviewMessage =
| { type: 'designUpdated'; data: DesignData }
| { type: 'getDesignData' }
| { type: 'webviewReady' }
| { type: 'generateCode'; data: DesignData }
| { type: 'applyChanges'; data: DesignData };

View File

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

View File

@ -77,12 +77,13 @@ body {
.btn-outline {
background-color: transparent;
color: var(--vscode-button-foreground);
color: white;
border: 1px solid var(--vscode-button-border);
}
.btn-outline:hover {
background-color: var(--vscode-button-hoverBackground);
background-color: black;
border-color: black;
}
.btn-outline:disabled {
@ -359,6 +360,24 @@ body {
.event-item .event-info {
flex: 1;
display: flex;
align-items: center;
}
.event-label-bold {
font-weight: 600;
}
.event-value-margin {
margin-left: 5px;
}
.mb-4 {
margin-bottom: 4px;
}
.mb-8 {
margin-bottom: 8px;
}
.event-item .event-name {
@ -434,22 +453,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 +471,141 @@ 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;
position: relative;
}
.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;
width: var(--canvas-width);
height: var(--canvas-height);
}
.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 {
@ -486,6 +631,10 @@ body {
font-size: 12px;
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
left: var(--widget-x);
top: var(--widget-y);
width: var(--widget-width);
height: var(--widget-height);
}
.canvas-widget:hover {
@ -497,6 +646,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,38 +730,27 @@ body {
border: 1px solid #ced4da;
padding: 8px;
text-align: left;
resize: both;
overflow: auto;
overflow: hidden;
}
.canvas-widget.widget-checkbutton {
background-color: white;
color: #333;
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
}
.canvas-widget.widget-checkbutton::before {
content: '☑️';
font-size: 14px;
}
.canvas-widget.widget-radiobutton {
background-color: white;
color: #333;
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
}
.canvas-widget.widget-radiobutton::before {
content: '🔘';
font-size: 14px;
}
@keyframes fadeIn {
from {
opacity: 0;
@ -591,3 +794,73 @@ body {
height: 200px;
}
}
/* Widget specific styles for Canvas content */
.widget-label-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
white-space: nowrap;
}
.widget-button-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
.widget-entry-content {
width: 100%;
height: 100%;
}
.widget-text-content {
width: 100%;
height: 100%;
resize: none;
}
.widget-check-content,
.widget-radio-content {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 100%;
cursor: default;
white-space: nowrap;
overflow: hidden;
color: inherit;
pointer-events: none;
}
.widget-check-input {
margin: 0;
}
.widget-check-text {
color: #333;
}
/* Toolbar specific styles */
.toolbar-group {
display: flex;
align-items: center;
margin-right: 10px;
}
.toolbar-label {
margin-right: 5px;
}
.toolbar-input {
padding: 4px;
border-radius: 4px;
border: 1px solid #ccc;
}