refactor
This commit is contained in:
parent
c87e51053c
commit
d8b0c738c9
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
out
|
||||||
|
.vscode-test
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
.gitea
|
||||||
|
README.md
|
||||||
|
examples/
|
||||||
|
.vscode/
|
||||||
|
docs/
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,5 +2,4 @@
|
|||||||
out/
|
out/
|
||||||
docs/
|
docs/
|
||||||
node_modules/
|
node_modules/
|
||||||
README.md
|
|
||||||
examples/
|
examples/
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Use Node.js LTS
|
||||||
|
FROM node:18-slim
|
||||||
|
|
||||||
|
# Install Python 3 and git
|
||||||
|
RUN apt-get update && apt-get install -y python3 python3-pip git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the extension
|
||||||
|
RUN npm run compile
|
||||||
|
|
||||||
|
# Install vsce to package the extension
|
||||||
|
RUN npm install -g @vscode/vsce
|
||||||
|
|
||||||
|
# Default command to package the extension
|
||||||
|
CMD ["vsce", "package", "--out", "extension.vsix"]
|
||||||
83
README.md
Normal file
83
README.md
Normal 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
11
package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "tkinter-designer",
|
"name": "tkinter-designer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"immer": "^11.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@ -1243,6 +1244,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|||||||
@ -50,6 +50,7 @@
|
|||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"immer": "^11.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
50
src/constants.ts
Normal file
50
src/constants.ts
Normal 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',
|
||||||
|
}
|
||||||
|
};
|
||||||
100
src/extension.ts
100
src/extension.ts
@ -1,130 +1,59 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { CodeGenerator } from './generator/CodeGenerator';
|
import { CodeParser } from './parser/codeParser';
|
||||||
import { CodeParser } from './parser/CodeParser';
|
import { TkinterDesignerProvider } from './webview/tkinterDesignerProvider';
|
||||||
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
|
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
|
||||||
const provider = new TkinterDesignerProvider(context.extensionUri);
|
|
||||||
|
|
||||||
TkinterDesignerProvider._instance = provider;
|
|
||||||
|
|
||||||
|
export function activate(context: vscode.ExtensionContext) {3
|
||||||
const openDesignerCommand = vscode.commands.registerCommand(
|
const openDesignerCommand = vscode.commands.registerCommand(
|
||||||
'tkinter-designer.openDesigner',
|
'tkinter-designer.openDesigner',
|
||||||
() => {
|
() => {
|
||||||
TkinterDesignerProvider.createOrShow(context.extensionUri);
|
TkinterDesignerProvider.createNew(context.extensionUri);
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const generateCodeCommand = vscode.commands.registerCommand(
|
|
||||||
'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');
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const parseCodeCommand = vscode.commands.registerCommand(
|
const parseCodeCommand = vscode.commands.registerCommand(
|
||||||
'tkinter-designer.parseCode',
|
'tkinter-designer.parseCode',
|
||||||
async () => {
|
async () => {
|
||||||
console.log('[ParseCode] Command invoked');
|
|
||||||
const activeEditor = vscode.window.activeTextEditor;
|
const activeEditor = vscode.window.activeTextEditor;
|
||||||
|
|
||||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
if (activeEditor && activeEditor.document.languageId === 'python') {
|
||||||
const parser = new CodeParser();
|
const parser = new CodeParser();
|
||||||
const code = activeEditor.document.getText();
|
const code = activeEditor.document.getText();
|
||||||
console.log('[ParseCode] Code length:', code.length);
|
const fileName = path.basename(
|
||||||
|
activeEditor.document.fileName,
|
||||||
|
'.py'
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const designData = await parser.parseCodeToDesign(code);
|
const designData = await parser.parseCodeToDesign(
|
||||||
|
code,
|
||||||
|
fileName
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
designData &&
|
designData &&
|
||||||
designData.widgets &&
|
designData.widgets &&
|
||||||
designData.widgets.length > 0
|
designData.widgets.length > 0
|
||||||
) {
|
) {
|
||||||
console.log('[ParseCode] Widgets found:', designData.widgets.length);
|
const designerInstance = TkinterDesignerProvider.createNew(
|
||||||
const designerInstance =
|
|
||||||
TkinterDesignerProvider.createOrShow(
|
|
||||||
context.extensionUri
|
context.extensionUri
|
||||||
);
|
);
|
||||||
if (designerInstance) {
|
|
||||||
designerInstance.loadDesignData(designData);
|
designerInstance.loadDesignData(designData);
|
||||||
} else {
|
|
||||||
}
|
|
||||||
|
|
||||||
vscode.window.showInformationMessage(
|
vscode.window.showInformationMessage(
|
||||||
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
|
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('[ParseCode] No widgets found');
|
|
||||||
vscode.window.showWarningMessage(
|
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.'
|
'No tkinter widgets found in the code. Make sure your code contains tkinter widget creation statements like tk.Label(), tk.Button(), etc.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ParseCode] Error:', error);
|
|
||||||
vscode.window.showErrorMessage(
|
vscode.window.showErrorMessage(
|
||||||
`Error parsing code: ${error}`
|
`Error parsing code: ${error}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('[ParseCode] No active Python editor');
|
|
||||||
vscode.window.showErrorMessage(
|
vscode.window.showErrorMessage(
|
||||||
'Please open a Python file with tkinter code'
|
'Please open a Python file with tkinter code'
|
||||||
);
|
);
|
||||||
@ -134,7 +63,6 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
openDesignerCommand,
|
openDesignerCommand,
|
||||||
generateCodeCommand,
|
|
||||||
parseCodeCommand
|
parseCodeCommand
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { DesignData, WidgetData } from './types';
|
import { DesignData, WidgetData, DesignEvent } from './types';
|
||||||
import {
|
import {
|
||||||
getVariableName,
|
getVariableName,
|
||||||
generateVariableNames,
|
generateVariableNames,
|
||||||
getWidgetTypeForGeneration,
|
getWidgetTypeForGeneration,
|
||||||
indentText,
|
indentText,
|
||||||
|
escapeString,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import {
|
import {
|
||||||
getWidgetParameters,
|
getWidgetParameters,
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
generateWidgetContent,
|
generateWidgetContent,
|
||||||
} from './widgetHelpers';
|
} from './widgetHelpers';
|
||||||
import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers';
|
import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers';
|
||||||
|
import { DEFAULT_FORM_CONFIG, PYTHON_CODE } from '../constants';
|
||||||
|
|
||||||
export class CodeGenerator {
|
export class CodeGenerator {
|
||||||
private indentLevel = 0;
|
private indentLevel = 0;
|
||||||
@ -18,48 +20,46 @@ export class CodeGenerator {
|
|||||||
|
|
||||||
public generateTkinterCode(designData: DesignData): string {
|
public generateTkinterCode(designData: DesignData): string {
|
||||||
this.designData = designData;
|
this.designData = designData;
|
||||||
console.log('[Generator] Start, widgets:', designData.widgets.length, 'events:', designData.events?.length || 0);
|
const className = designData.form.className || DEFAULT_FORM_CONFIG.CLASS_NAME;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const nameMap = generateVariableNames(designData.widgets);
|
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('');
|
||||||
|
|
||||||
lines.push('class Application:');
|
lines.push(`class ${className}:`);
|
||||||
this.indentLevel = 1;
|
this.indentLevel = 1;
|
||||||
|
|
||||||
lines.push(this.indent('def __init__(self):'));
|
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.INIT}(self):`));
|
||||||
this.indentLevel = 2;
|
this.indentLevel = 2;
|
||||||
lines.push(this.indent('self.root = tk.Tk()'));
|
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT} = tk.Tk()`));
|
||||||
lines.push(this.indent(`self.root.title("${designData.form.title}")`));
|
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.title("${escapeString(designData.form.title)}")`));
|
||||||
lines.push(
|
lines.push(
|
||||||
this.indent(
|
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('');
|
lines.push('');
|
||||||
|
|
||||||
this.indentLevel = 1;
|
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;
|
this.indentLevel = 2;
|
||||||
|
|
||||||
designData.widgets.forEach((widget) => {
|
designData.widgets.forEach((widget) => {
|
||||||
console.log('[Generator] Widget:', widget.id, widget.type);
|
|
||||||
lines.push(...this.generateWidgetCode(widget, nameMap));
|
lines.push(...this.generateWidgetCode(widget, nameMap));
|
||||||
lines.push('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.indentLevel = 1;
|
this.indentLevel = 1;
|
||||||
lines.push(this.indent('def run(self):'));
|
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.RUN}(self):`));
|
||||||
this.indentLevel = 2;
|
this.indentLevel = 2;
|
||||||
lines.push(this.indent('self.root.mainloop()'));
|
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.mainloop()`));
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
const hasEvents = designData.events && designData.events.length > 0;
|
const hasEvents = designData.events && designData.events.length > 0;
|
||||||
|
|
||||||
if (hasEvents) {
|
if (hasEvents) {
|
||||||
console.log('[Generator] Generating event handlers');
|
|
||||||
lines.push(
|
lines.push(
|
||||||
...generateEventHandlers(
|
...generateEventHandlers(
|
||||||
designData,
|
designData,
|
||||||
@ -72,12 +72,58 @@ export class CodeGenerator {
|
|||||||
this.indentLevel = 0;
|
this.indentLevel = 0;
|
||||||
lines.push('if __name__ == "__main__":');
|
lines.push('if __name__ == "__main__":');
|
||||||
this.indentLevel = 1;
|
this.indentLevel = 1;
|
||||||
lines.push(this.indent('app = Application()'));
|
lines.push(this.indent('try:'));
|
||||||
lines.push(this.indent('app.run()'));
|
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');
|
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(
|
private generateWidgetCode(
|
||||||
widget: WidgetData,
|
widget: WidgetData,
|
||||||
nameMap: Map<string, string>
|
nameMap: Map<string, string>
|
||||||
@ -96,7 +142,9 @@ export class CodeGenerator {
|
|||||||
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
|
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
|
||||||
|
|
||||||
const contentLines = generateWidgetContent(widget, varName);
|
const contentLines = generateWidgetContent(widget, varName);
|
||||||
contentLines.forEach((line) => lines.push(this.indent(line)));
|
if (contentLines.length > 0) {
|
||||||
|
contentLines.forEach((l) => lines.push(this.indent(l)));
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
...getWidgetEventBindings(
|
...getWidgetEventBindings(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { DesignData, Event, WidgetData } from './types';
|
import { DesignData, DesignEvent, WidgetData } from './types';
|
||||||
|
|
||||||
export function generateEventHandlers(
|
export function generateEventHandlers(
|
||||||
designData: DesignData,
|
designData: DesignData,
|
||||||
@ -8,22 +8,39 @@ export function generateEventHandlers(
|
|||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
if (designData.events && designData.events.length > 0) {
|
if (designData.events && designData.events.length > 0) {
|
||||||
designData.events.forEach((event: Event) => {
|
const handledNames = new Set<string>();
|
||||||
|
|
||||||
|
designData.events.forEach((event: DesignEvent) => {
|
||||||
const handlerName = event.name;
|
const handlerName = event.name;
|
||||||
|
|
||||||
|
if (handledNames.has(handlerName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handledNames.add(handlerName);
|
||||||
|
|
||||||
|
const signature = event.signature;
|
||||||
|
|
||||||
|
if (signature) {
|
||||||
|
lines.push(indentDef(`${signature}:`));
|
||||||
|
} else {
|
||||||
const isBindEvent =
|
const isBindEvent =
|
||||||
event.type.startsWith('<') && event.type.endsWith('>');
|
event.type.startsWith('<') && event.type.endsWith('>');
|
||||||
|
|
||||||
let hasCode = false;
|
|
||||||
const widget = (designData.widgets || []).find(
|
|
||||||
(w) => w.id === event.widget
|
|
||||||
);
|
|
||||||
if (isBindEvent) {
|
if (isBindEvent) {
|
||||||
lines.push(indentDef(`def ${handlerName}(self, event):`));
|
lines.push(
|
||||||
|
indentDef(`def ${handlerName}(self, event=None):`)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
lines.push(indentDef(`def ${handlerName}(self):`));
|
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) {
|
for (const line of codeLines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
lines.push(indentBody(line));
|
lines.push(indentBody(line));
|
||||||
|
|||||||
@ -1,8 +1,21 @@
|
|||||||
export interface Event {
|
export interface DesignEvent {
|
||||||
widget: string;
|
widget: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: 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 {
|
export interface WidgetData {
|
||||||
@ -12,17 +25,19 @@ export interface WidgetData {
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
properties: { [key: string]: any };
|
properties: WidgetProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesignData {
|
export interface DesignData {
|
||||||
form: {
|
form: {
|
||||||
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
size: {
|
size: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
widgets: WidgetData[];
|
widgets: WidgetData[];
|
||||||
events?: Event[];
|
events?: DesignEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,13 +25,12 @@ export function generateVariableNames(
|
|||||||
|
|
||||||
widgets.forEach((widget) => {
|
widgets.forEach((widget) => {
|
||||||
let baseName = widget.type.toLowerCase();
|
let baseName = widget.type.toLowerCase();
|
||||||
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
|
|
||||||
if (baseName === 'button') baseName = 'btn';
|
if (baseName === 'button') baseName = 'btn';
|
||||||
|
if (baseName === 'entry') baseName = 'entry';
|
||||||
if (baseName === 'checkbutton') baseName = 'chk';
|
if (baseName === 'checkbutton') baseName = 'chk';
|
||||||
if (baseName === 'radiobutton') baseName = 'radio';
|
if (baseName === 'radiobutton') baseName = 'radio';
|
||||||
if (baseName === 'label') baseName = 'lbl';
|
if (baseName === 'label') baseName = 'lbl';
|
||||||
|
|
||||||
|
|
||||||
const count = (counts.get(baseName) || 0) + 1;
|
const count = (counts.get(baseName) || 0) + 1;
|
||||||
counts.set(baseName, count);
|
counts.set(baseName, count);
|
||||||
names.set(widget.id, `${baseName}${count}`);
|
names.set(widget.id, `${baseName}${count}`);
|
||||||
|
|||||||
@ -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 { runPythonAst } from './pythonRunner';
|
||||||
import { convertASTResultToDesignData } from './astConverter';
|
import { convertASTResultToDesignData } from './astConverter';
|
||||||
|
import { ASTResult } from './astTypes';
|
||||||
|
|
||||||
export class CodeParser {
|
export class CodeParser {
|
||||||
public async parseCodeToDesign(
|
public async parseCodeToDesign(
|
||||||
pythonCode: string
|
pythonCode: string,
|
||||||
|
filename?: string
|
||||||
): Promise<DesignData | null> {
|
): Promise<DesignData | null> {
|
||||||
console.log(
|
|
||||||
'[Parser] parseCodeToDesign start, code length:',
|
|
||||||
pythonCode.length
|
|
||||||
);
|
|
||||||
const astRaw = await runPythonAst(pythonCode);
|
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 {
|
if (astRaw && astRaw.error) {
|
||||||
const widgetRegex =
|
vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`);
|
||||||
/(self\.)?(\w+)\s*=\s*tk\.(Label|Button|Text|Checkbutton|Radiobutton)\s*\(([^)]*)\)/g;
|
|
||||||
const placeRegex = /(self\.)?(\w+)\.place\s*\(([^)]*)\)/g;
|
|
||||||
const titleRegex = /\.title\s*\(\s*(["'])(.*?)\1\s*\)/;
|
|
||||||
const geometryRegex = /\.geometry\s*\(\s*(["'])(\d+)x(\d+)\1\s*\)/;
|
|
||||||
|
|
||||||
const widgets: WidgetData[] = [];
|
|
||||||
const widgetMap = new Map<string, WidgetData>();
|
|
||||||
|
|
||||||
let formTitle = 'App';
|
|
||||||
let formWidth = 800;
|
|
||||||
let formHeight = 600;
|
|
||||||
|
|
||||||
const tMatch = code.match(titleRegex);
|
|
||||||
if (tMatch) formTitle = tMatch[2];
|
|
||||||
const gMatch = code.match(geometryRegex);
|
|
||||||
if (gMatch) {
|
|
||||||
formWidth = parseInt(gMatch[2], 10) || 800;
|
|
||||||
formHeight = parseInt(gMatch[3], 10) || 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = widgetRegex.exec(code)) !== null) {
|
|
||||||
const varName = m[2];
|
|
||||||
const type = m[3];
|
|
||||||
const paramStr = m[4] || '';
|
|
||||||
const id = varName;
|
|
||||||
const w: WidgetData = {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 25,
|
|
||||||
properties: {},
|
|
||||||
};
|
|
||||||
const textMatch = paramStr.match(/text\s*=\s*(["'])(.*?)\1/);
|
|
||||||
if (textMatch) {
|
|
||||||
w.properties.text = textMatch[2];
|
|
||||||
}
|
|
||||||
const widthMatch = paramStr.match(/width\s*=\s*(\d+)/);
|
|
||||||
if (widthMatch) {
|
|
||||||
const wv = parseInt(widthMatch[1], 10);
|
|
||||||
if (!isNaN(wv)) w.width = wv;
|
|
||||||
}
|
|
||||||
const heightMatch = paramStr.match(/height\s*=\s*(\d+)/);
|
|
||||||
if (heightMatch) {
|
|
||||||
const hv = parseInt(heightMatch[1], 10);
|
|
||||||
if (!isNaN(hv)) w.height = hv;
|
|
||||||
}
|
|
||||||
widgets.push(w);
|
|
||||||
widgetMap.set(varName, w);
|
|
||||||
}
|
|
||||||
|
|
||||||
let p: RegExpExecArray | null;
|
|
||||||
while ((p = placeRegex.exec(code)) !== null) {
|
|
||||||
const varName = p[2];
|
|
||||||
const params = p[3];
|
|
||||||
const w = widgetMap.get(varName);
|
|
||||||
if (!w) continue;
|
|
||||||
const getNum = (key: string) => {
|
|
||||||
const r = new RegExp(key + '\\s*[:=]\\s*(\d+)');
|
|
||||||
const mm = params.match(r);
|
|
||||||
return mm ? parseInt(mm[1], 10) : undefined;
|
|
||||||
};
|
|
||||||
const x = getNum('x');
|
|
||||||
const y = getNum('y');
|
|
||||||
const width = getNum('width');
|
|
||||||
const height = getNum('height');
|
|
||||||
if (typeof x === 'number') w.x = x;
|
|
||||||
if (typeof y === 'number') w.y = y;
|
|
||||||
if (typeof width === 'number') w.width = width;
|
|
||||||
if (typeof height === 'number') w.height = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
const events: Event[] = [];
|
|
||||||
const configRegex =
|
|
||||||
/(self\.)?(\w+)\.config\s*\(\s*command\s*=\s*(self\.)?(\w+)\s*\)/g;
|
|
||||||
let c: RegExpExecArray | null;
|
|
||||||
while ((c = configRegex.exec(code)) !== null) {
|
|
||||||
const varName = c[2];
|
|
||||||
const handler = c[4];
|
|
||||||
if (widgetMap.has(varName)) {
|
|
||||||
events.push({
|
|
||||||
widget: varName,
|
|
||||||
type: 'command',
|
|
||||||
name: handler,
|
|
||||||
code: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (widgets.length === 0) {
|
|
||||||
console.log('[Parser] Regex found 0 widgets');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
form: {
|
const astDesign = convertASTResultToDesignData(astRaw as ASTResult);
|
||||||
title: formTitle,
|
|
||||||
size: { width: formWidth, height: formHeight },
|
if (astDesign) {
|
||||||
},
|
if (filename) {
|
||||||
widgets,
|
astDesign.form.name = filename;
|
||||||
events,
|
}
|
||||||
};
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { getDefaultWidth, getDefaultHeight } from './utils';
|
||||||
|
import { WidgetType, DEFAULT_FORM_CONFIG, EVENT_TYPES } from '../constants';
|
||||||
|
|
||||||
export function convertASTResultToDesignData(
|
export function convertASTResultToDesignData(astResult: ASTResult): DesignData | null {
|
||||||
astResult: any
|
if (!astResult) {
|
||||||
): DesignData | null {
|
return null;
|
||||||
if (!astResult || !astResult.widgets || astResult.widgets.length === 0) {
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!astResult.window && !astResult.widgets) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -12,48 +17,44 @@ export function convertASTResultToDesignData(
|
|||||||
let formTitle =
|
let formTitle =
|
||||||
(astResult.window && astResult.window.title) ||
|
(astResult.window && astResult.window.title) ||
|
||||||
astResult.title ||
|
astResult.title ||
|
||||||
'Parsed App';
|
DEFAULT_FORM_CONFIG.TITLE;
|
||||||
let formWidth =
|
let formWidth =
|
||||||
(astResult.window && astResult.window.width) || astResult.width || 800;
|
(astResult.window && astResult.window.width) || astResult.width || DEFAULT_FORM_CONFIG.WIDTH;
|
||||||
let formHeight =
|
let formHeight =
|
||||||
(astResult.window && astResult.window.height) ||
|
(astResult.window && astResult.window.height) ||
|
||||||
astResult.height ||
|
astResult.height ||
|
||||||
600;
|
DEFAULT_FORM_CONFIG.HEIGHT;
|
||||||
|
let className =
|
||||||
|
(astResult.window && astResult.window.className) || DEFAULT_FORM_CONFIG.CLASS_NAME;
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
const allowedTypes = new Set([
|
const allowedTypes = new Set([
|
||||||
'Label',
|
WidgetType.Label,
|
||||||
'Button',
|
WidgetType.Button,
|
||||||
'Text',
|
WidgetType.Entry,
|
||||||
'Checkbutton',
|
WidgetType.Text,
|
||||||
'Radiobutton',
|
WidgetType.Checkbutton,
|
||||||
|
WidgetType.Radiobutton,
|
||||||
]);
|
]);
|
||||||
for (const w of astResult.widgets) {
|
|
||||||
|
|
||||||
|
const inputWidgets = astResult.widgets || [];
|
||||||
|
|
||||||
|
for (const w of inputWidgets) {
|
||||||
counter++;
|
counter++;
|
||||||
const type = w.type || 'Widget';
|
const type = w.type || WidgetType.Widget;
|
||||||
if (!allowedTypes.has(type)) {
|
if (!allowedTypes.has(type as WidgetType)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const place = w.placement || {};
|
const place = w.placement || {};
|
||||||
const x = place.x !== undefined ? place.x : w.x !== undefined ? w.x : 0;
|
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 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
|
const p: Record<string, any> = w.properties || w.params || {};
|
||||||
? place.width
|
|
||||||
: w.width !== undefined
|
const width = [place.width, w.width, p.width].find(v => v !== undefined) ?? getDefaultWidth(type);
|
||||||
? w.width
|
const height = [place.height, w.height, p.height].find(v => v !== undefined) ?? getDefaultHeight(type);
|
||||||
: 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 id = w.variable_name || `ast_widget_${counter}`;
|
const id = w.variable_name || `ast_widget_${counter}`;
|
||||||
const widget: WidgetData = {
|
const widget: WidgetData = {
|
||||||
id,
|
id,
|
||||||
@ -67,33 +68,58 @@ export function convertASTResultToDesignData(
|
|||||||
widgets.push(widget);
|
widgets.push(widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
const events: Event[] = [];
|
const events: DesignEvent[] = [];
|
||||||
|
const methods = astResult.methods || {};
|
||||||
|
|
||||||
if (astResult.command_callbacks) {
|
if (astResult.command_callbacks) {
|
||||||
for (const callback of astResult.command_callbacks) {
|
for (const callback of astResult.command_callbacks) {
|
||||||
const rawName = callback.command?.name;
|
addDesignEvent(
|
||||||
const cleanName = rawName
|
events,
|
||||||
? String(rawName).replace(/^self\./, '')
|
methods,
|
||||||
: `on_${callback.widget}_command`;
|
callback.widget,
|
||||||
events.push({
|
EVENT_TYPES.COMMAND,
|
||||||
widget: callback.widget,
|
callback.command?.name,
|
||||||
type: 'command',
|
() => `on_${callback.widget}_command`,
|
||||||
name: cleanName,
|
callback.command?.lambda_body
|
||||||
code: callback.command?.lambda_body || '',
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (astResult.bind_events) {
|
if (astResult.bind_events) {
|
||||||
for (const bindEvent of astResult.bind_events) {
|
for (const bindEvent of astResult.bind_events) {
|
||||||
const rawName = bindEvent.callback?.name;
|
addDesignEvent(
|
||||||
const cleanName = rawName
|
events,
|
||||||
? String(rawName).replace(/^self\./, '')
|
methods,
|
||||||
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
|
bindEvent.widget,
|
||||||
events.push({
|
bindEvent.event,
|
||||||
widget: bindEvent.widget,
|
bindEvent.callback?.name,
|
||||||
type: bindEvent.event,
|
() => `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`,
|
||||||
name: cleanName,
|
bindEvent.callback?.lambda_body
|
||||||
code: 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 = {
|
const result: DesignData = {
|
||||||
form: {
|
form: {
|
||||||
|
name: DEFAULT_FORM_CONFIG.FORM_NAME,
|
||||||
title: formTitle,
|
title: formTitle,
|
||||||
size: { width: formWidth, height: formHeight },
|
size: { width: formWidth, height: formHeight },
|
||||||
|
className: className,
|
||||||
},
|
},
|
||||||
widgets,
|
widgets,
|
||||||
events: filteredEvents.length ? filteredEvents : [],
|
events: filteredEvents.length ? filteredEvents : [],
|
||||||
@ -111,8 +139,8 @@ export function convertASTResultToDesignData(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractWidgetPropertiesFromAST(w: any): any {
|
function extractWidgetPropertiesFromAST(w: ASTWidget): WidgetProperties {
|
||||||
const props: any = {};
|
const props: WidgetProperties = {};
|
||||||
if (!w) return props;
|
if (!w) return props;
|
||||||
const p = w.properties || w.params || {};
|
const p = w.properties || w.params || {};
|
||||||
if (p.text) props.text = p.text;
|
if (p.text) props.text = p.text;
|
||||||
@ -125,3 +153,43 @@ function extractWidgetPropertiesFromAST(w: any): any {
|
|||||||
if (p.height !== undefined) props.height = p.height;
|
if (p.height !== undefined) props.height = p.height;
|
||||||
return props;
|
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
60
src/parser/astTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,79 +2,178 @@ import * as path from 'path';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { ASTResult } from './astTypes';
|
||||||
|
|
||||||
async function executePythonScript(
|
class PythonExecutor {
|
||||||
pythonScriptPath: string,
|
private static instance: PythonExecutor;
|
||||||
pythonFilePath: string
|
private cachedPythonPath: string | null = null;
|
||||||
): Promise<string> {
|
|
||||||
return await new Promise((resolve, reject) => {
|
private constructor() {}
|
||||||
const pythonCommand = getPythonCommand();
|
|
||||||
const start = Date.now();
|
public static getInstance(): PythonExecutor {
|
||||||
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath);
|
if (!PythonExecutor.instance) {
|
||||||
const process = spawn(pythonCommand, [
|
PythonExecutor.instance = new PythonExecutor();
|
||||||
pythonScriptPath,
|
}
|
||||||
pythonFilePath,
|
return PythonExecutor.instance;
|
||||||
]);
|
}
|
||||||
let result = '';
|
|
||||||
let errorOutput = '';
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPythonCommands(): Promise<string[]> {
|
||||||
|
const commands: string[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
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) => {
|
process.stdout.on('data', (data) => {
|
||||||
result += data.toString();
|
stdout += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.stderr.on('data', (data) => {
|
process.stderr.on('data', (data) => {
|
||||||
errorOutput += data.toString();
|
stderr += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('close', (code) => {
|
process.on('close', (code) => {
|
||||||
const ms = Date.now() - start;
|
if (code !== 0) {
|
||||||
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms);
|
reject(new Error(stderr || `Process exited with code ${code}`));
|
||||||
if (code === 0) {
|
|
||||||
resolve(result);
|
|
||||||
} else {
|
} else {
|
||||||
reject(
|
resolve(stdout);
|
||||||
new Error(
|
|
||||||
`Python script failed with code ${code}: ${errorOutput}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
export async function executePythonScript(
|
||||||
|
pythonScriptPath: string,
|
||||||
|
pythonCode: string
|
||||||
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(tempFile);
|
const executor = PythonExecutor.getInstance();
|
||||||
} catch {}
|
return await executor.executeScript(pythonScriptPath, [], pythonCode);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPythonAst(pythonCode: string): Promise<any | null> {
|
export async function runPythonAst(code: string): Promise<ASTResult | { error: string }> {
|
||||||
const tempFilePath = createTempPythonFile(pythonCode);
|
|
||||||
try {
|
try {
|
||||||
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
|
const executor = PythonExecutor.getInstance();
|
||||||
const output = await executePythonScript(
|
const scriptPath = path.join(
|
||||||
pythonScriptPath,
|
__dirname,
|
||||||
tempFilePath
|
'..',
|
||||||
|
'..',
|
||||||
|
'src',
|
||||||
|
'parser',
|
||||||
|
'tkinter_ast_parser.py'
|
||||||
);
|
);
|
||||||
console.log('[PythonRunner] Received AST JSON length:', output.length);
|
|
||||||
const parsed = JSON.parse(output);
|
const output = await executor.executeScript(scriptPath, [], code);
|
||||||
return parsed;
|
try {
|
||||||
} catch (err) {
|
return JSON.parse(output) as ASTResult;
|
||||||
console.error('[PythonRunner] Error running Python AST:', err);
|
} catch (e) {
|
||||||
return null;
|
return { error: 'Failed to parse JSON output: ' + e };
|
||||||
} finally {
|
}
|
||||||
cleanupTempFile(tempFilePath);
|
} catch (error: any) {
|
||||||
console.log('[PythonRunner] Temp file cleaned:', tempFilePath);
|
return { error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/parser/tk_ast/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/__pycache__/grid_layout.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/__pycache__/grid_layout.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/__pycache__/parser.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/__pycache__/parser.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/base.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/calls.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/calls.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/context.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/context.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/events.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/events.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/imports.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/imports.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/values.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/values.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,4 +1,7 @@
|
|||||||
import ast
|
import ast
|
||||||
|
import textwrap
|
||||||
|
import tokenize
|
||||||
|
import io
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
from .imports import handle_import, handle_import_from
|
from .imports import handle_import, handle_import_from
|
||||||
@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
|
|||||||
|
|
||||||
class TkinterAnalyzer(ast.NodeVisitor):
|
class TkinterAnalyzer(ast.NodeVisitor):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, source_code: str = ""):
|
||||||
|
self.source_code = source_code
|
||||||
|
self.line_offsets = [0]
|
||||||
|
current = 0
|
||||||
|
if source_code:
|
||||||
|
for line in source_code.splitlines(keepends=True):
|
||||||
|
current += len(line)
|
||||||
|
self.line_offsets.append(current)
|
||||||
|
|
||||||
self.widgets: List[Dict[str, Any]] = []
|
self.widgets: List[Dict[str, Any]] = []
|
||||||
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
|
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
|
||||||
self.imports: Dict[str, str] = {}
|
self.imports: Dict[str, str] = {}
|
||||||
@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
|||||||
self.event_handlers: List[Dict[str, Any]] = []
|
self.event_handlers: List[Dict[str, Any]] = []
|
||||||
self.command_callbacks: List[Dict[str, Any]] = []
|
self.command_callbacks: List[Dict[str, Any]] = []
|
||||||
self.bind_events: List[Dict[str, Any]] = []
|
self.bind_events: List[Dict[str, Any]] = []
|
||||||
|
self.methods: Dict[str, str] = {}
|
||||||
|
|
||||||
def visit_Import(self, node: ast.Import):
|
def visit_Import(self, node: ast.Import):
|
||||||
handle_import(self, node)
|
handle_import(self, node)
|
||||||
@ -36,12 +48,119 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
|||||||
def visit_ClassDef(self, node: ast.ClassDef):
|
def visit_ClassDef(self, node: ast.ClassDef):
|
||||||
prev = self.current_class
|
prev = self.current_class
|
||||||
enter_class(self, node)
|
enter_class(self, node)
|
||||||
|
|
||||||
|
is_tk_class = False
|
||||||
|
for base in node.bases:
|
||||||
|
if isinstance(base, ast.Attribute) and base.attr == 'Tk':
|
||||||
|
is_tk_class = True
|
||||||
|
elif isinstance(base, ast.Name) and base.id == 'Tk':
|
||||||
|
is_tk_class = True
|
||||||
|
|
||||||
|
if node.name == 'Application':
|
||||||
|
self.window_config['className'] = node.name
|
||||||
|
elif is_tk_class and self.window_config.get('className') != 'Application':
|
||||||
|
self.window_config['className'] = node.name
|
||||||
|
elif not self.window_config.get('className'):
|
||||||
|
self.window_config['className'] = node.name
|
||||||
|
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
exit_class(self, prev)
|
exit_class(self, prev)
|
||||||
|
|
||||||
def visit_FunctionDef(self, node: ast.FunctionDef):
|
def visit_FunctionDef(self, node: ast.FunctionDef):
|
||||||
prev = self.current_method
|
prev = self.current_method
|
||||||
enter_function(self, node)
|
enter_function(self, node)
|
||||||
|
|
||||||
|
if self.current_class and node.name not in ['__init__', 'create_widgets', 'run'] and self.source_code and node.body:
|
||||||
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
self.generic_visit(node)
|
||||||
exit_function(self, prev)
|
exit_function(self, prev)
|
||||||
|
|
||||||
@ -101,9 +220,3 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
|||||||
|
|
||||||
def analyze_widget_creation_commands(self, node: ast.Assign):
|
def analyze_widget_creation_commands(self, node: ast.Assign):
|
||||||
return analyze_widget_creation_commands(self, node)
|
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)
|
|
||||||
@ -15,15 +15,11 @@ def handle_method_call(analyzer, node: ast.Call):
|
|||||||
arg0 = node.args[0]
|
arg0 = node.args[0]
|
||||||
if isinstance(arg0, ast.Constant):
|
if isinstance(arg0, ast.Constant):
|
||||||
analyzer.window_config['title'] = arg0.value
|
analyzer.window_config['title'] = arg0.value
|
||||||
elif isinstance(arg0, ast.Str):
|
|
||||||
analyzer.window_config['title'] = arg0.s
|
|
||||||
|
|
||||||
elif method_name == 'geometry' and node.args:
|
elif method_name == 'geometry' and node.args:
|
||||||
arg0 = node.args[0]
|
arg0 = node.args[0]
|
||||||
if isinstance(arg0, ast.Constant):
|
if isinstance(arg0, ast.Constant):
|
||||||
geometry = arg0.value
|
geometry = arg0.value
|
||||||
elif isinstance(arg0, ast.Str):
|
|
||||||
geometry = arg0.s
|
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
if 'x' in str(geometry):
|
if 'x' in str(geometry):
|
||||||
|
|||||||
@ -15,10 +15,6 @@ def get_variable_name(node: ast.AST) -> str:
|
|||||||
def extract_value(node: ast.AST) -> Any:
|
def extract_value(node: ast.AST) -> Any:
|
||||||
if isinstance(node, ast.Constant):
|
if isinstance(node, ast.Constant):
|
||||||
return node.value
|
return node.value
|
||||||
elif isinstance(node, ast.Str):
|
|
||||||
return node.s
|
|
||||||
elif isinstance(node, ast.Num):
|
|
||||||
return node.n
|
|
||||||
elif isinstance(node, ast.Name):
|
elif isinstance(node, ast.Name):
|
||||||
return f"${node.id}"
|
return f"${node.id}"
|
||||||
elif isinstance(node, ast.Attribute):
|
elif isinstance(node, ast.Attribute):
|
||||||
@ -65,7 +61,7 @@ def get_operator_symbol(op_node: ast.AST) -> str:
|
|||||||
|
|
||||||
def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
||||||
body = lambda_node.body
|
body = lambda_node.body
|
||||||
if isinstance(body, (ast.Constant, ast.Str, ast.Num)):
|
if isinstance(body, ast.Constant):
|
||||||
return 'simple'
|
return 'simple'
|
||||||
elif isinstance(body, (ast.Name, ast.Attribute)):
|
elif isinstance(body, (ast.Name, ast.Attribute)):
|
||||||
return 'simple'
|
return 'simple'
|
||||||
@ -76,11 +72,9 @@ def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
|||||||
|
|
||||||
def extract_lambda_body(body_node: ast.AST) -> str:
|
def extract_lambda_body(body_node: ast.AST) -> str:
|
||||||
if isinstance(body_node, ast.Constant):
|
if isinstance(body_node, ast.Constant):
|
||||||
|
if isinstance(body_node.value, str):
|
||||||
|
return f'"{body_node.value}"'
|
||||||
return str(body_node.value)
|
return str(body_node.value)
|
||||||
elif isinstance(body_node, ast.Str):
|
|
||||||
return f'"{body_node.s}"'
|
|
||||||
elif isinstance(body_node, ast.Num):
|
|
||||||
return str(body_node.n)
|
|
||||||
elif isinstance(body_node, ast.Name):
|
elif isinstance(body_node, ast.Name):
|
||||||
return body_node.id
|
return body_node.id
|
||||||
elif isinstance(body_node, ast.Attribute):
|
elif isinstance(body_node, ast.Attribute):
|
||||||
@ -138,8 +132,6 @@ def extract_lambda_body(body_node: ast.AST) -> str:
|
|||||||
for value in body_node.values:
|
for value in body_node.values:
|
||||||
if isinstance(value, ast.Constant):
|
if isinstance(value, ast.Constant):
|
||||||
parts.append(str(value.value))
|
parts.append(str(value.value))
|
||||||
elif isinstance(value, ast.Str):
|
|
||||||
parts.append(value.s)
|
|
||||||
else:
|
else:
|
||||||
parts.append(f"{{{extract_lambda_body(value)}}}")
|
parts.append(f"{{{extract_lambda_body(value)}}}")
|
||||||
return f"f\"{''.join(parts)}\""
|
return f"f\"{''.join(parts)}\""
|
||||||
|
|||||||
@ -19,10 +19,13 @@ def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool:
|
|||||||
widget_type = call_node.func.attr
|
widget_type = call_node.func.attr
|
||||||
|
|
||||||
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
|
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
|
||||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
|
|
||||||
return False
|
resolved = analyzer.imports.get(module_name, module_name)
|
||||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
if resolved in ['ttk', 'tkinter.ttk']:
|
||||||
|
return widget_type in ['Label', 'Button', 'Entry', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
|
|
||||||
|
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
|
|
||||||
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
||||||
if not isinstance(node.value, ast.Call):
|
if not isinstance(node.value, ast.Call):
|
||||||
@ -31,7 +34,7 @@ def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
|||||||
if isinstance(node.value.func, ast.Attribute):
|
if isinstance(node.value.func, ast.Attribute):
|
||||||
return is_tkinter_widget_call(analyzer, node.value)
|
return is_tkinter_widget_call(analyzer, node.value)
|
||||||
elif isinstance(node.value.func, ast.Name):
|
elif isinstance(node.value.func, ast.Name):
|
||||||
return node.value.func.id in ['Label', 'Button', 'Text', 'Checkbutton']
|
return node.value.func.id in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
||||||
@ -49,10 +52,6 @@ def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
|||||||
|
|
||||||
if isinstance(call_node.func, ast.Attribute):
|
if isinstance(call_node.func, ast.Attribute):
|
||||||
widget_type = call_node.func.attr
|
widget_type = call_node.func.attr
|
||||||
if isinstance(call_node.func.value, ast.Name):
|
|
||||||
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):
|
elif isinstance(call_node.func, ast.Name):
|
||||||
widget_type = call_node.func.id
|
widget_type = call_node.func.id
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from tk_ast.grid_layout import GridLayoutAnalyzer
|
|||||||
def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
tree = ast.parse(code)
|
tree = ast.parse(code)
|
||||||
analyzer = TkinterAnalyzer()
|
analyzer = TkinterAnalyzer(code)
|
||||||
analyzer.visit(tree)
|
analyzer.visit(tree)
|
||||||
grid_analyzer = GridLayoutAnalyzer()
|
grid_analyzer = GridLayoutAnalyzer()
|
||||||
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
|
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
|
||||||
@ -18,6 +18,7 @@ def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
|||||||
'widgets': widgets,
|
'widgets': widgets,
|
||||||
'command_callbacks': analyzer.command_callbacks,
|
'command_callbacks': analyzer.command_callbacks,
|
||||||
'bind_events': analyzer.bind_events,
|
'bind_events': analyzer.bind_events,
|
||||||
|
'methods': analyzer.methods,
|
||||||
'success': True
|
'success': True
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tk_ast.parser import parse_tkinter_code, parse_file
|
from tk_ast.parser import parse_tkinter_code, parse_file
|
||||||
except Exception as e:
|
except ImportError as e:
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Failed to import tk_ast package: {e}. Ensure 'tk_ast' exists next to this script."
|
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 __name__ == '__main__':
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
print(parse_file(sys.argv[1]))
|
sys.stdout.write(str(parse_file(sys.argv[1])) + '\n')
|
||||||
else:
|
else:
|
||||||
code = sys.stdin.read()
|
code = sys.stdin.read()
|
||||||
result = parse_tkinter_code(code)
|
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')
|
||||||
@ -1,21 +1,9 @@
|
|||||||
|
import { WidgetType, WIDGET_DIMENSIONS } from '../constants';
|
||||||
|
|
||||||
export function getDefaultWidth(type: string): number {
|
export function getDefaultWidth(type: string): number {
|
||||||
const defaults: { [key: string]: number } = {
|
return WIDGET_DIMENSIONS[type as WidgetType]?.width || WIDGET_DIMENSIONS.DEFAULT.width;
|
||||||
Label: 100,
|
|
||||||
Button: 80,
|
|
||||||
Text: 200,
|
|
||||||
Checkbutton: 100,
|
|
||||||
Radiobutton: 100,
|
|
||||||
};
|
|
||||||
return defaults[type] || 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultHeight(type: string): number {
|
export function getDefaultHeight(type: string): number {
|
||||||
const defaults: { [key: string]: number } = {
|
return WIDGET_DIMENSIONS[type as WidgetType]?.height || WIDGET_DIMENSIONS.DEFAULT.height;
|
||||||
Label: 25,
|
|
||||||
Button: 30,
|
|
||||||
Text: 100,
|
|
||||||
Checkbutton: 25,
|
|
||||||
Radiobutton: 25,
|
|
||||||
};
|
|
||||||
return defaults[type] || 25;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,26 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { Uri } from 'vscode';
|
import { runPythonAst } from '../parser/pythonRunner';
|
||||||
|
import { DesignData } from '../generator/types';
|
||||||
|
import { WebviewMessage } from './react/types';
|
||||||
|
import { ProjectIO } from './projectIO';
|
||||||
|
|
||||||
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||||
public static readonly viewType = 'tkinter-designer';
|
public static readonly viewType = 'tkinter-designer';
|
||||||
public static _instance: TkinterDesignerProvider | undefined;
|
private _view?: vscode.WebviewPanel | vscode.WebviewView;
|
||||||
private _view?: vscode.WebviewPanel;
|
private _designData: DesignData = {
|
||||||
private _designData: any = {
|
|
||||||
widgets: [],
|
widgets: [],
|
||||||
events: [],
|
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) {}
|
constructor(private readonly _extensionUri: vscode.Uri) {}
|
||||||
|
|
||||||
public static createOrShow(extensionUri: vscode.Uri) {
|
public static createNew(extensionUri: vscode.Uri): TkinterDesignerProvider {
|
||||||
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');
|
|
||||||
const panel = vscode.window.createWebviewPanel(
|
const panel = vscode.window.createWebviewPanel(
|
||||||
TkinterDesignerProvider.viewType,
|
TkinterDesignerProvider.viewType,
|
||||||
'Tkinter Designer',
|
'Tkinter Designer',
|
||||||
column || vscode.ViewColumn.One,
|
vscode.ViewColumn.Beside,
|
||||||
{
|
{
|
||||||
enableScripts: true,
|
enableScripts: true,
|
||||||
retainContextWhenHidden: 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;
|
const provider = new TkinterDesignerProvider(extensionUri);
|
||||||
TkinterDesignerProvider._instance._setWebviewContent(panel.webview);
|
provider._view = panel;
|
||||||
TkinterDesignerProvider._instance._setupMessageHandling(panel.webview);
|
provider._setWebviewContent(panel.webview);
|
||||||
|
provider._setupMessageHandling(panel.webview);
|
||||||
|
|
||||||
panel.onDidDispose(() => {
|
panel.onDidDispose(() => {
|
||||||
console.log('[Webview] Panel disposed');
|
provider._view = undefined;
|
||||||
if (TkinterDesignerProvider._instance) {
|
|
||||||
TkinterDesignerProvider._instance._view = undefined;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return TkinterDesignerProvider._instance;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveWebviewView(
|
public resolveWebviewView(
|
||||||
@ -65,7 +50,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
context: vscode.WebviewViewResolveContext,
|
context: vscode.WebviewViewResolveContext,
|
||||||
_token: vscode.CancellationToken
|
_token: vscode.CancellationToken
|
||||||
) {
|
) {
|
||||||
this._view = webviewView as any;
|
this._view = webviewView;
|
||||||
webviewView.webview.options = {
|
webviewView.webview.options = {
|
||||||
enableScripts: true,
|
enableScripts: true,
|
||||||
localResourceRoots: [
|
localResourceRoots: [
|
||||||
@ -85,8 +70,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _setupMessageHandling(webview: vscode.Webview) {
|
private _setupMessageHandling(webview: vscode.Webview) {
|
||||||
webview.onDidReceiveMessage((message) => {
|
webview.onDidReceiveMessage(async (message) => {
|
||||||
console.log('[Webview] Message:', message.type);
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'designUpdated':
|
case 'designUpdated':
|
||||||
this._designData = message.data;
|
this._designData = message.data;
|
||||||
@ -107,12 +91,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
type: 'loadDesign',
|
type: 'loadDesign',
|
||||||
data: this._designData,
|
data: this._designData,
|
||||||
});
|
});
|
||||||
console.log('[Webview] Sent loadDesign');
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'generateCode':
|
case 'generateCode':
|
||||||
console.log('[Webview] Generating code from webview');
|
await this.handleGenerateCode(message.data);
|
||||||
this.handleGenerateCode(message.data);
|
break;
|
||||||
|
case 'applyChanges':
|
||||||
|
await this.handleApplyChanges(message.data);
|
||||||
break;
|
break;
|
||||||
case 'showInfo':
|
case 'showInfo':
|
||||||
vscode.window.showInformationMessage(message.text);
|
vscode.window.showInformationMessage(message.text);
|
||||||
@ -120,6 +105,15 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
case 'showError':
|
case 'showError':
|
||||||
vscode.window.showErrorMessage(message.text);
|
vscode.window.showErrorMessage(message.text);
|
||||||
break;
|
break;
|
||||||
|
case 'exportProject':
|
||||||
|
await ProjectIO.exportProject(message.data);
|
||||||
|
break;
|
||||||
|
case 'importProject':
|
||||||
|
const data = await ProjectIO.importProject();
|
||||||
|
if (data) {
|
||||||
|
this.loadDesignData(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, undefined);
|
}, undefined);
|
||||||
}
|
}
|
||||||
@ -136,38 +130,17 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
type: 'loadDesign',
|
type: 'loadDesign',
|
||||||
data: this._designData,
|
data: this._designData,
|
||||||
});
|
});
|
||||||
console.log('[Webview] loadDesign posted');
|
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleGenerateCode(designData: any): Promise<void> {
|
private async handleGenerateCode(designData: any): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('[GenerateCode] Start');
|
|
||||||
const { CodeGenerator } = await import(
|
const { CodeGenerator } = await import(
|
||||||
'../generator/CodeGenerator'
|
'../generator/codeGenerator'
|
||||||
);
|
);
|
||||||
const generator = new CodeGenerator();
|
const generator = new CodeGenerator();
|
||||||
const pythonCode = generator.generateTkinterCode(designData);
|
const pythonCode = generator.generateTkinterCode(designData);
|
||||||
|
|
||||||
const activeEditor = vscode.window.activeTextEditor;
|
|
||||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
|
||||||
console.log('[GenerateCode] Writing into active editor');
|
|
||||||
const doc = activeEditor.document;
|
|
||||||
const start = new vscode.Position(0, 0);
|
|
||||||
const end = doc.lineCount
|
|
||||||
? doc.lineAt(doc.lineCount - 1).range.end
|
|
||||||
: start;
|
|
||||||
const fullRange = new vscode.Range(start, end);
|
|
||||||
await activeEditor.edit((editBuilder) => {
|
|
||||||
editBuilder.replace(fullRange, pythonCode);
|
|
||||||
});
|
|
||||||
await doc.save();
|
|
||||||
vscode.window.showInformationMessage(
|
|
||||||
'Python code generated into the active file'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log('[GenerateCode] Creating new file');
|
|
||||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||||
if (!workspaceFolder) {
|
if (!workspaceFolder) {
|
||||||
vscode.window.showErrorMessage(
|
vscode.window.showErrorMessage(
|
||||||
@ -175,12 +148,27 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fileName = `app_${Date.now()}.py`;
|
|
||||||
const filePath = path.join(
|
const formName = designData.form.name || 'Form1';
|
||||||
workspaceFolder.uri.fsPath,
|
let fileName = `${formName}.py`;
|
||||||
fileName
|
let fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||||
);
|
let counter = 1;
|
||||||
const fileUri = Uri.file(filePath);
|
|
||||||
|
const exists = async (u: vscode.Uri) => {
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(u);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (await exists(fileUri)) {
|
||||||
|
fileName = `${formName}_${counter}.py`;
|
||||||
|
fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const fileBytes = encoder.encode(pythonCode);
|
const fileBytes = encoder.encode(pythonCode);
|
||||||
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
||||||
@ -192,14 +180,213 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
|||||||
vscode.window.showInformationMessage(
|
vscode.window.showInformationMessage(
|
||||||
`Python file created: ${fileName}`
|
`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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log('[GenerateCode] Done');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GenerateCode] Error:', error);
|
|
||||||
vscode.window.showErrorMessage(`Error generating code: ${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.'
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
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(
|
||||||
|
`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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
vscode.window.showErrorMessage(`Error applying changes: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _getHtmlForWebview(webview: vscode.Webview): string {
|
private _getHtmlForWebview(webview: vscode.Webview): string {
|
||||||
const styleUri = webview.asWebviewUri(
|
const styleUri = webview.asWebviewUri(
|
||||||
vscode.Uri.joinPath(
|
vscode.Uri.joinPath(
|
||||||
|
|||||||
95
src/webview/projectIO.ts
Normal file
95
src/webview/projectIO.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,10 +5,12 @@ import { PropertiesPanel } from './components/PropertiesPanel';
|
|||||||
import { EventsPanel } from './components/EventsPanel';
|
import { EventsPanel } from './components/EventsPanel';
|
||||||
import { Canvas } from './components/Canvas';
|
import { Canvas } from './components/Canvas';
|
||||||
import { useMessaging } from './useMessaging';
|
import { useMessaging } from './useMessaging';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useMessaging();
|
useMessaging();
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
@ -23,5 +25,6 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,69 @@ import React, { useRef } from 'react';
|
|||||||
import { useAppDispatch, useAppState } from '../state';
|
import { useAppDispatch, useAppState } from '../state';
|
||||||
import type { WidgetType } from '../types';
|
import type { WidgetType } from '../types';
|
||||||
|
|
||||||
|
import { Widget } from '../types';
|
||||||
|
|
||||||
|
function renderWidgetContent(w: Widget) {
|
||||||
|
switch (w.type) {
|
||||||
|
case 'Label':
|
||||||
|
return (
|
||||||
|
<div 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() {
|
export function Canvas() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { design, selectedWidgetId } = useAppState();
|
const { design, selectedWidgetId } = useAppState();
|
||||||
@ -17,12 +80,10 @@ export function Canvas() {
|
|||||||
const rect = containerRef.current?.getBoundingClientRect();
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
const x = e.clientX - (rect?.left || 0);
|
const x = e.clientX - (rect?.left || 0);
|
||||||
const y = e.clientY - (rect?.top || 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 } });
|
if (type) dispatch({ type: 'addWidget', payload: { type, x, y } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = (id: string | null) => {
|
const onSelect = (id: string | null) => {
|
||||||
console.log('[Canvas] Select widget', id);
|
|
||||||
dispatch({ type: 'selectWidget', payload: { id } });
|
dispatch({ type: 'selectWidget', payload: { id } });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -34,19 +95,126 @@ export function Canvas() {
|
|||||||
if (!w) return;
|
if (!w) return;
|
||||||
const initX = w.x;
|
const initX = w.x;
|
||||||
const initY = w.y;
|
const initY = w.y;
|
||||||
console.log('[Canvas] Drag start', id, 'at', initX, initY);
|
|
||||||
const onMove = (ev: MouseEvent) => {
|
const onMove = (ev: MouseEvent) => {
|
||||||
const dx = ev.clientX - startX;
|
const dx = ev.clientX - startX;
|
||||||
const dy = ev.clientY - startY;
|
const dy = ev.clientY - startY;
|
||||||
|
const newX = initX + dx;
|
||||||
|
const newY = initY + dy;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'updateWidget',
|
type: 'updateWidget',
|
||||||
payload: { id, patch: { x: initX + dx, y: initY + dy } },
|
payload: { id, patch: { x: newX, y: newY } },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
window.removeEventListener('mouseup', onUp);
|
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('mousemove', onMove);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
@ -54,9 +222,24 @@ export function Canvas() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="canvas-container">
|
<div className="canvas-container">
|
||||||
|
<div className="window-frame">
|
||||||
|
<div className="window-title-bar">
|
||||||
|
<div className="window-title">
|
||||||
|
{design.form?.title || 'Tkinter App'}
|
||||||
|
</div>
|
||||||
|
<div className="window-controls">
|
||||||
|
<div className="window-control minimize"></div>
|
||||||
|
<div className="window-control maximize"></div>
|
||||||
|
<div className="window-control close"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
id="designCanvas"
|
id="designCanvas"
|
||||||
className="design-canvas"
|
className="design-canvas"
|
||||||
|
style={{
|
||||||
|
'--canvas-width': `${design.form.size.width}px`,
|
||||||
|
'--canvas-height': `${design.form.size.height}px`,
|
||||||
|
} as React.CSSProperties}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
@ -72,12 +255,11 @@ export function Canvas() {
|
|||||||
key={w.id}
|
key={w.id}
|
||||||
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
|
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
'--widget-x': `${w.x}px`,
|
||||||
left: w.x,
|
'--widget-y': `${w.y}px`,
|
||||||
top: w.y,
|
'--widget-width': `${w.width}px`,
|
||||||
width: w.width,
|
'--widget-height': `${w.height}px`,
|
||||||
height: w.height,
|
} as React.CSSProperties}
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(w.id);
|
onSelect(w.id);
|
||||||
@ -85,11 +267,79 @@ export function Canvas() {
|
|||||||
onMouseDown={(e) => onMouseDown(e, w.id)}
|
onMouseDown={(e) => onMouseDown(e, w.id)}
|
||||||
>
|
>
|
||||||
<div className="widget-content">
|
<div className="widget-content">
|
||||||
{w.properties?.text || w.type}
|
{renderWidgetContent(w)}
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/webview/react/components/ErrorBoundary.tsx
Normal file
45
src/webview/react/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,7 @@ export function EventsPanel() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { design, selectedWidgetId, vscode } = useAppState();
|
const { design, selectedWidgetId, vscode } = useAppState();
|
||||||
const [eventType, setEventType] = useState('command');
|
const [eventType, setEventType] = useState('command');
|
||||||
const [eventName, setEventName] = useState('onClick');
|
const [eventName, setEventName] = useState('on_click');
|
||||||
const [eventCode, setEventCode] = useState('print("clicked")');
|
|
||||||
|
|
||||||
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||||
const widgetEvents = (id: string | undefined) =>
|
const widgetEvents = (id: string | undefined) =>
|
||||||
@ -20,10 +19,17 @@ export function EventsPanel() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!eventType || !eventName || !eventCode) {
|
if (!eventName) {
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showError',
|
type: 'showError',
|
||||||
text: 'Please fill in all fields: event type, name, and code',
|
text: 'Please fill in handler name',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!eventType) {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'showError',
|
||||||
|
text: 'Please fill in event type',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -33,21 +39,39 @@ export function EventsPanel() {
|
|||||||
widget: w.id,
|
widget: w.id,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
name: eventName,
|
name: eventName,
|
||||||
code: eventCode,
|
code: 'pass',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showInfo',
|
type: 'showInfo',
|
||||||
text: `Event added: ${eventType} -> ${eventName}`,
|
text: `Event added: ${eventName}`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = (type: string) => {
|
const remove = (type: string, name: string) => {
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
dispatch({ type: 'removeEvent', payload: { widget: w.id, type } });
|
dispatch({
|
||||||
|
type: 'removeEvent',
|
||||||
|
payload: { widget: w.id, type, name },
|
||||||
|
});
|
||||||
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
|
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commonEvents = [
|
||||||
|
'command',
|
||||||
|
'<Button-1>',
|
||||||
|
'<Button-2>',
|
||||||
|
'<Button-3>',
|
||||||
|
'<Double-Button-1>',
|
||||||
|
'<Enter>',
|
||||||
|
'<Leave>',
|
||||||
|
'<FocusIn>',
|
||||||
|
'<FocusOut>',
|
||||||
|
'<Return>',
|
||||||
|
'<Key>',
|
||||||
|
'<Configure>',
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="events-panel">
|
<div className="events-panel">
|
||||||
<h3>Events & Commands</h3>
|
<h3>Events & Commands</h3>
|
||||||
@ -60,12 +84,41 @@ export function EventsPanel() {
|
|||||||
<label className="property-label">
|
<label className="property-label">
|
||||||
Event Type:
|
Event Type:
|
||||||
</label>
|
</label>
|
||||||
|
<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
|
<input
|
||||||
className="event-type-select"
|
className="event-handler-input"
|
||||||
type="text"
|
type="text"
|
||||||
|
placeholder="Enter custom event type"
|
||||||
value={eventType}
|
value={eventType}
|
||||||
onChange={(e) => setEventType(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEventType(e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<label className="property-label">
|
<label className="property-label">
|
||||||
Handler Name:
|
Handler Name:
|
||||||
</label>
|
</label>
|
||||||
@ -75,14 +128,6 @@ export function EventsPanel() {
|
|||||||
value={eventName}
|
value={eventName}
|
||||||
onChange={(e) => setEventName(e.target.value)}
|
onChange={(e) => setEventName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<label className="property-label">
|
|
||||||
Handler Code:
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="event-handler-input"
|
|
||||||
value={eventCode}
|
|
||||||
onChange={(e) => setEventCode(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="event-buttons">
|
<div className="event-buttons">
|
||||||
<button className="event-btn primary" onClick={add}>
|
<button className="event-btn primary" onClick={add}>
|
||||||
@ -97,23 +142,26 @@ export function EventsPanel() {
|
|||||||
widgetEvents(w.id).map((ev) => (
|
widgetEvents(w.id).map((ev) => (
|
||||||
<div
|
<div
|
||||||
className="event-item"
|
className="event-item"
|
||||||
key={`${ev.widget}-${ev.type}`}
|
key={`${ev.widget}-${ev.type}-${ev.name}`}
|
||||||
>
|
>
|
||||||
<div className="event-info">
|
<div className="event-info">
|
||||||
<span className="event-name">
|
<span
|
||||||
{ev.name}
|
className="event-name event-label-bold"
|
||||||
</span>{' '}
|
>
|
||||||
<span className="event-handler">
|
{ev.type}:
|
||||||
{ev.type}
|
</span>
|
||||||
|
<span
|
||||||
|
className="event-name event-value-margin"
|
||||||
|
>
|
||||||
|
{ev.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="event-code">
|
|
||||||
{ev.code}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="event-actions">
|
<div className="event-actions">
|
||||||
<button
|
<button
|
||||||
className="event-btn secondary"
|
className="event-btn secondary"
|
||||||
onClick={() => remove(ev.type)}
|
onClick={() =>
|
||||||
|
remove(ev.type, ev.name)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { WidgetType } from '../types';
|
|||||||
const WIDGETS: WidgetType[] = [
|
const WIDGETS: WidgetType[] = [
|
||||||
'Label',
|
'Label',
|
||||||
'Button',
|
'Button',
|
||||||
|
'Entry',
|
||||||
'Text',
|
'Text',
|
||||||
'Checkbutton',
|
'Checkbutton',
|
||||||
'Radiobutton',
|
'Radiobutton',
|
||||||
@ -15,7 +16,6 @@ export function Palette() {
|
|||||||
e: React.DragEvent<HTMLDivElement>,
|
e: React.DragEvent<HTMLDivElement>,
|
||||||
type: WidgetType
|
type: WidgetType
|
||||||
) => {
|
) => {
|
||||||
console.log('[Palette] Drag start', type);
|
|
||||||
e.dataTransfer.setData('text/plain', type);
|
e.dataTransfer.setData('text/plain', type);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,19 +1,88 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAppDispatch, useAppState } from '../state';
|
import { useAppDispatch, useAppState } from '../state';
|
||||||
|
|
||||||
|
import { Widget } from '../types';
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { design, selectedWidgetId } = useAppState();
|
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 w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||||
|
|
||||||
const update = (patch: Partial<typeof w>) => {
|
const update = (patch: Partial<Widget>) => {
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'updateWidget',
|
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;
|
if (!w) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'updateProps',
|
type: 'updateProps',
|
||||||
@ -27,7 +96,7 @@ export function PropertiesPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="properties-panel">
|
<div className="properties-panel">
|
||||||
<h3>Properties</h3>
|
<h3>Widget Properties</h3>
|
||||||
<div id="propertiesContent">
|
<div id="propertiesContent">
|
||||||
{!w ? (
|
{!w ? (
|
||||||
<p>Select a widget to edit properties</p>
|
<p>Select a widget to edit properties</p>
|
||||||
|
|||||||
@ -1,36 +1,23 @@
|
|||||||
import React, { useRef } from 'react';
|
import React from 'react';
|
||||||
import { useAppDispatch, useAppState } from '../state';
|
import { useAppDispatch, useAppState } from '../state';
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { vscode, design } = useAppState();
|
const { vscode, design } = useAppState();
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const exportProject = () => {
|
const exportProject = () => {
|
||||||
try {
|
try {
|
||||||
console.log('[Toolbar] Export project');
|
|
||||||
const exportData = {
|
const exportData = {
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
description: 'Tkinter Designer Project',
|
description: 'Tkinter Designer Project',
|
||||||
data: design,
|
data: design,
|
||||||
};
|
};
|
||||||
const jsonString = JSON.stringify(exportData, null, 2);
|
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `tkinter-project-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showInfo',
|
type: 'exportProject',
|
||||||
text: 'Project exported successfully!',
|
data: exportData,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Export error:', error);
|
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showError',
|
type: 'showError',
|
||||||
text: `Export failed: ${error.message}`,
|
text: `Export failed: ${error.message}`,
|
||||||
@ -38,41 +25,13 @@ export function Toolbar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importProject = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const importProject = () => {
|
||||||
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({
|
vscode.postMessage({
|
||||||
type: 'showInfo',
|
type: 'importProject',
|
||||||
text: 'Project imported successfully!',
|
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Import error:', error);
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'showError',
|
|
||||||
text: `Import failed: ${error.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () =>
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'showError',
|
|
||||||
text: 'Failed to read file',
|
|
||||||
});
|
|
||||||
reader.readAsText(file);
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
console.log('[Toolbar] Clear all');
|
|
||||||
dispatch({ type: 'clear' });
|
dispatch({ type: 'clear' });
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showInfo',
|
type: 'showInfo',
|
||||||
@ -81,7 +40,6 @@ export function Toolbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateCode = () => {
|
const generateCode = () => {
|
||||||
console.log('[Toolbar] Generate code');
|
|
||||||
if (design.widgets.length === 0) {
|
if (design.widgets.length === 0) {
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'showError',
|
type: 'showError',
|
||||||
@ -96,18 +54,42 @@ export function Toolbar() {
|
|||||||
vscode.postMessage({ type: 'generateCode', data: design });
|
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 (
|
return (
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
id="importFileInput"
|
|
||||||
accept=".json"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={importProject}
|
|
||||||
/>
|
|
||||||
<h2>Tkinter Visual Designer</h2>
|
<h2>Tkinter Visual Designer</h2>
|
||||||
<div className="toolbar-buttons">
|
<div className="toolbar-buttons">
|
||||||
|
<div 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
|
<button
|
||||||
id="undoBtn"
|
id="undoBtn"
|
||||||
className="btn btn-outline"
|
className="btn btn-outline"
|
||||||
@ -137,7 +119,7 @@ export function Toolbar() {
|
|||||||
id="importBtn"
|
id="importBtn"
|
||||||
className="btn btn-outline"
|
className="btn btn-outline"
|
||||||
title="Import project from JSON"
|
title="Import project from JSON"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={importProject}
|
||||||
>
|
>
|
||||||
📥 Import
|
📥 Import
|
||||||
</button>
|
</button>
|
||||||
@ -149,6 +131,13 @@ export function Toolbar() {
|
|||||||
>
|
>
|
||||||
Generate Code
|
Generate Code
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="applyBtn"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={applyChanges}
|
||||||
|
>
|
||||||
|
Apply Changes
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
id="clearBtn"
|
id="clearBtn"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|||||||
@ -12,7 +12,7 @@ declare global {
|
|||||||
const vscode =
|
const vscode =
|
||||||
typeof window.acquireVsCodeApi === 'function'
|
typeof window.acquireVsCodeApi === 'function'
|
||||||
? window.acquireVsCodeApi()
|
? window.acquireVsCodeApi()
|
||||||
: { postMessage: (msg: any) => console.log('[Webview message]', msg) };
|
: { postMessage: (msg: any) => {} };
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
if (container) {
|
if (container) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { createContext, useContext, useReducer, useMemo } from 'react';
|
import React, { createContext, useContext, useReducer, useMemo } from 'react';
|
||||||
|
import { produce } from 'immer';
|
||||||
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
|
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
@ -20,39 +21,90 @@ type Action =
|
|||||||
}
|
}
|
||||||
| { type: 'deleteWidget'; payload: { id: string } }
|
| { type: 'deleteWidget'; payload: { id: string } }
|
||||||
| { type: 'addEvent'; payload: EventBinding }
|
| { type: 'addEvent'; payload: EventBinding }
|
||||||
| { type: 'removeEvent'; payload: { widget: string; type: string } }
|
| {
|
||||||
|
type: 'removeEvent';
|
||||||
|
payload: { widget: string; type: string; name?: string };
|
||||||
|
}
|
||||||
| { type: 'clear' }
|
| { type: 'clear' }
|
||||||
| { type: 'undo' }
|
| { type: 'undo' }
|
||||||
| { type: 'redo' }
|
| { type: 'redo' }
|
||||||
| {
|
| {
|
||||||
type: 'setForm';
|
type: 'setForm';
|
||||||
payload: {
|
payload: {
|
||||||
|
name?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: { width: number; height: number };
|
size?: { width: number; height: number };
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
| { type: 'pushHistory' };
|
||||||
|
|
||||||
const AppStateContext = createContext<AppState | undefined>(undefined);
|
const AppStateContext = createContext<AppState | undefined>(undefined);
|
||||||
const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
|
const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
function clone(data: DesignData): DesignData {
|
// Helper to check if two design states are effectively different to warrant a history entry
|
||||||
return JSON.parse(JSON.stringify(data));
|
// 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 {
|
function reducer(state: AppState, action: Action): AppState {
|
||||||
console.log('[State] Action:', action.type);
|
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) {
|
switch (action.type) {
|
||||||
case 'init': {
|
case 'init': {
|
||||||
const next = {
|
draft.design = action.payload;
|
||||||
...state,
|
draft.selectedWidgetId = null;
|
||||||
design: action.payload,
|
if (!draft.design.events) draft.design.events = [];
|
||||||
selectedWidgetId: null,
|
// Init resets history usually
|
||||||
};
|
draft.history = [JSON.parse(JSON.stringify(draft.design))];
|
||||||
if (!next.design.events) next.design.events = [];
|
draft.historyIndex = 0;
|
||||||
console.log('[State] Init design widgets:', next.design.widgets.length);
|
break;
|
||||||
return pushHistory(next);
|
|
||||||
}
|
}
|
||||||
case 'addWidget': {
|
case 'addWidget': {
|
||||||
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||||
@ -65,135 +117,127 @@ function reducer(state: AppState, action: Action): AppState {
|
|||||||
height: 40,
|
height: 40,
|
||||||
properties: { text: action.payload.type },
|
properties: { text: action.payload.type },
|
||||||
};
|
};
|
||||||
const design = clone(state.design);
|
draft.design.widgets.push(w);
|
||||||
design.widgets.push(w);
|
draft.selectedWidgetId = id;
|
||||||
const next = { ...state, design, selectedWidgetId: id };
|
shouldPushHistory = true;
|
||||||
console.log('[State] Added widget:', id, w.type);
|
break;
|
||||||
return pushHistory(next);
|
|
||||||
}
|
}
|
||||||
case 'selectWidget': {
|
case 'selectWidget': {
|
||||||
console.log('[State] Select widget:', action.payload.id);
|
draft.selectedWidgetId = action.payload.id;
|
||||||
return { ...state, selectedWidgetId: action.payload.id };
|
// Selection change doesn't need history
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case 'updateWidget': {
|
case 'updateWidget': {
|
||||||
const design = clone(state.design);
|
const idx = draft.design.widgets.findIndex(
|
||||||
const idx = design.widgets.findIndex(
|
|
||||||
(w) => w.id === action.payload.id
|
(w) => w.id === action.payload.id
|
||||||
);
|
);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
design.widgets[idx] = {
|
Object.assign(
|
||||||
...design.widgets[idx],
|
draft.design.widgets[idx],
|
||||||
...action.payload.patch,
|
action.payload.patch
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
const next = { ...state, design };
|
// Don't push history here for every drag frame.
|
||||||
console.log('[State] Updated widget:', action.payload.id);
|
// Components should dispatch 'pushHistory' on drag end.
|
||||||
return pushHistory(next);
|
break;
|
||||||
}
|
}
|
||||||
case 'updateProps': {
|
case 'updateProps': {
|
||||||
const design = clone(state.design);
|
const idx = draft.design.widgets.findIndex(
|
||||||
const idx = design.widgets.findIndex(
|
|
||||||
(w) => w.id === action.payload.id
|
(w) => w.id === action.payload.id
|
||||||
);
|
);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
design.widgets[idx] = {
|
draft.design.widgets[idx].properties = {
|
||||||
...design.widgets[idx],
|
...draft.design.widgets[idx].properties,
|
||||||
properties: {
|
|
||||||
...design.widgets[idx].properties,
|
|
||||||
...action.payload.properties,
|
...action.payload.properties,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const next = { ...state, design };
|
shouldPushHistory = true;
|
||||||
console.log('[State] Updated properties for:', action.payload.id);
|
break;
|
||||||
return pushHistory(next);
|
|
||||||
}
|
}
|
||||||
case 'deleteWidget': {
|
case 'deleteWidget': {
|
||||||
const design = clone(state.design);
|
draft.design.widgets = draft.design.widgets.filter(
|
||||||
design.widgets = design.widgets.filter(
|
|
||||||
(w) => w.id !== action.payload.id
|
(w) => w.id !== action.payload.id
|
||||||
);
|
);
|
||||||
if (!design.events) design.events = [];
|
if (!draft.design.events) draft.design.events = [];
|
||||||
design.events = design.events.filter(
|
draft.design.events = draft.design.events.filter(
|
||||||
(e) => e.widget !== action.payload.id
|
(e) => e.widget !== action.payload.id
|
||||||
);
|
);
|
||||||
const next = { ...state, design, selectedWidgetId: null };
|
draft.selectedWidgetId = null;
|
||||||
console.log('[State] Deleted widget:', action.payload.id);
|
shouldPushHistory = true;
|
||||||
return pushHistory(next);
|
break;
|
||||||
}
|
}
|
||||||
case 'addEvent': {
|
case 'addEvent': {
|
||||||
const design = clone(state.design);
|
if (!draft.design.events) draft.design.events = [];
|
||||||
if (!design.events) design.events = [];
|
const existsIndex = draft.design.events.findIndex(
|
||||||
const exists = design.events.find(
|
|
||||||
(e) =>
|
(e) =>
|
||||||
e.widget === action.payload.widget &&
|
e.widget === action.payload.widget &&
|
||||||
e.type === action.payload.type
|
e.type === action.payload.type &&
|
||||||
|
e.name === action.payload.name
|
||||||
);
|
);
|
||||||
if (!exists) {
|
if (existsIndex >= 0) {
|
||||||
design.events.push(action.payload);
|
draft.design.events[existsIndex].code = action.payload.code;
|
||||||
|
} else {
|
||||||
|
draft.design.events.push(action.payload);
|
||||||
}
|
}
|
||||||
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget);
|
shouldPushHistory = true;
|
||||||
return pushHistory({ ...state, design });
|
break;
|
||||||
}
|
}
|
||||||
case 'removeEvent': {
|
case 'removeEvent': {
|
||||||
const design = clone(state.design);
|
if (!draft.design.events) draft.design.events = [];
|
||||||
if (!design.events) design.events = [];
|
draft.design.events = draft.design.events.filter((e) => {
|
||||||
design.events = design.events.filter(
|
const matchWidget = e.widget === action.payload.widget;
|
||||||
(e) =>
|
const matchType = e.type === action.payload.type;
|
||||||
!(
|
const matchName = action.payload.name
|
||||||
e.widget === action.payload.widget &&
|
? e.name === action.payload.name
|
||||||
e.type === action.payload.type
|
: true;
|
||||||
)
|
return !(matchWidget && matchType && matchName);
|
||||||
);
|
});
|
||||||
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget);
|
shouldPushHistory = true;
|
||||||
return pushHistory({ ...state, design });
|
break;
|
||||||
}
|
}
|
||||||
case 'clear': {
|
case 'clear': {
|
||||||
const design: DesignData = {
|
draft.design.widgets = [];
|
||||||
form: state.design.form,
|
draft.design.events = [];
|
||||||
widgets: [],
|
draft.selectedWidgetId = null;
|
||||||
events: [],
|
shouldPushHistory = true;
|
||||||
};
|
break;
|
||||||
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': {
|
case 'setForm': {
|
||||||
const design = clone(state.design);
|
if (action.payload.name)
|
||||||
if (action.payload.title) design.form.title = action.payload.title;
|
draft.design.form.name = action.payload.name;
|
||||||
if (action.payload.size) design.form.size = action.payload.size;
|
if (action.payload.title)
|
||||||
console.log('[State] Set form', action.payload);
|
draft.design.form.title = action.payload.title;
|
||||||
return pushHistory({ ...state, design });
|
if (action.payload.size)
|
||||||
}
|
draft.design.form.size = action.payload.size;
|
||||||
default:
|
if (action.payload.className)
|
||||||
return state;
|
draft.design.form.className = action.payload.className;
|
||||||
|
shouldPushHistory = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushHistory(next: AppState): AppState {
|
if (shouldPushHistory) {
|
||||||
const hist = next.history.slice(0, next.historyIndex + 1);
|
pushHistoryDraft(draft);
|
||||||
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() {
|
export function useAppState() {
|
||||||
@ -217,7 +261,11 @@ export function AppProvider({
|
|||||||
}) {
|
}) {
|
||||||
const initialDesign: DesignData = useMemo(
|
const initialDesign: DesignData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
form: { title: 'My App', size: { width: 800, height: 600 } },
|
form: {
|
||||||
|
name: 'Form1',
|
||||||
|
title: 'My App',
|
||||||
|
size: { width: 800, height: 600 },
|
||||||
|
},
|
||||||
widgets: [],
|
widgets: [],
|
||||||
events: [],
|
events: [],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export type WidgetType =
|
export type WidgetType =
|
||||||
| 'Label'
|
| 'Label'
|
||||||
| 'Button'
|
| 'Button'
|
||||||
|
| 'Entry'
|
||||||
| 'Text'
|
| 'Text'
|
||||||
| 'Checkbutton'
|
| 'Checkbutton'
|
||||||
| 'Radiobutton';
|
| 'Radiobutton';
|
||||||
@ -24,9 +25,19 @@ export interface EventBinding {
|
|||||||
|
|
||||||
export interface DesignData {
|
export interface DesignData {
|
||||||
form: {
|
form: {
|
||||||
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
size: { width: number; height: number };
|
size: { width: number; height: number };
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
widgets: Widget[];
|
widgets: Widget[];
|
||||||
events: EventBinding[];
|
events: EventBinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WebviewMessage =
|
||||||
|
| { type: 'designUpdated'; data: DesignData }
|
||||||
|
| { type: 'getDesignData' }
|
||||||
|
| { type: 'webviewReady' }
|
||||||
|
| { type: 'generateCode'; data: DesignData }
|
||||||
|
| { type: 'applyChanges'; data: DesignData };
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,12 @@ export function useMessaging() {
|
|||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
dispatch({ type: 'init', payload: message.data });
|
dispatch({ type: 'init', payload: message.data });
|
||||||
break;
|
break;
|
||||||
|
case 'updateFormName':
|
||||||
|
dispatch({
|
||||||
|
type: 'setForm',
|
||||||
|
payload: { name: message.name },
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -25,7 +31,12 @@ export function useMessaging() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initializedRef.current) return;
|
if (!initializedRef.current) return;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
vscode.postMessage({ type: 'designUpdated', data: design });
|
vscode.postMessage({ type: 'designUpdated', data: design });
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
}, [design, vscode]);
|
}, [design, vscode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -77,12 +77,13 @@ body {
|
|||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--vscode-button-foreground);
|
color: white;
|
||||||
border: 1px solid var(--vscode-button-border);
|
border: 1px solid var(--vscode-button-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:hover {
|
.btn-outline:hover {
|
||||||
background-color: var(--vscode-button-hoverBackground);
|
background-color: black;
|
||||||
|
border-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:disabled {
|
.btn-outline:disabled {
|
||||||
@ -359,6 +360,24 @@ body {
|
|||||||
|
|
||||||
.event-item .event-info {
|
.event-item .event-info {
|
||||||
flex: 1;
|
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 {
|
.event-item .event-name {
|
||||||
@ -434,22 +453,16 @@ body {
|
|||||||
|
|
||||||
.canvas-container {
|
.canvas-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px;
|
padding: 40px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
background-color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
.design-canvas {
|
align-items: flex-start;
|
||||||
min-height: 600px;
|
|
||||||
min-width: 800px;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
border: 2px dashed var(--vscode-panel-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
position: relative;
|
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
linear-gradient(45deg, #d0d0d0 25%, transparent 25%),
|
||||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
linear-gradient(-45deg, #d0d0d0 25%, transparent 25%),
|
||||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
linear-gradient(45deg, transparent 75%, #d0d0d0 75%),
|
||||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
|
linear-gradient(-45deg, transparent 75%, #d0d0d0 75%);
|
||||||
background-size: 20px 20px;
|
background-size: 20px 20px;
|
||||||
background-position:
|
background-position:
|
||||||
0 0,
|
0 0,
|
||||||
@ -458,9 +471,141 @@ body {
|
|||||||
-10px 0px;
|
-10px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-canvas.drag-over {
|
.window-frame {
|
||||||
border-color: var(--vscode-focusBorder);
|
display: flex;
|
||||||
background-color: var(--vscode-list-dropBackground);
|
flex-direction: column;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
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 {
|
.canvas-placeholder {
|
||||||
@ -486,6 +631,10 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
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 {
|
.canvas-widget:hover {
|
||||||
@ -497,6 +646,71 @@ body {
|
|||||||
box-shadow: 0 0 0 1px var(--vscode-button-background);
|
box-shadow: 0 0 0 1px var(--vscode-button-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid var(--vscode-button-background);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover {
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.n {
|
||||||
|
top: -5px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.s {
|
||||||
|
bottom: -5px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.e {
|
||||||
|
top: 50%;
|
||||||
|
right: -5px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.w {
|
||||||
|
top: 50%;
|
||||||
|
left: -5px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.ne {
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.nw {
|
||||||
|
top: -5px;
|
||||||
|
left: -5px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.se {
|
||||||
|
bottom: -5px;
|
||||||
|
right: -5px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.sw {
|
||||||
|
bottom: -5px;
|
||||||
|
left: -5px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-widget.widget-label {
|
.canvas-widget.widget-label {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
color: #333;
|
color: #333;
|
||||||
@ -516,38 +730,27 @@ body {
|
|||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
resize: both;
|
overflow: hidden;
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-widget.widget-checkbutton {
|
.canvas-widget.widget-checkbutton {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
color: #333;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-widget.widget-checkbutton::before {
|
|
||||||
content: '☑️';
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-widget.widget-radiobutton {
|
.canvas-widget.widget-radiobutton {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
color: #333;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-widget.widget-radiobutton::before {
|
|
||||||
content: '🔘';
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@ -591,3 +794,73 @@ body {
|
|||||||
height: 200px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user