refactor
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
out
|
||||
.vscode-test
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.gitea
|
||||
README.md
|
||||
examples/
|
||||
.vscode/
|
||||
docs/
|
||||
@@ -2,5 +2,4 @@
|
||||
out/
|
||||
docs/
|
||||
node_modules/
|
||||
README.md
|
||||
examples/
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Use Node.js LTS
|
||||
FROM node:18-slim
|
||||
|
||||
# Install Python 3 and git
|
||||
RUN apt-get update && apt-get install -y python3 python3-pip git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the extension
|
||||
RUN npm run compile
|
||||
|
||||
# Install vsce to package the extension
|
||||
RUN npm install -g @vscode/vsce
|
||||
|
||||
# Default command to package the extension
|
||||
CMD ["vsce", "package", "--out", "extension.vsix"]
|
||||
@@ -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`, и т.п.).
|
||||
Generated
+11
@@ -8,6 +8,7 @@
|
||||
"name": "tkinter-designer",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"immer": "^11.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -1243,6 +1244,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
|
||||
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"immer": "^11.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
};
|
||||
+16
-88
@@ -1,130 +1,59 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { CodeGenerator } from './generator/CodeGenerator';
|
||||
import { CodeParser } from './parser/CodeParser';
|
||||
import { TkinterDesignerProvider } from './webview/TkinterDesignerProvider';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
const provider = new TkinterDesignerProvider(context.extensionUri);
|
||||
|
||||
TkinterDesignerProvider._instance = provider;
|
||||
import { CodeParser } from './parser/codeParser';
|
||||
import { TkinterDesignerProvider } from './webview/tkinterDesignerProvider';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {3
|
||||
const openDesignerCommand = vscode.commands.registerCommand(
|
||||
'tkinter-designer.openDesigner',
|
||||
() => {
|
||||
TkinterDesignerProvider.createOrShow(context.extensionUri);
|
||||
}
|
||||
);
|
||||
|
||||
const generateCodeCommand = vscode.commands.registerCommand(
|
||||
'tkinter-designer.generateCode',
|
||||
async () => {
|
||||
console.log('[GenerateCode] Command invoked');
|
||||
const generator = new CodeGenerator();
|
||||
const designData = await provider.getDesignData();
|
||||
if (
|
||||
!designData ||
|
||||
!designData.widgets ||
|
||||
designData.widgets.length === 0
|
||||
) {
|
||||
console.log('[GenerateCode] No design data');
|
||||
vscode.window.showWarningMessage(
|
||||
'No design data found. Please open the designer and create some widgets first.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pythonCode = generator.generateTkinterCode(designData);
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
|
||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
||||
console.log('[GenerateCode] Writing into active Python file');
|
||||
const doc = activeEditor.document;
|
||||
const start = new vscode.Position(0, 0);
|
||||
const end = doc.lineCount
|
||||
? doc.lineAt(doc.lineCount - 1).range.end
|
||||
: start;
|
||||
const fullRange = new vscode.Range(start, end);
|
||||
await activeEditor.edit((editBuilder) => {
|
||||
editBuilder.replace(fullRange, pythonCode);
|
||||
});
|
||||
await doc.save();
|
||||
vscode.window.showInformationMessage(
|
||||
'Python code generated into the active file'
|
||||
);
|
||||
} else {
|
||||
console.log('[GenerateCode] Creating new Python file');
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage(
|
||||
'No workspace folder is open. Please open a folder first.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fileName = `app_${Date.now()}.py`;
|
||||
const filePath = path.join(
|
||||
workspaceFolder.uri.fsPath,
|
||||
fileName
|
||||
);
|
||||
const fileUri = vscode.Uri.file(filePath);
|
||||
const encoder = new TextEncoder();
|
||||
const fileBytes = encoder.encode(pythonCode);
|
||||
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
||||
await vscode.window.showTextDocument(doc, { preview: false });
|
||||
vscode.window.showInformationMessage(
|
||||
`Python file created: ${fileName}`
|
||||
);
|
||||
}
|
||||
console.log('[GenerateCode] Done');
|
||||
TkinterDesignerProvider.createNew(context.extensionUri);
|
||||
}
|
||||
);
|
||||
|
||||
const parseCodeCommand = vscode.commands.registerCommand(
|
||||
'tkinter-designer.parseCode',
|
||||
async () => {
|
||||
console.log('[ParseCode] Command invoked');
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
|
||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
||||
const parser = new CodeParser();
|
||||
const code = activeEditor.document.getText();
|
||||
console.log('[ParseCode] Code length:', code.length);
|
||||
const fileName = path.basename(
|
||||
activeEditor.document.fileName,
|
||||
'.py'
|
||||
);
|
||||
|
||||
try {
|
||||
const designData = await parser.parseCodeToDesign(code);
|
||||
const designData = await parser.parseCodeToDesign(
|
||||
code,
|
||||
fileName
|
||||
);
|
||||
if (
|
||||
designData &&
|
||||
designData.widgets &&
|
||||
designData.widgets.length > 0
|
||||
) {
|
||||
console.log('[ParseCode] Widgets found:', designData.widgets.length);
|
||||
const designerInstance =
|
||||
TkinterDesignerProvider.createOrShow(
|
||||
const designerInstance = TkinterDesignerProvider.createNew(
|
||||
context.extensionUri
|
||||
);
|
||||
if (designerInstance) {
|
||||
designerInstance.loadDesignData(designData);
|
||||
} else {
|
||||
}
|
||||
);
|
||||
|
||||
designerInstance.loadDesignData(designData);
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
`Code parsed successfully! Found ${designData.widgets.length} widgets.`
|
||||
);
|
||||
} else {
|
||||
console.log('[ParseCode] No widgets found');
|
||||
vscode.window.showWarningMessage(
|
||||
'No tkinter widgets found in the code. Make sure your code contains tkinter widget creation statements like tk.Label(), tk.Button(), etc.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ParseCode] Error:', error);
|
||||
vscode.window.showErrorMessage(
|
||||
`Error parsing code: ${error}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('[ParseCode] No active Python editor');
|
||||
vscode.window.showErrorMessage(
|
||||
'Please open a Python file with tkinter code'
|
||||
);
|
||||
@@ -134,7 +63,6 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
context.subscriptions.push(
|
||||
openDesignerCommand,
|
||||
generateCodeCommand,
|
||||
parseCodeCommand
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DesignData, WidgetData } from './types';
|
||||
import { DesignData, WidgetData, DesignEvent } from './types';
|
||||
import {
|
||||
getVariableName,
|
||||
generateVariableNames,
|
||||
getWidgetTypeForGeneration,
|
||||
indentText,
|
||||
escapeString,
|
||||
} from './utils';
|
||||
import {
|
||||
getWidgetParameters,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
generateWidgetContent,
|
||||
} from './widgetHelpers';
|
||||
import { generateEventHandlers, getWidgetEventBindings } from './eventHelpers';
|
||||
import { DEFAULT_FORM_CONFIG, PYTHON_CODE } from '../constants';
|
||||
|
||||
export class CodeGenerator {
|
||||
private indentLevel = 0;
|
||||
@@ -18,48 +20,46 @@ export class CodeGenerator {
|
||||
|
||||
public generateTkinterCode(designData: DesignData): string {
|
||||
this.designData = designData;
|
||||
console.log('[Generator] Start, widgets:', designData.widgets.length, 'events:', designData.events?.length || 0);
|
||||
const className = designData.form.className || DEFAULT_FORM_CONFIG.CLASS_NAME;
|
||||
const lines: string[] = [];
|
||||
const nameMap = generateVariableNames(designData.widgets);
|
||||
|
||||
lines.push('import tkinter as tk');
|
||||
lines.push(PYTHON_CODE.IMPORTS.TKINTER);
|
||||
lines.push(PYTHON_CODE.IMPORTS.TTK);
|
||||
lines.push('');
|
||||
|
||||
lines.push('class Application:');
|
||||
lines.push(`class ${className}:`);
|
||||
this.indentLevel = 1;
|
||||
|
||||
lines.push(this.indent('def __init__(self):'));
|
||||
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.INIT}(self):`));
|
||||
this.indentLevel = 2;
|
||||
lines.push(this.indent('self.root = tk.Tk()'));
|
||||
lines.push(this.indent(`self.root.title("${designData.form.title}")`));
|
||||
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT} = tk.Tk()`));
|
||||
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.title("${escapeString(designData.form.title)}")`));
|
||||
lines.push(
|
||||
this.indent(
|
||||
`self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")`
|
||||
`${PYTHON_CODE.VARIABLES.ROOT}.geometry("${designData.form.size.width}x${designData.form.size.height}")`
|
||||
)
|
||||
);
|
||||
lines.push(this.indent('self.create_widgets()'));
|
||||
lines.push(this.indent(`self.${PYTHON_CODE.METHODS.CREATE_WIDGETS}()`));
|
||||
lines.push('');
|
||||
|
||||
this.indentLevel = 1;
|
||||
lines.push(this.indent('def create_widgets(self):'));
|
||||
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.CREATE_WIDGETS}(self):`));
|
||||
this.indentLevel = 2;
|
||||
|
||||
designData.widgets.forEach((widget) => {
|
||||
console.log('[Generator] Widget:', widget.id, widget.type);
|
||||
lines.push(...this.generateWidgetCode(widget, nameMap));
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
this.indentLevel = 1;
|
||||
lines.push(this.indent('def run(self):'));
|
||||
lines.push(this.indent(`def ${PYTHON_CODE.METHODS.RUN}(self):`));
|
||||
this.indentLevel = 2;
|
||||
lines.push(this.indent('self.root.mainloop()'));
|
||||
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.ROOT}.mainloop()`));
|
||||
lines.push('');
|
||||
|
||||
const hasEvents = designData.events && designData.events.length > 0;
|
||||
|
||||
if (hasEvents) {
|
||||
console.log('[Generator] Generating event handlers');
|
||||
lines.push(
|
||||
...generateEventHandlers(
|
||||
designData,
|
||||
@@ -72,12 +72,58 @@ export class CodeGenerator {
|
||||
this.indentLevel = 0;
|
||||
lines.push('if __name__ == "__main__":');
|
||||
this.indentLevel = 1;
|
||||
lines.push(this.indent('app = Application()'));
|
||||
lines.push(this.indent('app.run()'));
|
||||
lines.push(this.indent('try:'));
|
||||
this.indentLevel = 2;
|
||||
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.APP} = ${className}()`));
|
||||
lines.push(this.indent(`${PYTHON_CODE.VARIABLES.APP}.${PYTHON_CODE.METHODS.RUN}()`));
|
||||
this.indentLevel = 1;
|
||||
lines.push(this.indent('except Exception as e:'));
|
||||
this.indentLevel = 2;
|
||||
lines.push(this.indent('import traceback'));
|
||||
lines.push(this.indent('traceback.print_exc()'));
|
||||
lines.push(this.indent('input("Press Enter to exit...")'));
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
public generateCreateWidgetsBody(designData: DesignData): string {
|
||||
this.designData = designData;
|
||||
const lines: string[] = [];
|
||||
const nameMap = generateVariableNames(designData.widgets);
|
||||
this.indentLevel = 2;
|
||||
|
||||
designData.widgets.forEach((widget) => {
|
||||
lines.push(...this.generateWidgetCode(widget, nameMap));
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
public generateEventHandler(event: DesignEvent): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (event.signature) {
|
||||
lines.push(` ${event.signature}:`);
|
||||
} else {
|
||||
lines.push(` def ${event.name}(self, event=None):`);
|
||||
}
|
||||
|
||||
if (event.code) {
|
||||
const codeContent =
|
||||
typeof event.code === 'string'
|
||||
? event.code
|
||||
: String(event.code || '');
|
||||
const body = codeContent
|
||||
.split('\n')
|
||||
.map((l: string) => indentText(2, l))
|
||||
.join('\n');
|
||||
lines.push(body);
|
||||
} else {
|
||||
lines.push(' pass');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private generateWidgetCode(
|
||||
widget: WidgetData,
|
||||
nameMap: Map<string, string>
|
||||
@@ -96,7 +142,9 @@ export class CodeGenerator {
|
||||
lines.push(this.indent(`self.${varName}.place(${placeParams})`));
|
||||
|
||||
const contentLines = generateWidgetContent(widget, varName);
|
||||
contentLines.forEach((line) => lines.push(this.indent(line)));
|
||||
if (contentLines.length > 0) {
|
||||
contentLines.forEach((l) => lines.push(this.indent(l)));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
...getWidgetEventBindings(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DesignData, Event, WidgetData } from './types';
|
||||
import { DesignData, DesignEvent, WidgetData } from './types';
|
||||
|
||||
export function generateEventHandlers(
|
||||
designData: DesignData,
|
||||
@@ -8,22 +8,39 @@ export function generateEventHandlers(
|
||||
const lines: string[] = [];
|
||||
|
||||
if (designData.events && designData.events.length > 0) {
|
||||
designData.events.forEach((event: Event) => {
|
||||
const handlerName = event.name;
|
||||
const isBindEvent =
|
||||
event.type.startsWith('<') && event.type.endsWith('>');
|
||||
const handledNames = new Set<string>();
|
||||
|
||||
let hasCode = false;
|
||||
const widget = (designData.widgets || []).find(
|
||||
(w) => w.id === event.widget
|
||||
);
|
||||
if (isBindEvent) {
|
||||
lines.push(indentDef(`def ${handlerName}(self, event):`));
|
||||
designData.events.forEach((event: DesignEvent) => {
|
||||
const handlerName = event.name;
|
||||
|
||||
if (handledNames.has(handlerName)) {
|
||||
return;
|
||||
}
|
||||
handledNames.add(handlerName);
|
||||
|
||||
const signature = event.signature;
|
||||
|
||||
if (signature) {
|
||||
lines.push(indentDef(`${signature}:`));
|
||||
} else {
|
||||
lines.push(indentDef(`def ${handlerName}(self):`));
|
||||
const isBindEvent =
|
||||
event.type.startsWith('<') && event.type.endsWith('>');
|
||||
|
||||
if (isBindEvent) {
|
||||
lines.push(
|
||||
indentDef(`def ${handlerName}(self, event=None):`)
|
||||
);
|
||||
} else {
|
||||
lines.push(indentDef(`def ${handlerName}(self):`));
|
||||
}
|
||||
}
|
||||
|
||||
const codeLines = (event.code || '').split('\n');
|
||||
let hasCode = false;
|
||||
const codeContent =
|
||||
typeof event.code === 'string'
|
||||
? event.code
|
||||
: String(event.code || '');
|
||||
const codeLines = codeContent.split('\n');
|
||||
for (const line of codeLines) {
|
||||
if (line.trim()) {
|
||||
lines.push(indentBody(line));
|
||||
|
||||
+18
-3
@@ -1,8 +1,21 @@
|
||||
export interface Event {
|
||||
export interface DesignEvent {
|
||||
widget: string;
|
||||
type: string;
|
||||
name: string;
|
||||
code: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface WidgetProperties {
|
||||
text?: string;
|
||||
command?: string | { name?: string; lambda_body?: string };
|
||||
variable?: string;
|
||||
orient?: string;
|
||||
from_?: number;
|
||||
to?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WidgetData {
|
||||
@@ -12,17 +25,19 @@ export interface WidgetData {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
properties: { [key: string]: any };
|
||||
properties: WidgetProperties;
|
||||
}
|
||||
|
||||
export interface DesignData {
|
||||
form: {
|
||||
name: string;
|
||||
title: string;
|
||||
size: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
className?: string;
|
||||
};
|
||||
widgets: WidgetData[];
|
||||
events?: Event[];
|
||||
events?: DesignEvent[];
|
||||
}
|
||||
|
||||
@@ -25,12 +25,11 @@ export function generateVariableNames(
|
||||
|
||||
widgets.forEach((widget) => {
|
||||
let baseName = widget.type.toLowerCase();
|
||||
// Handle special cases or short forms if desired, e.g. 'button' -> 'btn'
|
||||
if (baseName === 'button') baseName = 'btn';
|
||||
if (baseName === 'entry') baseName = 'entry';
|
||||
if (baseName === 'checkbutton') baseName = 'chk';
|
||||
if (baseName === 'radiobutton') baseName = 'radio';
|
||||
if (baseName === 'label') baseName = 'lbl';
|
||||
|
||||
|
||||
const count = (counts.get(baseName) || 0) + 1;
|
||||
counts.set(baseName, count);
|
||||
|
||||
+21
-125
@@ -1,137 +1,33 @@
|
||||
import { DesignData, WidgetData, Event } from '../generator/types';
|
||||
import * as vscode from 'vscode';
|
||||
import { DesignData, WidgetData, DesignEvent } from '../generator/types';
|
||||
import { runPythonAst } from './pythonRunner';
|
||||
import { convertASTResultToDesignData } from './astConverter';
|
||||
import { ASTResult } from './astTypes';
|
||||
|
||||
export class CodeParser {
|
||||
public async parseCodeToDesign(
|
||||
pythonCode: string
|
||||
pythonCode: string,
|
||||
filename?: string
|
||||
): Promise<DesignData | null> {
|
||||
console.log(
|
||||
'[Parser] parseCodeToDesign start, code length:',
|
||||
pythonCode.length
|
||||
);
|
||||
const astRaw = await runPythonAst(pythonCode);
|
||||
const astDesign = convertASTResultToDesignData(astRaw);
|
||||
if (astDesign && astDesign.widgets && astDesign.widgets.length > 0) {
|
||||
console.log(
|
||||
'[Parser] AST parsed widgets:',
|
||||
astDesign.widgets.length
|
||||
);
|
||||
return astDesign;
|
||||
}
|
||||
console.log('[Parser] AST returned no widgets, using regex fallback');
|
||||
const regexDesign = this.parseWithRegexInline(pythonCode);
|
||||
console.log(
|
||||
'[Parser] Regex parsed widgets:',
|
||||
regexDesign?.widgets?.length || 0
|
||||
);
|
||||
return regexDesign;
|
||||
}
|
||||
|
||||
private parseWithRegexInline(code: string): DesignData | null {
|
||||
const widgetRegex =
|
||||
/(self\.)?(\w+)\s*=\s*tk\.(Label|Button|Text|Checkbutton|Radiobutton)\s*\(([^)]*)\)/g;
|
||||
const placeRegex = /(self\.)?(\w+)\.place\s*\(([^)]*)\)/g;
|
||||
const titleRegex = /\.title\s*\(\s*(["'])(.*?)\1\s*\)/;
|
||||
const geometryRegex = /\.geometry\s*\(\s*(["'])(\d+)x(\d+)\1\s*\)/;
|
||||
|
||||
const widgets: WidgetData[] = [];
|
||||
const widgetMap = new Map<string, WidgetData>();
|
||||
|
||||
let formTitle = 'App';
|
||||
let formWidth = 800;
|
||||
let formHeight = 600;
|
||||
|
||||
const tMatch = code.match(titleRegex);
|
||||
if (tMatch) formTitle = tMatch[2];
|
||||
const gMatch = code.match(geometryRegex);
|
||||
if (gMatch) {
|
||||
formWidth = parseInt(gMatch[2], 10) || 800;
|
||||
formHeight = parseInt(gMatch[3], 10) || 600;
|
||||
}
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = widgetRegex.exec(code)) !== null) {
|
||||
const varName = m[2];
|
||||
const type = m[3];
|
||||
const paramStr = m[4] || '';
|
||||
const id = varName;
|
||||
const w: WidgetData = {
|
||||
id,
|
||||
type,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 25,
|
||||
properties: {},
|
||||
};
|
||||
const textMatch = paramStr.match(/text\s*=\s*(["'])(.*?)\1/);
|
||||
if (textMatch) {
|
||||
w.properties.text = textMatch[2];
|
||||
}
|
||||
const widthMatch = paramStr.match(/width\s*=\s*(\d+)/);
|
||||
if (widthMatch) {
|
||||
const wv = parseInt(widthMatch[1], 10);
|
||||
if (!isNaN(wv)) w.width = wv;
|
||||
}
|
||||
const heightMatch = paramStr.match(/height\s*=\s*(\d+)/);
|
||||
if (heightMatch) {
|
||||
const hv = parseInt(heightMatch[1], 10);
|
||||
if (!isNaN(hv)) w.height = hv;
|
||||
}
|
||||
widgets.push(w);
|
||||
widgetMap.set(varName, w);
|
||||
}
|
||||
|
||||
let p: RegExpExecArray | null;
|
||||
while ((p = placeRegex.exec(code)) !== null) {
|
||||
const varName = p[2];
|
||||
const params = p[3];
|
||||
const w = widgetMap.get(varName);
|
||||
if (!w) continue;
|
||||
const getNum = (key: string) => {
|
||||
const r = new RegExp(key + '\\s*[:=]\\s*(\d+)');
|
||||
const mm = params.match(r);
|
||||
return mm ? parseInt(mm[1], 10) : undefined;
|
||||
};
|
||||
const x = getNum('x');
|
||||
const y = getNum('y');
|
||||
const width = getNum('width');
|
||||
const height = getNum('height');
|
||||
if (typeof x === 'number') w.x = x;
|
||||
if (typeof y === 'number') w.y = y;
|
||||
if (typeof width === 'number') w.width = width;
|
||||
if (typeof height === 'number') w.height = height;
|
||||
}
|
||||
|
||||
const events: Event[] = [];
|
||||
const configRegex =
|
||||
/(self\.)?(\w+)\.config\s*\(\s*command\s*=\s*(self\.)?(\w+)\s*\)/g;
|
||||
let c: RegExpExecArray | null;
|
||||
while ((c = configRegex.exec(code)) !== null) {
|
||||
const varName = c[2];
|
||||
const handler = c[4];
|
||||
if (widgetMap.has(varName)) {
|
||||
events.push({
|
||||
widget: varName,
|
||||
type: 'command',
|
||||
name: handler,
|
||||
code: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (widgets.length === 0) {
|
||||
console.log('[Parser] Regex found 0 widgets');
|
||||
if (astRaw && astRaw.error) {
|
||||
vscode.window.showErrorMessage(`Parser Error: ${astRaw.error}`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
form: {
|
||||
title: formTitle,
|
||||
size: { width: formWidth, height: formHeight },
|
||||
},
|
||||
widgets,
|
||||
events,
|
||||
};
|
||||
|
||||
const astDesign = convertASTResultToDesignData(astRaw as ASTResult);
|
||||
|
||||
if (astDesign) {
|
||||
if (filename) {
|
||||
astDesign.form.name = filename;
|
||||
}
|
||||
return astDesign;
|
||||
}
|
||||
|
||||
vscode.window.showErrorMessage(
|
||||
'Could not parse Python code safely. Please ensure your code has no syntax errors and follows the standard structure (class-based Tkinter app).'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-56
@@ -1,10 +1,15 @@
|
||||
import { DesignData, WidgetData, Event } from '../generator/types';
|
||||
import { DesignData, WidgetData, DesignEvent, WidgetProperties } from '../generator/types';
|
||||
import { ASTResult, ASTWidget, ASTMethodData } from './astTypes';
|
||||
import { getDefaultWidth, getDefaultHeight } from './utils';
|
||||
import { WidgetType, DEFAULT_FORM_CONFIG, EVENT_TYPES } from '../constants';
|
||||
|
||||
export function convertASTResultToDesignData(
|
||||
astResult: any
|
||||
): DesignData | null {
|
||||
if (!astResult || !astResult.widgets || astResult.widgets.length === 0) {
|
||||
export function convertASTResultToDesignData(astResult: ASTResult): DesignData | null {
|
||||
if (!astResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (!astResult.window && !astResult.widgets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,48 +17,44 @@ export function convertASTResultToDesignData(
|
||||
let formTitle =
|
||||
(astResult.window && astResult.window.title) ||
|
||||
astResult.title ||
|
||||
'Parsed App';
|
||||
DEFAULT_FORM_CONFIG.TITLE;
|
||||
let formWidth =
|
||||
(astResult.window && astResult.window.width) || astResult.width || 800;
|
||||
(astResult.window && astResult.window.width) || astResult.width || DEFAULT_FORM_CONFIG.WIDTH;
|
||||
let formHeight =
|
||||
(astResult.window && astResult.window.height) ||
|
||||
astResult.height ||
|
||||
600;
|
||||
DEFAULT_FORM_CONFIG.HEIGHT;
|
||||
let className =
|
||||
(astResult.window && astResult.window.className) || DEFAULT_FORM_CONFIG.CLASS_NAME;
|
||||
|
||||
let counter = 0;
|
||||
const allowedTypes = new Set([
|
||||
'Label',
|
||||
'Button',
|
||||
'Text',
|
||||
'Checkbutton',
|
||||
'Radiobutton',
|
||||
WidgetType.Label,
|
||||
WidgetType.Button,
|
||||
WidgetType.Entry,
|
||||
WidgetType.Text,
|
||||
WidgetType.Checkbutton,
|
||||
WidgetType.Radiobutton,
|
||||
]);
|
||||
for (const w of astResult.widgets) {
|
||||
|
||||
|
||||
const inputWidgets = astResult.widgets || [];
|
||||
|
||||
for (const w of inputWidgets) {
|
||||
counter++;
|
||||
const type = w.type || 'Widget';
|
||||
if (!allowedTypes.has(type)) {
|
||||
const type = w.type || WidgetType.Widget;
|
||||
if (!allowedTypes.has(type as WidgetType)) {
|
||||
continue;
|
||||
}
|
||||
const place = w.placement || {};
|
||||
const x = place.x !== undefined ? place.x : w.x !== undefined ? w.x : 0;
|
||||
const y = place.y !== undefined ? place.y : w.y !== undefined ? w.y : 0;
|
||||
const p = (w.properties || w.params || {}) as any;
|
||||
const width =
|
||||
place.width !== undefined
|
||||
? place.width
|
||||
: w.width !== undefined
|
||||
? w.width
|
||||
: p.width !== undefined
|
||||
? p.width
|
||||
: getDefaultWidth(type);
|
||||
const height =
|
||||
place.height !== undefined
|
||||
? place.height
|
||||
: w.height !== undefined
|
||||
? w.height
|
||||
: p.height !== undefined
|
||||
? p.height
|
||||
: getDefaultHeight(type);
|
||||
|
||||
|
||||
const p: Record<string, any> = w.properties || w.params || {};
|
||||
|
||||
const width = [place.width, w.width, p.width].find(v => v !== undefined) ?? getDefaultWidth(type);
|
||||
const height = [place.height, w.height, p.height].find(v => v !== undefined) ?? getDefaultHeight(type);
|
||||
const id = w.variable_name || `ast_widget_${counter}`;
|
||||
const widget: WidgetData = {
|
||||
id,
|
||||
@@ -67,33 +68,58 @@ export function convertASTResultToDesignData(
|
||||
widgets.push(widget);
|
||||
}
|
||||
|
||||
const events: Event[] = [];
|
||||
const events: DesignEvent[] = [];
|
||||
const methods = astResult.methods || {};
|
||||
|
||||
if (astResult.command_callbacks) {
|
||||
for (const callback of astResult.command_callbacks) {
|
||||
const rawName = callback.command?.name;
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: `on_${callback.widget}_command`;
|
||||
events.push({
|
||||
widget: callback.widget,
|
||||
type: 'command',
|
||||
name: cleanName,
|
||||
code: callback.command?.lambda_body || '',
|
||||
});
|
||||
addDesignEvent(
|
||||
events,
|
||||
methods,
|
||||
callback.widget,
|
||||
EVENT_TYPES.COMMAND,
|
||||
callback.command?.name,
|
||||
() => `on_${callback.widget}_command`,
|
||||
callback.command?.lambda_body
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (astResult.bind_events) {
|
||||
for (const bindEvent of astResult.bind_events) {
|
||||
const rawName = bindEvent.callback?.name;
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
|
||||
events.push({
|
||||
widget: bindEvent.widget,
|
||||
type: bindEvent.event,
|
||||
name: cleanName,
|
||||
code: bindEvent.callback?.lambda_body || '',
|
||||
});
|
||||
addDesignEvent(
|
||||
events,
|
||||
methods,
|
||||
bindEvent.widget,
|
||||
bindEvent.event,
|
||||
bindEvent.callback?.name,
|
||||
() => `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`,
|
||||
bindEvent.callback?.lambda_body
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (astResult.widgets) {
|
||||
for (const w of astResult.widgets) {
|
||||
const p: Record<string, any> = w.properties || w.params || {};
|
||||
if (p.command) {
|
||||
const widgetId = w.variable_name;
|
||||
if (!widgetId) continue;
|
||||
|
||||
const cmd = p.command;
|
||||
const rawName = cmd.name || cmd;
|
||||
|
||||
addDesignEvent(
|
||||
events,
|
||||
methods,
|
||||
widgetId,
|
||||
EVENT_TYPES.COMMAND,
|
||||
rawName,
|
||||
() => `on_${widgetId}_command`,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +128,10 @@ export function convertASTResultToDesignData(
|
||||
|
||||
const result: DesignData = {
|
||||
form: {
|
||||
name: DEFAULT_FORM_CONFIG.FORM_NAME,
|
||||
title: formTitle,
|
||||
size: { width: formWidth, height: formHeight },
|
||||
className: className,
|
||||
},
|
||||
widgets,
|
||||
events: filteredEvents.length ? filteredEvents : [],
|
||||
@@ -111,8 +139,8 @@ export function convertASTResultToDesignData(
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractWidgetPropertiesFromAST(w: any): any {
|
||||
const props: any = {};
|
||||
function extractWidgetPropertiesFromAST(w: ASTWidget): WidgetProperties {
|
||||
const props: WidgetProperties = {};
|
||||
if (!w) return props;
|
||||
const p = w.properties || w.params || {};
|
||||
if (p.text) props.text = p.text;
|
||||
@@ -125,3 +153,43 @@ function extractWidgetPropertiesFromAST(w: any): any {
|
||||
if (p.height !== undefined) props.height = p.height;
|
||||
return props;
|
||||
}
|
||||
|
||||
function addDesignEvent(
|
||||
events: DesignEvent[],
|
||||
methods: Record<string, any>,
|
||||
widgetId: string,
|
||||
eventType: string,
|
||||
rawName: string | undefined | null,
|
||||
defaultNameGen: () => string,
|
||||
lambdaBody: string | undefined,
|
||||
checkExists: boolean = false
|
||||
) {
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: defaultNameGen();
|
||||
|
||||
if (checkExists) {
|
||||
const exists = events.find(
|
||||
(e) => e.widget === widgetId && e.type === eventType
|
||||
);
|
||||
if (exists) return;
|
||||
}
|
||||
|
||||
const methodData = methods[cleanName];
|
||||
const codeBody =
|
||||
typeof methodData === 'object' && methodData !== null
|
||||
? (methodData as ASTMethodData).body
|
||||
: (methodData as string);
|
||||
const signature =
|
||||
typeof methodData === 'object' && methodData !== null
|
||||
? (methodData as ASTMethodData).signature
|
||||
: undefined;
|
||||
|
||||
events.push({
|
||||
widget: widgetId,
|
||||
type: eventType,
|
||||
name: cleanName,
|
||||
code: codeBody || lambdaBody || '',
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+158
-59
@@ -2,79 +2,178 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import * as vscode from 'vscode';
|
||||
import { ASTResult } from './astTypes';
|
||||
|
||||
async function executePythonScript(
|
||||
pythonScriptPath: string,
|
||||
pythonFilePath: string
|
||||
): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const pythonCommand = getPythonCommand();
|
||||
const start = Date.now();
|
||||
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath);
|
||||
const process = spawn(pythonCommand, [
|
||||
pythonScriptPath,
|
||||
pythonFilePath,
|
||||
]);
|
||||
let result = '';
|
||||
let errorOutput = '';
|
||||
class PythonExecutor {
|
||||
private static instance: PythonExecutor;
|
||||
private cachedPythonPath: string | null = null;
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
result += data.toString();
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): PythonExecutor {
|
||||
if (!PythonExecutor.instance) {
|
||||
PythonExecutor.instance = new PythonExecutor();
|
||||
}
|
||||
return PythonExecutor.instance;
|
||||
}
|
||||
|
||||
public async getPythonPath(): Promise<string> {
|
||||
if (this.cachedPythonPath) {
|
||||
return this.cachedPythonPath;
|
||||
}
|
||||
|
||||
const commands = await this.findPythonCommands();
|
||||
|
||||
for (const cmd of commands) {
|
||||
try {
|
||||
await this.verifyPython(cmd);
|
||||
this.cachedPythonPath = cmd;
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No suitable Python interpreter found. Please install Python or configure "python.defaultInterpreterPath" in VS Code.');
|
||||
}
|
||||
|
||||
private async verifyPython(cmd: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const process = spawn(cmd, ['--version'], { shell: false });
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Python check failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
private async findPythonCommands(): Promise<string[]> {
|
||||
const commands: string[] = [];
|
||||
|
||||
process.on('close', (code) => {
|
||||
const ms = Date.now() - start;
|
||||
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms);
|
||||
if (code === 0) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Python script failed with code ${code}: ${errorOutput}`
|
||||
)
|
||||
);
|
||||
|
||||
const extension = vscode.extensions.getExtension('ms-python.python');
|
||||
if (extension) {
|
||||
if (!extension.isActive) {
|
||||
await extension.activate();
|
||||
}
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('python');
|
||||
let pythonPath =
|
||||
config.get<string>('defaultInterpreterPath') ||
|
||||
config.get<string>('pythonPath');
|
||||
|
||||
if (pythonPath && pythonPath !== 'python') {
|
||||
if (pythonPath.includes('${workspaceFolder}')) {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||
pythonPath = pythonPath.replace(
|
||||
'${workspaceFolder}',
|
||||
workspaceFolders[0].uri.fsPath
|
||||
);
|
||||
}
|
||||
}
|
||||
commands.push(pythonPath);
|
||||
}
|
||||
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
commands.push('py');
|
||||
commands.push('python');
|
||||
} else {
|
||||
commands.push('python3');
|
||||
commands.push('python');
|
||||
}
|
||||
|
||||
return Array.from(new Set(commands));
|
||||
}
|
||||
|
||||
public async executeScript(scriptPath: string, args: string[] = [], stdinInput?: string): Promise<string> {
|
||||
const pythonPath = await this.getPythonPath();
|
||||
const allArgs = [scriptPath, ...args];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn(pythonPath, allArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr || `Process exited with code ${code}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
reject(new Error(`Failed to spawn python process: ${err.message}`));
|
||||
});
|
||||
|
||||
if (stdinInput) {
|
||||
try {
|
||||
process.stdin.write(stdinInput);
|
||||
process.stdin.end();
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Failed to write to stdin: ${e.message}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getPythonCommand(): string {
|
||||
return process.platform === 'win32' ? 'python' : 'python3';
|
||||
}
|
||||
|
||||
function createTempPythonFile(pythonCode: string): string {
|
||||
const tempDir = os.tmpdir();
|
||||
const tempFilePath = path.join(tempDir, `tk_ast_${Date.now()}.py`);
|
||||
fs.writeFileSync(tempFilePath, pythonCode, 'utf8');
|
||||
return tempFilePath;
|
||||
}
|
||||
|
||||
function cleanupTempFile(tempFile: string): void {
|
||||
export async function executePythonScript(
|
||||
pythonScriptPath: string,
|
||||
pythonCode: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
fs.unlinkSync(tempFile);
|
||||
} catch {}
|
||||
const executor = PythonExecutor.getInstance();
|
||||
return await executor.executeScript(pythonScriptPath, [], pythonCode);
|
||||
} catch (error: any) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPythonAst(pythonCode: string): Promise<any | null> {
|
||||
const tempFilePath = createTempPythonFile(pythonCode);
|
||||
export async function runPythonAst(code: string): Promise<ASTResult | { error: string }> {
|
||||
try {
|
||||
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
|
||||
const output = await executePythonScript(
|
||||
pythonScriptPath,
|
||||
tempFilePath
|
||||
const executor = PythonExecutor.getInstance();
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'parser',
|
||||
'tkinter_ast_parser.py'
|
||||
);
|
||||
console.log('[PythonRunner] Received AST JSON length:', output.length);
|
||||
const parsed = JSON.parse(output);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error('[PythonRunner] Error running Python AST:', err);
|
||||
return null;
|
||||
} finally {
|
||||
cleanupTempFile(tempFilePath);
|
||||
console.log('[PythonRunner] Temp file cleaned:', tempFilePath);
|
||||
|
||||
const output = await executor.executeScript(scriptPath, [], code);
|
||||
try {
|
||||
return JSON.parse(output) as ASTResult;
|
||||
} catch (e) {
|
||||
return { error: 'Failed to parse JSON output: ' + e };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,7 @@
|
||||
import ast
|
||||
import textwrap
|
||||
import tokenize
|
||||
import io
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from .imports import handle_import, handle_import_from
|
||||
@@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
|
||||
|
||||
class TkinterAnalyzer(ast.NodeVisitor):
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, source_code: str = ""):
|
||||
self.source_code = source_code
|
||||
self.line_offsets = [0]
|
||||
current = 0
|
||||
if source_code:
|
||||
for line in source_code.splitlines(keepends=True):
|
||||
current += len(line)
|
||||
self.line_offsets.append(current)
|
||||
|
||||
self.widgets: List[Dict[str, Any]] = []
|
||||
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
|
||||
self.imports: Dict[str, str] = {}
|
||||
@@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
||||
self.event_handlers: List[Dict[str, Any]] = []
|
||||
self.command_callbacks: List[Dict[str, Any]] = []
|
||||
self.bind_events: List[Dict[str, Any]] = []
|
||||
self.methods: Dict[str, str] = {}
|
||||
|
||||
def visit_Import(self, node: ast.Import):
|
||||
handle_import(self, node)
|
||||
@@ -36,12 +48,119 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
||||
def visit_ClassDef(self, node: ast.ClassDef):
|
||||
prev = self.current_class
|
||||
enter_class(self, node)
|
||||
|
||||
is_tk_class = False
|
||||
for base in node.bases:
|
||||
if isinstance(base, ast.Attribute) and base.attr == 'Tk':
|
||||
is_tk_class = True
|
||||
elif isinstance(base, ast.Name) and base.id == 'Tk':
|
||||
is_tk_class = True
|
||||
|
||||
if node.name == 'Application':
|
||||
self.window_config['className'] = node.name
|
||||
elif is_tk_class and self.window_config.get('className') != 'Application':
|
||||
self.window_config['className'] = node.name
|
||||
elif not self.window_config.get('className'):
|
||||
self.window_config['className'] = node.name
|
||||
|
||||
self.generic_visit(node)
|
||||
exit_class(self, prev)
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef):
|
||||
prev = self.current_method
|
||||
enter_function(self, node)
|
||||
|
||||
if self.current_class and node.name not in ['__init__', 'create_widgets', 'run'] and self.source_code and node.body:
|
||||
try:
|
||||
|
||||
|
||||
|
||||
|
||||
start_line = node.lineno
|
||||
end_line = getattr(node, 'end_lineno', None)
|
||||
|
||||
if end_line is None:
|
||||
|
||||
end_line = node.body[-1].lineno
|
||||
|
||||
lines = self.source_code.splitlines(keepends=True)
|
||||
|
||||
func_lines = lines[start_line-1 : end_line]
|
||||
func_text = "".join(func_lines)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
body_start_idx = -1
|
||||
|
||||
try:
|
||||
tokens = list(tokenize.tokenize(io.BytesIO(func_text.encode('utf-8')).readline))
|
||||
|
||||
nesting = 0
|
||||
colon_token = None
|
||||
|
||||
for tok in tokens:
|
||||
if tok.type == tokenize.OP:
|
||||
if tok.string in '([{':
|
||||
nesting += 1
|
||||
elif tok.string in ')]}':
|
||||
nesting -= 1
|
||||
elif tok.string == ':' and nesting == 0:
|
||||
colon_token = tok
|
||||
|
||||
break
|
||||
|
||||
if colon_token:
|
||||
|
||||
|
||||
|
||||
|
||||
colon_line_idx = colon_token.end[0] - 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
body_lines = func_lines[colon_line_idx + 1:]
|
||||
|
||||
|
||||
colon_line = func_lines[colon_line_idx]
|
||||
after_colon = colon_line[colon_token.end[1]:]
|
||||
|
||||
if after_colon.strip():
|
||||
body_lines.insert(0, after_colon)
|
||||
|
||||
body_text = "".join(body_lines)
|
||||
|
||||
|
||||
dedented_body = textwrap.dedent(body_text)
|
||||
|
||||
|
||||
|
||||
|
||||
sig_lines = func_lines[:colon_line_idx]
|
||||
sig_lines.append(colon_line[:colon_token.end[1]])
|
||||
|
||||
sig_raw = "".join(sig_lines).strip()
|
||||
if sig_raw.endswith(':'):
|
||||
sig_raw = sig_raw[:-1].strip()
|
||||
|
||||
self.methods[node.name] = {
|
||||
'body': dedented_body.strip(),
|
||||
'signature': sig_raw
|
||||
}
|
||||
|
||||
except tokenize.TokenError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
self.generic_visit(node)
|
||||
exit_function(self, prev)
|
||||
|
||||
@@ -101,9 +220,3 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
||||
|
||||
def analyze_widget_creation_commands(self, node: ast.Assign):
|
||||
return analyze_widget_creation_commands(self, node)
|
||||
|
||||
def create_widget_handler_connections(self, widgets: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
return create_widget_handler_connections(self, widgets)
|
||||
|
||||
def is_interactive_widget(self, widget_type: str) -> bool:
|
||||
return is_interactive_widget(widget_type)
|
||||
@@ -15,15 +15,11 @@ def handle_method_call(analyzer, node: ast.Call):
|
||||
arg0 = node.args[0]
|
||||
if isinstance(arg0, ast.Constant):
|
||||
analyzer.window_config['title'] = arg0.value
|
||||
elif isinstance(arg0, ast.Str):
|
||||
analyzer.window_config['title'] = arg0.s
|
||||
|
||||
elif method_name == 'geometry' and node.args:
|
||||
arg0 = node.args[0]
|
||||
if isinstance(arg0, ast.Constant):
|
||||
geometry = arg0.value
|
||||
elif isinstance(arg0, ast.Str):
|
||||
geometry = arg0.s
|
||||
else:
|
||||
return
|
||||
if 'x' in str(geometry):
|
||||
|
||||
@@ -15,10 +15,6 @@ def get_variable_name(node: ast.AST) -> str:
|
||||
def extract_value(node: ast.AST) -> Any:
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
elif isinstance(node, ast.Str):
|
||||
return node.s
|
||||
elif isinstance(node, ast.Num):
|
||||
return node.n
|
||||
elif isinstance(node, ast.Name):
|
||||
return f"${node.id}"
|
||||
elif isinstance(node, ast.Attribute):
|
||||
@@ -65,7 +61,7 @@ def get_operator_symbol(op_node: ast.AST) -> str:
|
||||
|
||||
def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
||||
body = lambda_node.body
|
||||
if isinstance(body, (ast.Constant, ast.Str, ast.Num)):
|
||||
if isinstance(body, ast.Constant):
|
||||
return 'simple'
|
||||
elif isinstance(body, (ast.Name, ast.Attribute)):
|
||||
return 'simple'
|
||||
@@ -76,11 +72,9 @@ def analyze_lambda_complexity(lambda_node: ast.Lambda) -> str:
|
||||
|
||||
def extract_lambda_body(body_node: ast.AST) -> str:
|
||||
if isinstance(body_node, ast.Constant):
|
||||
if isinstance(body_node.value, str):
|
||||
return f'"{body_node.value}"'
|
||||
return str(body_node.value)
|
||||
elif isinstance(body_node, ast.Str):
|
||||
return f'"{body_node.s}"'
|
||||
elif isinstance(body_node, ast.Num):
|
||||
return str(body_node.n)
|
||||
elif isinstance(body_node, ast.Name):
|
||||
return body_node.id
|
||||
elif isinstance(body_node, ast.Attribute):
|
||||
@@ -138,8 +132,6 @@ def extract_lambda_body(body_node: ast.AST) -> str:
|
||||
for value in body_node.values:
|
||||
if isinstance(value, ast.Constant):
|
||||
parts.append(str(value.value))
|
||||
elif isinstance(value, ast.Str):
|
||||
parts.append(value.s)
|
||||
else:
|
||||
parts.append(f"{{{extract_lambda_body(value)}}}")
|
||||
return f"f\"{''.join(parts)}\""
|
||||
|
||||
@@ -19,10 +19,13 @@ def is_tkinter_widget_call(analyzer, call_node: ast.Call) -> bool:
|
||||
widget_type = call_node.func.attr
|
||||
|
||||
if module_name in ['tk', 'tkinter'] or module_name in analyzer.imports.values():
|
||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
||||
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
|
||||
return False
|
||||
return widget_type in ['Label', 'Button', 'Text', 'Checkbutton', 'Radiobutton']
|
||||
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
|
||||
resolved = analyzer.imports.get(module_name, module_name)
|
||||
if resolved in ['ttk', 'tkinter.ttk']:
|
||||
return widget_type in ['Label', 'Button', 'Entry', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
|
||||
return widget_type in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
|
||||
def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
||||
if not isinstance(node.value, ast.Call):
|
||||
@@ -31,7 +34,7 @@ def is_widget_creation(analyzer, node: ast.Assign) -> bool:
|
||||
if isinstance(node.value.func, ast.Attribute):
|
||||
return is_tkinter_widget_call(analyzer, node.value)
|
||||
elif isinstance(node.value.func, ast.Name):
|
||||
return node.value.func.id in ['Label', 'Button', 'Text', 'Checkbutton']
|
||||
return node.value.func.id in ['Label', 'Button', 'Entry', 'Text', 'Frame', 'Checkbutton', 'Radiobutton']
|
||||
return False
|
||||
|
||||
def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
||||
@@ -49,10 +52,6 @@ def extract_widget_info(analyzer, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if isinstance(call_node.func, ast.Attribute):
|
||||
widget_type = call_node.func.attr
|
||||
if isinstance(call_node.func.value, ast.Name):
|
||||
module_name = call_node.func.value.id
|
||||
if module_name in ['ttk'] or 'ttk' in analyzer.imports.values():
|
||||
return None
|
||||
elif isinstance(call_node.func, ast.Name):
|
||||
widget_type = call_node.func.id
|
||||
else:
|
||||
|
||||
@@ -9,7 +9,7 @@ from tk_ast.grid_layout import GridLayoutAnalyzer
|
||||
def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
analyzer = TkinterAnalyzer()
|
||||
analyzer = TkinterAnalyzer(code)
|
||||
analyzer.visit(tree)
|
||||
grid_analyzer = GridLayoutAnalyzer()
|
||||
widgets = grid_analyzer.analyze_grid_layout(analyzer.widgets)
|
||||
@@ -18,6 +18,7 @@ def parse_tkinter_code(code: str) -> Dict[str, Any]:
|
||||
'widgets': widgets,
|
||||
'command_callbacks': analyzer.command_callbacks,
|
||||
'bind_events': analyzer.bind_events,
|
||||
'methods': analyzer.methods,
|
||||
'success': True
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
try:
|
||||
from tk_ast.parser import parse_tkinter_code, parse_file
|
||||
except Exception as e:
|
||||
except ImportError as e:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Failed to import tk_ast package: {e}. Ensure 'tk_ast' exists next to this script."
|
||||
)
|
||||
@@ -14,8 +18,8 @@ except Exception as e:
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1:
|
||||
print(parse_file(sys.argv[1]))
|
||||
sys.stdout.write(str(parse_file(sys.argv[1])) + '\n')
|
||||
else:
|
||||
code = sys.stdin.read()
|
||||
result = parse_tkinter_code(code)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
sys.stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + '\n')
|
||||
+4
-16
@@ -1,21 +1,9 @@
|
||||
import { WidgetType, WIDGET_DIMENSIONS } from '../constants';
|
||||
|
||||
export function getDefaultWidth(type: string): number {
|
||||
const defaults: { [key: string]: number } = {
|
||||
Label: 100,
|
||||
Button: 80,
|
||||
Text: 200,
|
||||
Checkbutton: 100,
|
||||
Radiobutton: 100,
|
||||
};
|
||||
return defaults[type] || 100;
|
||||
return WIDGET_DIMENSIONS[type as WidgetType]?.width || WIDGET_DIMENSIONS.DEFAULT.width;
|
||||
}
|
||||
|
||||
export function getDefaultHeight(type: string): number {
|
||||
const defaults: { [key: string]: number } = {
|
||||
Label: 25,
|
||||
Button: 30,
|
||||
Text: 100,
|
||||
Checkbutton: 25,
|
||||
Radiobutton: 25,
|
||||
};
|
||||
return defaults[type] || 25;
|
||||
return WIDGET_DIMENSIONS[type as WidgetType]?.height || WIDGET_DIMENSIONS.DEFAULT.height;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { Uri } from 'vscode';
|
||||
import { runPythonAst } from '../parser/pythonRunner';
|
||||
import { DesignData } from '../generator/types';
|
||||
import { WebviewMessage } from './react/types';
|
||||
import { ProjectIO } from './projectIO';
|
||||
|
||||
export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'tkinter-designer';
|
||||
public static _instance: TkinterDesignerProvider | undefined;
|
||||
private _view?: vscode.WebviewPanel;
|
||||
private _designData: any = {
|
||||
private _view?: vscode.WebviewPanel | vscode.WebviewView;
|
||||
private _designData: DesignData = {
|
||||
widgets: [],
|
||||
events: [],
|
||||
form: { title: 'My App', size: { width: 800, height: 600 } },
|
||||
form: { name: 'App', title: 'My App', size: { width: 800, height: 600 } },
|
||||
};
|
||||
|
||||
constructor(private readonly _extensionUri: vscode.Uri) {}
|
||||
|
||||
public static createOrShow(extensionUri: vscode.Uri) {
|
||||
const column = vscode.window.activeTextEditor
|
||||
? vscode.window.activeTextEditor.viewColumn
|
||||
: undefined;
|
||||
if (TkinterDesignerProvider._instance?._view) {
|
||||
console.log('[Webview] Revealing existing panel');
|
||||
TkinterDesignerProvider._instance._view.reveal(column);
|
||||
return TkinterDesignerProvider._instance;
|
||||
}
|
||||
|
||||
console.log('[Webview] Creating new panel');
|
||||
public static createNew(extensionUri: vscode.Uri): TkinterDesignerProvider {
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
TkinterDesignerProvider.viewType,
|
||||
'Tkinter Designer',
|
||||
column || vscode.ViewColumn.One,
|
||||
vscode.ViewColumn.Beside,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
@@ -40,24 +32,17 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
],
|
||||
}
|
||||
);
|
||||
if (!TkinterDesignerProvider._instance) {
|
||||
TkinterDesignerProvider._instance = new TkinterDesignerProvider(
|
||||
extensionUri
|
||||
);
|
||||
}
|
||||
|
||||
TkinterDesignerProvider._instance._view = panel;
|
||||
TkinterDesignerProvider._instance._setWebviewContent(panel.webview);
|
||||
TkinterDesignerProvider._instance._setupMessageHandling(panel.webview);
|
||||
const provider = new TkinterDesignerProvider(extensionUri);
|
||||
provider._view = panel;
|
||||
provider._setWebviewContent(panel.webview);
|
||||
provider._setupMessageHandling(panel.webview);
|
||||
|
||||
panel.onDidDispose(() => {
|
||||
console.log('[Webview] Panel disposed');
|
||||
if (TkinterDesignerProvider._instance) {
|
||||
TkinterDesignerProvider._instance._view = undefined;
|
||||
}
|
||||
provider._view = undefined;
|
||||
});
|
||||
|
||||
return TkinterDesignerProvider._instance;
|
||||
return provider;
|
||||
}
|
||||
|
||||
public resolveWebviewView(
|
||||
@@ -65,7 +50,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
context: vscode.WebviewViewResolveContext,
|
||||
_token: vscode.CancellationToken
|
||||
) {
|
||||
this._view = webviewView as any;
|
||||
this._view = webviewView;
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [
|
||||
@@ -85,8 +70,7 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
|
||||
private _setupMessageHandling(webview: vscode.Webview) {
|
||||
webview.onDidReceiveMessage((message) => {
|
||||
console.log('[Webview] Message:', message.type);
|
||||
webview.onDidReceiveMessage(async (message) => {
|
||||
switch (message.type) {
|
||||
case 'designUpdated':
|
||||
this._designData = message.data;
|
||||
@@ -107,12 +91,13 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
type: 'loadDesign',
|
||||
data: this._designData,
|
||||
});
|
||||
console.log('[Webview] Sent loadDesign');
|
||||
}
|
||||
break;
|
||||
case 'generateCode':
|
||||
console.log('[Webview] Generating code from webview');
|
||||
this.handleGenerateCode(message.data);
|
||||
await this.handleGenerateCode(message.data);
|
||||
break;
|
||||
case 'applyChanges':
|
||||
await this.handleApplyChanges(message.data);
|
||||
break;
|
||||
case 'showInfo':
|
||||
vscode.window.showInformationMessage(message.text);
|
||||
@@ -120,6 +105,15 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
case 'showError':
|
||||
vscode.window.showErrorMessage(message.text);
|
||||
break;
|
||||
case 'exportProject':
|
||||
await ProjectIO.exportProject(message.data);
|
||||
break;
|
||||
case 'importProject':
|
||||
const data = await ProjectIO.importProject();
|
||||
if (data) {
|
||||
this.loadDesignData(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, undefined);
|
||||
}
|
||||
@@ -136,67 +130,260 @@ export class TkinterDesignerProvider implements vscode.WebviewViewProvider {
|
||||
type: 'loadDesign',
|
||||
data: this._designData,
|
||||
});
|
||||
console.log('[Webview] loadDesign posted');
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGenerateCode(designData: any): Promise<void> {
|
||||
try {
|
||||
console.log('[GenerateCode] Start');
|
||||
const { CodeGenerator } = await import(
|
||||
'../generator/CodeGenerator'
|
||||
'../generator/codeGenerator'
|
||||
);
|
||||
const generator = new CodeGenerator();
|
||||
const pythonCode = generator.generateTkinterCode(designData);
|
||||
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (activeEditor && activeEditor.document.languageId === 'python') {
|
||||
console.log('[GenerateCode] Writing into active editor');
|
||||
const doc = activeEditor.document;
|
||||
const start = new vscode.Position(0, 0);
|
||||
const end = doc.lineCount
|
||||
? doc.lineAt(doc.lineCount - 1).range.end
|
||||
: start;
|
||||
const fullRange = new vscode.Range(start, end);
|
||||
await activeEditor.edit((editBuilder) => {
|
||||
editBuilder.replace(fullRange, pythonCode);
|
||||
});
|
||||
await doc.save();
|
||||
vscode.window.showInformationMessage(
|
||||
'Python code generated into the active file'
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage(
|
||||
'No workspace folder is open. Please open a folder first.'
|
||||
);
|
||||
} else {
|
||||
console.log('[GenerateCode] Creating new file');
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage(
|
||||
'No workspace folder is open. Please open a folder first.'
|
||||
);
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
const formName = designData.form.name || 'Form1';
|
||||
let fileName = `${formName}.py`;
|
||||
let fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||
let counter = 1;
|
||||
|
||||
const exists = async (u: vscode.Uri) => {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(u);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const fileName = `app_${Date.now()}.py`;
|
||||
const filePath = path.join(
|
||||
workspaceFolder.uri.fsPath,
|
||||
fileName
|
||||
};
|
||||
|
||||
while (await exists(fileUri)) {
|
||||
fileName = `${formName}_${counter}.py`;
|
||||
fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||
counter++;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const fileBytes = encoder.encode(pythonCode);
|
||||
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
preview: false,
|
||||
});
|
||||
vscode.window.showInformationMessage(
|
||||
`Python file created: ${fileName}`
|
||||
);
|
||||
|
||||
const newFormName = path.basename(fileName, '.py');
|
||||
if (newFormName !== formName) {
|
||||
if (this._view) {
|
||||
const webview = (this._view as any).webview || this._view;
|
||||
webview.postMessage({
|
||||
type: 'updateFormName',
|
||||
name: newFormName,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(`Error generating code: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApplyChanges(designData: DesignData): Promise<void> {
|
||||
try {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage(
|
||||
'No workspace folder is open. Please open a folder first.'
|
||||
);
|
||||
const fileUri = Uri.file(filePath);
|
||||
const encoder = new TextEncoder();
|
||||
const fileBytes = encoder.encode(pythonCode);
|
||||
await vscode.workspace.fs.writeFile(fileUri, fileBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
const formName = designData.form.name || 'Form1';
|
||||
const fileName = `${formName}.py`;
|
||||
const fileUri = vscode.Uri.joinPath(workspaceFolder.uri, fileName);
|
||||
|
||||
let existingMethods: any = {};
|
||||
let oldClassName = '';
|
||||
let fileContent = '';
|
||||
let fileExists = false;
|
||||
|
||||
try {
|
||||
await vscode.workspace.fs.stat(fileUri);
|
||||
fileExists = true;
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
preview: false,
|
||||
});
|
||||
fileContent = doc.getText();
|
||||
const astResult = await runPythonAst(fileContent);
|
||||
|
||||
if (astResult && !('error' in astResult)) {
|
||||
if (astResult.methods) {
|
||||
existingMethods = astResult.methods as Record<string, any>;
|
||||
if (designData.events) {
|
||||
for (const event of designData.events) {
|
||||
if (existingMethods[event.name]) {
|
||||
const methodData = existingMethods[event.name];
|
||||
if (typeof methodData === 'object' && methodData !== null) {
|
||||
event.code = methodData.body;
|
||||
event.signature = methodData.signature;
|
||||
} else if (typeof methodData === 'string') {
|
||||
event.code = methodData;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (astResult.window && astResult.window.className) {
|
||||
oldClassName = astResult.window.className;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
const { CodeGenerator } = await import(
|
||||
'../generator/codeGenerator'
|
||||
);
|
||||
const generator = new CodeGenerator();
|
||||
|
||||
if (!fileExists) {
|
||||
const pythonCode = generator.generateTkinterCode(designData);
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(
|
||||
fileUri,
|
||||
encoder.encode(pythonCode)
|
||||
);
|
||||
vscode.window.showInformationMessage(
|
||||
`Python file created: ${fileName}`
|
||||
`Created new file: ${fileName}`
|
||||
);
|
||||
} else {
|
||||
const newCreateWidgets =
|
||||
generator.generateCreateWidgetsBody(designData);
|
||||
|
||||
const createWidgetsRegex =
|
||||
/(def\s+create_widgets\s*\(\s*self[^)]*\)\s*(?:->\s*[^:]+)?\s*:)([\s\S]*?)(?=\n\s*def\s|\n\s*if\s+__name__|\Z)/;
|
||||
|
||||
let newFileContent = fileContent;
|
||||
|
||||
if (createWidgetsRegex.test(fileContent)) {
|
||||
newFileContent = fileContent.replace(
|
||||
createWidgetsRegex,
|
||||
(match, defLine) => {
|
||||
return `${defLine}\n${newCreateWidgets}\n`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
vscode.window.showWarningMessage(
|
||||
'Could not find create_widgets method to update. Regenerating full file might be needed if structure is broken.'
|
||||
);
|
||||
}
|
||||
|
||||
if (designData.events) {
|
||||
const methodsToInject: string[] = [];
|
||||
for (const event of designData.events) {
|
||||
if (!existingMethods[event.name]) {
|
||||
methodsToInject.push(
|
||||
generator.generateEventHandler(event)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (methodsToInject.length > 0) {
|
||||
const runRegex = /\s*def\s+run\s*\(/;
|
||||
if (runRegex.test(newFileContent)) {
|
||||
newFileContent = newFileContent.replace(
|
||||
runRegex,
|
||||
(match) => {
|
||||
return `\n${methodsToInject.join('\n\n')}\n\n${match}`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const mainRegex = /if\s+__name__\s*==/;
|
||||
if (mainRegex.test(newFileContent)) {
|
||||
newFileContent = newFileContent.replace(
|
||||
mainRegex,
|
||||
(match) => {
|
||||
return `${methodsToInject.join('\n\n')}\n\n${match}`;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
newFileContent += `\n\n${methodsToInject.join('\n\n')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const titleRegex = /self\.root\.title\s*\(\s*["'].*?["']\s*\)/;
|
||||
newFileContent = newFileContent.replace(
|
||||
titleRegex,
|
||||
`self.root.title("${designData.form.title}")`
|
||||
);
|
||||
|
||||
const geoRegex = /self\.root\.geometry\s*\(\s*["'].*?["']\s*\)/;
|
||||
newFileContent = newFileContent.replace(
|
||||
geoRegex,
|
||||
`self.root.geometry("${designData.form.size.width}x${designData.form.size.height}")`
|
||||
);
|
||||
|
||||
if (
|
||||
oldClassName &&
|
||||
designData.form.className &&
|
||||
oldClassName !== designData.form.className
|
||||
) {
|
||||
const classDefRegex = new RegExp(
|
||||
`class\\s+${oldClassName}\\s*:`
|
||||
);
|
||||
newFileContent = newFileContent.replace(
|
||||
classDefRegex,
|
||||
`class ${designData.form.className}:`
|
||||
);
|
||||
|
||||
const instanceRegex = new RegExp(
|
||||
`=\\s*${oldClassName}\\s*\\(`
|
||||
);
|
||||
newFileContent = newFileContent.replace(
|
||||
instanceRegex,
|
||||
`= ${designData.form.className}(`
|
||||
);
|
||||
}
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(fileUri);
|
||||
const editor = vscode.window.visibleTextEditors.find(
|
||||
(e) => e.document.uri.toString() === fileUri.toString()
|
||||
);
|
||||
|
||||
if (editor) {
|
||||
const fullRange = doc.validateRange(
|
||||
new vscode.Range(
|
||||
0,
|
||||
0,
|
||||
Number.MAX_VALUE,
|
||||
Number.MAX_VALUE
|
||||
)
|
||||
);
|
||||
await editor.edit((editBuilder) => {
|
||||
editBuilder.replace(fullRange, newFileContent);
|
||||
});
|
||||
await doc.save();
|
||||
} else {
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(
|
||||
fileUri,
|
||||
encoder.encode(newFileContent)
|
||||
);
|
||||
}
|
||||
vscode.window.showInformationMessage(
|
||||
`Smart updated ${fileName}`
|
||||
);
|
||||
}
|
||||
console.log('[GenerateCode] Done');
|
||||
} catch (error) {
|
||||
console.error('[GenerateCode] Error:', error);
|
||||
vscode.window.showErrorMessage(`Error generating code: ${error}`);
|
||||
vscode.window.showErrorMessage(`Error applying changes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
-12
@@ -5,23 +5,26 @@ import { PropertiesPanel } from './components/PropertiesPanel';
|
||||
import { EventsPanel } from './components/EventsPanel';
|
||||
import { Canvas } from './components/Canvas';
|
||||
import { useMessaging } from './useMessaging';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
||||
export function App() {
|
||||
useMessaging();
|
||||
return (
|
||||
<div className="container">
|
||||
<Toolbar />
|
||||
<div className="main-content">
|
||||
<div className="sidebar">
|
||||
<h3>Widgets</h3>
|
||||
<Palette />
|
||||
<PropertiesPanel />
|
||||
<EventsPanel />
|
||||
</div>
|
||||
<div className="design-area">
|
||||
<Canvas />
|
||||
<ErrorBoundary>
|
||||
<div className="container">
|
||||
<Toolbar />
|
||||
<div className="main-content">
|
||||
<div className="sidebar">
|
||||
<h3>Widgets</h3>
|
||||
<Palette />
|
||||
<PropertiesPanel />
|
||||
<EventsPanel />
|
||||
</div>
|
||||
<div className="design-area">
|
||||
<Canvas />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,69 @@ import React, { useRef } from 'react';
|
||||
import { useAppDispatch, useAppState } from '../state';
|
||||
import type { WidgetType } from '../types';
|
||||
|
||||
import { Widget } from '../types';
|
||||
|
||||
function renderWidgetContent(w: Widget) {
|
||||
switch (w.type) {
|
||||
case 'Label':
|
||||
return (
|
||||
<div className="widget-label-content">
|
||||
{w.properties?.text || 'Label'}
|
||||
</div>
|
||||
);
|
||||
case 'Button':
|
||||
return (
|
||||
<div className="widget-button-content">
|
||||
{w.properties?.text || 'Button'}
|
||||
</div>
|
||||
);
|
||||
case 'Entry':
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="Entry"
|
||||
className="widget-entry-content"
|
||||
/>
|
||||
);
|
||||
case 'Text':
|
||||
return (
|
||||
<textarea
|
||||
disabled
|
||||
className="widget-text-content"
|
||||
>
|
||||
Text Area
|
||||
</textarea>
|
||||
);
|
||||
case 'Checkbutton':
|
||||
return (
|
||||
<div className="widget-check-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
checked
|
||||
className="widget-check-input"
|
||||
/>
|
||||
<span className="widget-check-text">{w.properties?.text || 'Check'}</span>
|
||||
</div>
|
||||
);
|
||||
case 'Radiobutton':
|
||||
return (
|
||||
<div className="widget-radio-content">
|
||||
<input
|
||||
type="radio"
|
||||
disabled
|
||||
checked
|
||||
className="widget-check-input"
|
||||
/>
|
||||
<span className="widget-check-text">{w.properties?.text || 'Radio'}</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return w.properties?.text || w.type;
|
||||
}
|
||||
}
|
||||
|
||||
export function Canvas() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { design, selectedWidgetId } = useAppState();
|
||||
@@ -17,12 +80,10 @@ export function Canvas() {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const x = e.clientX - (rect?.left || 0);
|
||||
const y = e.clientY - (rect?.top || 0);
|
||||
console.log('[Canvas] Drop widget', type, 'at', x, y);
|
||||
if (type) dispatch({ type: 'addWidget', payload: { type, x, y } });
|
||||
};
|
||||
|
||||
const onSelect = (id: string | null) => {
|
||||
console.log('[Canvas] Select widget', id);
|
||||
dispatch({ type: 'selectWidget', payload: { id } });
|
||||
};
|
||||
|
||||
@@ -34,19 +95,126 @@ export function Canvas() {
|
||||
if (!w) return;
|
||||
const initX = w.x;
|
||||
const initY = w.y;
|
||||
console.log('[Canvas] Drag start', id, 'at', initX, initY);
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
const newX = initX + dx;
|
||||
const newY = initY + dy;
|
||||
dispatch({
|
||||
type: 'updateWidget',
|
||||
payload: { id, patch: { x: initX + dx, y: initY + dy } },
|
||||
payload: { id, patch: { x: newX, y: newY } },
|
||||
});
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
console.log('[Canvas] Drag end', id);
|
||||
dispatch({ type: 'pushHistory' });
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const onResizeStart = (
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
id: string,
|
||||
direction: string
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const w = design.widgets.find((w) => w.id === id);
|
||||
if (!w) return;
|
||||
const initX = w.x;
|
||||
const initY = w.y;
|
||||
const initWidth = w.width;
|
||||
const initHeight = w.height;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
const patch: Partial<Widget> = {};
|
||||
|
||||
if (direction.includes('e')) {
|
||||
patch.width = Math.max(10, initWidth + dx);
|
||||
}
|
||||
if (direction.includes('w')) {
|
||||
const newWidth = Math.max(10, initWidth - dx);
|
||||
patch.width = newWidth;
|
||||
patch.x = initX + (initWidth - newWidth);
|
||||
}
|
||||
if (direction.includes('s')) {
|
||||
patch.height = Math.max(10, initHeight + dy);
|
||||
}
|
||||
if (direction.includes('n')) {
|
||||
const newHeight = Math.max(10, initHeight - dy);
|
||||
patch.height = newHeight;
|
||||
patch.y = initY + (initHeight - newHeight);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'updateWidget',
|
||||
payload: { id, patch },
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
dispatch({ type: 'pushHistory' });
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const onWindowResizeStart = (
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
direction: string
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const initWidth = design.form.size.width;
|
||||
const initHeight = design.form.size.height;
|
||||
|
||||
let animationFrameId: number | null = null;
|
||||
let currentEv: MouseEvent | null = null;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
currentEv = ev;
|
||||
|
||||
if (animationFrameId) return;
|
||||
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
if (!currentEv) return;
|
||||
|
||||
const dx = currentEv.clientX - startX;
|
||||
const dy = currentEv.clientY - startY;
|
||||
|
||||
let newWidth = initWidth;
|
||||
let newHeight = initHeight;
|
||||
|
||||
if (direction.includes('e')) {
|
||||
newWidth = Math.max(100, initWidth + dx);
|
||||
}
|
||||
if (direction.includes('s')) {
|
||||
newHeight = Math.max(100, initHeight + dy);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { size: { width: newWidth, height: newHeight } },
|
||||
});
|
||||
|
||||
animationFrameId = null;
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
dispatch({ type: 'pushHistory' });
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
@@ -54,41 +222,123 @@ export function Canvas() {
|
||||
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
<div
|
||||
id="designCanvas"
|
||||
className="design-canvas"
|
||||
ref={containerRef}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
{design.widgets.length === 0 && (
|
||||
<div className="canvas-placeholder">
|
||||
<p>Drag widgets here to start designing</p>
|
||||
<div className="window-frame">
|
||||
<div className="window-title-bar">
|
||||
<div className="window-title">
|
||||
{design.form?.title || 'Tkinter App'}
|
||||
</div>
|
||||
)}
|
||||
{design.widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: w.x,
|
||||
top: w.y,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(w.id);
|
||||
}}
|
||||
onMouseDown={(e) => onMouseDown(e, w.id)}
|
||||
>
|
||||
<div className="widget-content">
|
||||
{w.properties?.text || w.type}
|
||||
<div className="window-controls">
|
||||
<div className="window-control minimize"></div>
|
||||
<div className="window-control maximize"></div>
|
||||
<div className="window-control close"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="designCanvas"
|
||||
className="design-canvas"
|
||||
style={{
|
||||
'--canvas-width': `${design.form.size.width}px`,
|
||||
'--canvas-height': `${design.form.size.height}px`,
|
||||
} as React.CSSProperties}
|
||||
ref={containerRef}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
{design.widgets.length === 0 && (
|
||||
<div className="canvas-placeholder">
|
||||
<p>Drag widgets here to start designing</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{design.widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`canvas-widget widget-${w.type.toLowerCase()}${selectedWidgetId === w.id ? ' selected' : ''}`}
|
||||
style={{
|
||||
'--widget-x': `${w.x}px`,
|
||||
'--widget-y': `${w.y}px`,
|
||||
'--widget-width': `${w.width}px`,
|
||||
'--widget-height': `${w.height}px`,
|
||||
} as React.CSSProperties}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(w.id);
|
||||
}}
|
||||
onMouseDown={(e) => onMouseDown(e, w.id)}
|
||||
>
|
||||
<div className="widget-content">
|
||||
{renderWidgetContent(w)}
|
||||
</div>
|
||||
{selectedWidgetId === w.id && (
|
||||
<>
|
||||
<div
|
||||
className="resize-handle n"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'n')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle s"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 's')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle e"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'e')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle w"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'w')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle ne"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'ne')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle nw"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'nw')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle se"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'se')
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle sw"
|
||||
onMouseDown={(e) =>
|
||||
onResizeStart(e, w.id, 'sw')
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="window-resize-handle e"
|
||||
onMouseDown={(e) => onWindowResizeStart(e, 'e')}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
<div
|
||||
className="window-resize-handle s"
|
||||
onMouseDown={(e) => onWindowResizeStart(e, 's')}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
<div
|
||||
className="window-resize-handle se"
|
||||
onMouseDown={(e) => onWindowResizeStart(e, 'se')}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 { design, selectedWidgetId, vscode } = useAppState();
|
||||
const [eventType, setEventType] = useState('command');
|
||||
const [eventName, setEventName] = useState('onClick');
|
||||
const [eventCode, setEventCode] = useState('print("clicked")');
|
||||
const [eventName, setEventName] = useState('on_click');
|
||||
|
||||
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||
const widgetEvents = (id: string | undefined) =>
|
||||
@@ -20,10 +19,17 @@ export function EventsPanel() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!eventType || !eventName || !eventCode) {
|
||||
if (!eventName) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'Please fill in all fields: event type, name, and code',
|
||||
text: 'Please fill in handler name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!eventType) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'Please fill in event type',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -33,21 +39,39 @@ export function EventsPanel() {
|
||||
widget: w.id,
|
||||
type: eventType,
|
||||
name: eventName,
|
||||
code: eventCode,
|
||||
code: 'pass',
|
||||
},
|
||||
});
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: `Event added: ${eventType} -> ${eventName}`,
|
||||
text: `Event added: ${eventName}`,
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (type: string) => {
|
||||
const remove = (type: string, name: string) => {
|
||||
if (!w) return;
|
||||
dispatch({ type: 'removeEvent', payload: { widget: w.id, type } });
|
||||
dispatch({
|
||||
type: 'removeEvent',
|
||||
payload: { widget: w.id, type, name },
|
||||
});
|
||||
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
|
||||
};
|
||||
|
||||
const commonEvents = [
|
||||
'command',
|
||||
'<Button-1>',
|
||||
'<Button-2>',
|
||||
'<Button-3>',
|
||||
'<Double-Button-1>',
|
||||
'<Enter>',
|
||||
'<Leave>',
|
||||
'<FocusIn>',
|
||||
'<FocusOut>',
|
||||
'<Return>',
|
||||
'<Key>',
|
||||
'<Configure>',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="events-panel">
|
||||
<h3>Events & Commands</h3>
|
||||
@@ -60,12 +84,41 @@ export function EventsPanel() {
|
||||
<label className="property-label">
|
||||
Event Type:
|
||||
</label>
|
||||
<input
|
||||
className="event-type-select"
|
||||
type="text"
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
/>
|
||||
<div className="mb-8">
|
||||
<select
|
||||
className="event-handler-input mb-4"
|
||||
value={
|
||||
commonEvents.includes(eventType)
|
||||
? eventType
|
||||
: 'custom'
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.value !== 'custom') {
|
||||
setEventType(e.target.value);
|
||||
} else {
|
||||
setEventType('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{commonEvents.map((evt) => (
|
||||
<option key={evt} value={evt}>
|
||||
{evt}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
{!commonEvents.includes(eventType) && (
|
||||
<input
|
||||
className="event-handler-input"
|
||||
type="text"
|
||||
placeholder="Enter custom event type"
|
||||
value={eventType}
|
||||
onChange={(e) =>
|
||||
setEventType(e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="property-label">
|
||||
Handler Name:
|
||||
</label>
|
||||
@@ -75,14 +128,6 @@ export function EventsPanel() {
|
||||
value={eventName}
|
||||
onChange={(e) => setEventName(e.target.value)}
|
||||
/>
|
||||
<label className="property-label">
|
||||
Handler Code:
|
||||
</label>
|
||||
<textarea
|
||||
className="event-handler-input"
|
||||
value={eventCode}
|
||||
onChange={(e) => setEventCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="event-buttons">
|
||||
<button className="event-btn primary" onClick={add}>
|
||||
@@ -97,23 +142,26 @@ export function EventsPanel() {
|
||||
widgetEvents(w.id).map((ev) => (
|
||||
<div
|
||||
className="event-item"
|
||||
key={`${ev.widget}-${ev.type}`}
|
||||
key={`${ev.widget}-${ev.type}-${ev.name}`}
|
||||
>
|
||||
<div className="event-info">
|
||||
<span className="event-name">
|
||||
<span
|
||||
className="event-name event-label-bold"
|
||||
>
|
||||
{ev.type}:
|
||||
</span>
|
||||
<span
|
||||
className="event-name event-value-margin"
|
||||
>
|
||||
{ev.name}
|
||||
</span>{' '}
|
||||
<span className="event-handler">
|
||||
{ev.type}
|
||||
</span>
|
||||
<div className="event-code">
|
||||
{ev.code}
|
||||
</div>
|
||||
</div>
|
||||
<div className="event-actions">
|
||||
<button
|
||||
className="event-btn secondary"
|
||||
onClick={() => remove(ev.type)}
|
||||
onClick={() =>
|
||||
remove(ev.type, ev.name)
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WidgetType } from '../types';
|
||||
const WIDGETS: WidgetType[] = [
|
||||
'Label',
|
||||
'Button',
|
||||
'Entry',
|
||||
'Text',
|
||||
'Checkbutton',
|
||||
'Radiobutton',
|
||||
@@ -15,7 +16,6 @@ export function Palette() {
|
||||
e: React.DragEvent<HTMLDivElement>,
|
||||
type: WidgetType
|
||||
) => {
|
||||
console.log('[Palette] Drag start', type);
|
||||
e.dataTransfer.setData('text/plain', type);
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,88 @@
|
||||
import React from 'react';
|
||||
import { useAppDispatch, useAppState } from '../state';
|
||||
|
||||
import { Widget } from '../types';
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { design, selectedWidgetId } = useAppState();
|
||||
const updateForm = <K extends keyof typeof design.form>(key: K, value: typeof design.form[K]) => {
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedWidgetId === null) {
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<h3>Form Properties</h3>
|
||||
<div className="properties-form">
|
||||
<div className="property-row">
|
||||
<label className="property-label">Title:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="text"
|
||||
value={design.form.title}
|
||||
onChange={(e) =>
|
||||
updateForm('title', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label className="property-label">Class Name:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="text"
|
||||
value={design.form.className || 'Application'}
|
||||
onChange={(e) =>
|
||||
updateForm('className', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label className="property-label">Width:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="number"
|
||||
value={design.form.size.width}
|
||||
onChange={(e) =>
|
||||
updateForm('size', {
|
||||
...design.form.size,
|
||||
width: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label className="property-label">Height:</label>
|
||||
<input
|
||||
className="property-input"
|
||||
type="number"
|
||||
value={design.form.size.height}
|
||||
onChange={(e) =>
|
||||
updateForm('size', {
|
||||
...design.form.size,
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||
|
||||
const update = (patch: Partial<typeof w>) => {
|
||||
const update = (patch: Partial<Widget>) => {
|
||||
if (!w) return;
|
||||
dispatch({
|
||||
type: 'updateWidget',
|
||||
payload: { id: w.id, patch: patch as any },
|
||||
payload: { id: w.id, patch },
|
||||
});
|
||||
};
|
||||
const updateProp = (key: string, value: any) => {
|
||||
const updateProp = (key: string, value: string | number | boolean) => {
|
||||
if (!w) return;
|
||||
dispatch({
|
||||
type: 'updateProps',
|
||||
@@ -27,7 +96,7 @@ export function PropertiesPanel() {
|
||||
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<h3>Properties</h3>
|
||||
<h3>Widget Properties</h3>
|
||||
<div id="propertiesContent">
|
||||
{!w ? (
|
||||
<p>Select a widget to edit properties</p>
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useAppDispatch, useAppState } from '../state';
|
||||
|
||||
export function Toolbar() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { vscode, design } = useAppState();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const exportProject = () => {
|
||||
try {
|
||||
console.log('[Toolbar] Export project');
|
||||
const exportData = {
|
||||
version: '1.0',
|
||||
created: new Date().toISOString(),
|
||||
description: 'Tkinter Designer Project',
|
||||
data: design,
|
||||
};
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `tkinter-project-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: 'Project exported successfully!',
|
||||
type: 'exportProject',
|
||||
data: exportData,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Export error:', error);
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: `Export failed: ${error.message}`,
|
||||
@@ -38,41 +25,13 @@ export function Toolbar() {
|
||||
}
|
||||
};
|
||||
|
||||
const importProject = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log('[Toolbar] Import project');
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const jsonString = ev.target?.result as string;
|
||||
const obj = JSON.parse(jsonString);
|
||||
if (!obj || !obj.data)
|
||||
throw new Error('Invalid project file format');
|
||||
dispatch({ type: 'init', payload: obj.data });
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: 'Project imported successfully!',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Import error:', error);
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: `Import failed: ${error.message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.onerror = () =>
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'Failed to read file',
|
||||
});
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
const importProject = () => {
|
||||
vscode.postMessage({
|
||||
type: 'importProject',
|
||||
});
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
console.log('[Toolbar] Clear all');
|
||||
dispatch({ type: 'clear' });
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
@@ -81,7 +40,6 @@ export function Toolbar() {
|
||||
};
|
||||
|
||||
const generateCode = () => {
|
||||
console.log('[Toolbar] Generate code');
|
||||
if (design.widgets.length === 0) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
@@ -96,18 +54,42 @@ export function Toolbar() {
|
||||
vscode.postMessage({ type: 'generateCode', data: design });
|
||||
};
|
||||
|
||||
const applyChanges = () => {
|
||||
if (design.widgets.length === 0) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'No widgets to apply changes!',
|
||||
});
|
||||
return;
|
||||
}
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: 'Applying changes...',
|
||||
});
|
||||
vscode.postMessage({ type: 'applyChanges', data: design });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="importFileInput"
|
||||
accept=".json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={importProject}
|
||||
/>
|
||||
<h2>Tkinter Visual Designer</h2>
|
||||
<div className="toolbar-buttons">
|
||||
<div className="toolbar-group">
|
||||
<label htmlFor="formName" className="toolbar-label">
|
||||
Form Name:
|
||||
</label>
|
||||
<input
|
||||
id="formName"
|
||||
type="text"
|
||||
value={design.form.name || 'Form1'}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { name: e.target.value },
|
||||
})
|
||||
}
|
||||
className="toolbar-input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="undoBtn"
|
||||
className="btn btn-outline"
|
||||
@@ -137,7 +119,7 @@ export function Toolbar() {
|
||||
id="importBtn"
|
||||
className="btn btn-outline"
|
||||
title="Import project from JSON"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={importProject}
|
||||
>
|
||||
📥 Import
|
||||
</button>
|
||||
@@ -149,6 +131,13 @@ export function Toolbar() {
|
||||
>
|
||||
Generate Code
|
||||
</button>
|
||||
<button
|
||||
id="applyBtn"
|
||||
className="btn btn-primary"
|
||||
onClick={applyChanges}
|
||||
>
|
||||
Apply Changes
|
||||
</button>
|
||||
<button
|
||||
id="clearBtn"
|
||||
className="btn btn-secondary"
|
||||
|
||||
@@ -12,7 +12,7 @@ declare global {
|
||||
const vscode =
|
||||
typeof window.acquireVsCodeApi === 'function'
|
||||
? window.acquireVsCodeApi()
|
||||
: { postMessage: (msg: any) => console.log('[Webview message]', msg) };
|
||||
: { postMessage: (msg: any) => {} };
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
|
||||
+202
-154
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useContext, useReducer, useMemo } from 'react';
|
||||
import { produce } from 'immer';
|
||||
import type { DesignData, Widget, WidgetType, EventBinding } from './types';
|
||||
|
||||
export interface AppState {
|
||||
@@ -20,180 +21,223 @@ type Action =
|
||||
}
|
||||
| { type: 'deleteWidget'; payload: { id: string } }
|
||||
| { type: 'addEvent'; payload: EventBinding }
|
||||
| { type: 'removeEvent'; payload: { widget: string; type: string } }
|
||||
| {
|
||||
type: 'removeEvent';
|
||||
payload: { widget: string; type: string; name?: string };
|
||||
}
|
||||
| { type: 'clear' }
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' }
|
||||
| {
|
||||
type: 'setForm';
|
||||
payload: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
size?: { width: number; height: number };
|
||||
className?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
| { type: 'pushHistory' };
|
||||
|
||||
const AppStateContext = createContext<AppState | undefined>(undefined);
|
||||
const AppDispatchContext = createContext<React.Dispatch<Action> | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
function clone(data: DesignData): DesignData {
|
||||
return JSON.parse(JSON.stringify(data));
|
||||
// Helper to check if two design states are effectively different to warrant a history entry
|
||||
// This helps prevent spamming history with tiny drag updates if we were to push every frame (though we usually push on mouse up)
|
||||
function hasDesignChanged(prev: DesignData, next: DesignData): boolean {
|
||||
return JSON.stringify(prev) !== JSON.stringify(next);
|
||||
}
|
||||
|
||||
function reducer(state: AppState, action: Action): AppState {
|
||||
console.log('[State] Action:', action.type);
|
||||
switch (action.type) {
|
||||
case 'init': {
|
||||
const next = {
|
||||
...state,
|
||||
design: action.payload,
|
||||
selectedWidgetId: null,
|
||||
};
|
||||
if (!next.design.events) next.design.events = [];
|
||||
console.log('[State] Init design widgets:', next.design.widgets.length);
|
||||
return pushHistory(next);
|
||||
}
|
||||
case 'addWidget': {
|
||||
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
const w: Widget = {
|
||||
id,
|
||||
type: action.payload.type,
|
||||
x: action.payload.x,
|
||||
y: action.payload.y,
|
||||
width: 120,
|
||||
height: 40,
|
||||
properties: { text: action.payload.type },
|
||||
};
|
||||
const design = clone(state.design);
|
||||
design.widgets.push(w);
|
||||
const next = { ...state, design, selectedWidgetId: id };
|
||||
console.log('[State] Added widget:', id, w.type);
|
||||
return pushHistory(next);
|
||||
}
|
||||
case 'selectWidget': {
|
||||
console.log('[State] Select widget:', action.payload.id);
|
||||
return { ...state, selectedWidgetId: action.payload.id };
|
||||
}
|
||||
case 'updateWidget': {
|
||||
const design = clone(state.design);
|
||||
const idx = design.widgets.findIndex(
|
||||
(w) => w.id === action.payload.id
|
||||
);
|
||||
if (idx >= 0) {
|
||||
design.widgets[idx] = {
|
||||
...design.widgets[idx],
|
||||
...action.payload.patch,
|
||||
};
|
||||
}
|
||||
const next = { ...state, design };
|
||||
console.log('[State] Updated widget:', action.payload.id);
|
||||
return pushHistory(next);
|
||||
}
|
||||
case 'updateProps': {
|
||||
const design = clone(state.design);
|
||||
const idx = design.widgets.findIndex(
|
||||
(w) => w.id === action.payload.id
|
||||
);
|
||||
if (idx >= 0) {
|
||||
design.widgets[idx] = {
|
||||
...design.widgets[idx],
|
||||
properties: {
|
||||
...design.widgets[idx].properties,
|
||||
...action.payload.properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
const next = { ...state, design };
|
||||
console.log('[State] Updated properties for:', action.payload.id);
|
||||
return pushHistory(next);
|
||||
}
|
||||
case 'deleteWidget': {
|
||||
const design = clone(state.design);
|
||||
design.widgets = design.widgets.filter(
|
||||
(w) => w.id !== action.payload.id
|
||||
);
|
||||
if (!design.events) design.events = [];
|
||||
design.events = design.events.filter(
|
||||
(e) => e.widget !== action.payload.id
|
||||
);
|
||||
const next = { ...state, design, selectedWidgetId: null };
|
||||
console.log('[State] Deleted widget:', action.payload.id);
|
||||
return pushHistory(next);
|
||||
}
|
||||
case 'addEvent': {
|
||||
const design = clone(state.design);
|
||||
if (!design.events) design.events = [];
|
||||
const exists = design.events.find(
|
||||
(e) =>
|
||||
e.widget === action.payload.widget &&
|
||||
e.type === action.payload.type
|
||||
);
|
||||
if (!exists) {
|
||||
design.events.push(action.payload);
|
||||
}
|
||||
console.log('[State] Added event:', action.payload.type, 'for', action.payload.widget);
|
||||
return pushHistory({ ...state, design });
|
||||
}
|
||||
case 'removeEvent': {
|
||||
const design = clone(state.design);
|
||||
if (!design.events) design.events = [];
|
||||
design.events = design.events.filter(
|
||||
(e) =>
|
||||
!(
|
||||
e.widget === action.payload.widget &&
|
||||
e.type === action.payload.type
|
||||
)
|
||||
);
|
||||
console.log('[State] Removed event:', action.payload.type, 'for', action.payload.widget);
|
||||
return pushHistory({ ...state, design });
|
||||
}
|
||||
case 'clear': {
|
||||
const design: DesignData = {
|
||||
form: state.design.form,
|
||||
widgets: [],
|
||||
events: [],
|
||||
};
|
||||
console.log('[State] Cleared design');
|
||||
return pushHistory({ ...state, design, selectedWidgetId: null });
|
||||
}
|
||||
case 'undo': {
|
||||
if (state.historyIndex <= 0) return state;
|
||||
const idx = state.historyIndex - 1;
|
||||
console.log('[State] Undo to index:', idx);
|
||||
return {
|
||||
...state,
|
||||
historyIndex: idx,
|
||||
design: clone(state.history[idx]),
|
||||
};
|
||||
}
|
||||
case 'redo': {
|
||||
if (state.historyIndex >= state.history.length - 1) return state;
|
||||
const idx = state.historyIndex + 1;
|
||||
console.log('[State] Redo to index:', idx);
|
||||
return {
|
||||
...state,
|
||||
historyIndex: idx,
|
||||
design: clone(state.history[idx]),
|
||||
};
|
||||
}
|
||||
case 'setForm': {
|
||||
const design = clone(state.design);
|
||||
if (action.payload.title) design.form.title = action.payload.title;
|
||||
if (action.payload.size) design.form.size = action.payload.size;
|
||||
console.log('[State] Set form', action.payload);
|
||||
return pushHistory({ ...state, design });
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
if (action.type === 'undo') {
|
||||
if (state.historyIndex <= 0) return state;
|
||||
const idx = state.historyIndex - 1;
|
||||
return {
|
||||
...state,
|
||||
historyIndex: idx,
|
||||
design: JSON.parse(JSON.stringify(state.history[idx])),
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'redo') {
|
||||
if (state.historyIndex >= state.history.length - 1) return state;
|
||||
const idx = state.historyIndex + 1;
|
||||
return {
|
||||
...state,
|
||||
historyIndex: idx,
|
||||
design: JSON.parse(JSON.stringify(state.history[idx])),
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'pushHistory') {
|
||||
// This action is explicitly called when an operation is "finished" (e.g. onMouseUp after drag)
|
||||
if (state.historyIndex >= 0 && !hasDesignChanged(state.history[state.historyIndex], state.design)) {
|
||||
return state;
|
||||
}
|
||||
return produce(state, (draft) => {
|
||||
pushHistoryDraft(draft);
|
||||
});
|
||||
}
|
||||
|
||||
// For other actions, we update the state but DO NOT push to history automatically
|
||||
// unless it's a discrete action like 'addWidget' or 'deleteWidget'.
|
||||
// Continuous actions like 'updateWidget' (drag) should only update state,
|
||||
// and rely on a subsequent 'pushHistory' or explicit logic to save state.
|
||||
|
||||
// However, to keep backward compatibility with existing components that might not call pushHistory,
|
||||
// we will still push history for "one-off" actions, but we should be careful with high-frequency updates.
|
||||
|
||||
// In the previous implementation, every update pushed history.
|
||||
// We will optimize this by only pushing history for significant actions,
|
||||
// or letting the UI trigger history pushes for drag operations.
|
||||
|
||||
return produce(state, (draft) => {
|
||||
let shouldPushHistory = false;
|
||||
|
||||
switch (action.type) {
|
||||
case 'init': {
|
||||
draft.design = action.payload;
|
||||
draft.selectedWidgetId = null;
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
// Init resets history usually
|
||||
draft.history = [JSON.parse(JSON.stringify(draft.design))];
|
||||
draft.historyIndex = 0;
|
||||
break;
|
||||
}
|
||||
case 'addWidget': {
|
||||
const id = `widget_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
const w: Widget = {
|
||||
id,
|
||||
type: action.payload.type,
|
||||
x: action.payload.x,
|
||||
y: action.payload.y,
|
||||
width: 120,
|
||||
height: 40,
|
||||
properties: { text: action.payload.type },
|
||||
};
|
||||
draft.design.widgets.push(w);
|
||||
draft.selectedWidgetId = id;
|
||||
shouldPushHistory = true;
|
||||
break;
|
||||
}
|
||||
case 'selectWidget': {
|
||||
draft.selectedWidgetId = action.payload.id;
|
||||
// Selection change doesn't need history
|
||||
break;
|
||||
}
|
||||
case 'updateWidget': {
|
||||
const idx = draft.design.widgets.findIndex(
|
||||
(w) => w.id === action.payload.id
|
||||
);
|
||||
if (idx >= 0) {
|
||||
Object.assign(
|
||||
draft.design.widgets[idx],
|
||||
action.payload.patch
|
||||
);
|
||||
}
|
||||
// Don't push history here for every drag frame.
|
||||
// Components should dispatch 'pushHistory' on drag end.
|
||||
break;
|
||||
}
|
||||
case 'updateProps': {
|
||||
const idx = draft.design.widgets.findIndex(
|
||||
(w) => w.id === action.payload.id
|
||||
);
|
||||
if (idx >= 0) {
|
||||
draft.design.widgets[idx].properties = {
|
||||
...draft.design.widgets[idx].properties,
|
||||
...action.payload.properties,
|
||||
};
|
||||
}
|
||||
shouldPushHistory = true;
|
||||
break;
|
||||
}
|
||||
case 'deleteWidget': {
|
||||
draft.design.widgets = draft.design.widgets.filter(
|
||||
(w) => w.id !== action.payload.id
|
||||
);
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
draft.design.events = draft.design.events.filter(
|
||||
(e) => e.widget !== action.payload.id
|
||||
);
|
||||
draft.selectedWidgetId = null;
|
||||
shouldPushHistory = true;
|
||||
break;
|
||||
}
|
||||
case 'addEvent': {
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
const existsIndex = draft.design.events.findIndex(
|
||||
(e) =>
|
||||
e.widget === action.payload.widget &&
|
||||
e.type === action.payload.type &&
|
||||
e.name === action.payload.name
|
||||
);
|
||||
if (existsIndex >= 0) {
|
||||
draft.design.events[existsIndex].code = action.payload.code;
|
||||
} else {
|
||||
draft.design.events.push(action.payload);
|
||||
}
|
||||
shouldPushHistory = true;
|
||||
break;
|
||||
}
|
||||
case 'removeEvent': {
|
||||
if (!draft.design.events) draft.design.events = [];
|
||||
draft.design.events = draft.design.events.filter((e) => {
|
||||
const matchWidget = e.widget === action.payload.widget;
|
||||
const matchType = e.type === action.payload.type;
|
||||
const matchName = action.payload.name
|
||||
? e.name === action.payload.name
|
||||
: true;
|
||||
return !(matchWidget && matchType && matchName);
|
||||
});
|
||||
shouldPushHistory = true;
|
||||
break;
|
||||
}
|
||||
case 'clear': {
|
||||
draft.design.widgets = [];
|
||||
draft.design.events = [];
|
||||
draft.selectedWidgetId = null;
|
||||
shouldPushHistory = true;
|
||||
break;
|
||||
}
|
||||
case 'setForm': {
|
||||
if (action.payload.name)
|
||||
draft.design.form.name = action.payload.name;
|
||||
if (action.payload.title)
|
||||
draft.design.form.title = action.payload.title;
|
||||
if (action.payload.size)
|
||||
draft.design.form.size = action.payload.size;
|
||||
if (action.payload.className)
|
||||
draft.design.form.className = action.payload.className;
|
||||
shouldPushHistory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPushHistory) {
|
||||
pushHistoryDraft(draft);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushHistory(next: AppState): AppState {
|
||||
const hist = next.history.slice(0, next.historyIndex + 1);
|
||||
hist.push(clone(next.design));
|
||||
console.log('[State] History length:', hist.length);
|
||||
return { ...next, history: hist, historyIndex: hist.length - 1 };
|
||||
function pushHistoryDraft(draft: AppState) {
|
||||
// Limit history stack size to prevent memory issues
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
// Remove future history if we were in the middle of the stack
|
||||
if (draft.historyIndex < draft.history.length - 1) {
|
||||
draft.history = draft.history.slice(0, draft.historyIndex + 1);
|
||||
}
|
||||
|
||||
draft.history.push(JSON.parse(JSON.stringify(draft.design)));
|
||||
|
||||
if (draft.history.length > MAX_HISTORY) {
|
||||
draft.history.shift(); // Remove oldest
|
||||
}
|
||||
|
||||
draft.historyIndex = draft.history.length - 1;
|
||||
}
|
||||
|
||||
export function useAppState() {
|
||||
@@ -217,7 +261,11 @@ export function AppProvider({
|
||||
}) {
|
||||
const initialDesign: DesignData = useMemo(
|
||||
() => ({
|
||||
form: { title: 'My App', size: { width: 800, height: 600 } },
|
||||
form: {
|
||||
name: 'Form1',
|
||||
title: 'My App',
|
||||
size: { width: 800, height: 600 },
|
||||
},
|
||||
widgets: [],
|
||||
events: [],
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type WidgetType =
|
||||
| 'Label'
|
||||
| 'Button'
|
||||
| 'Entry'
|
||||
| 'Text'
|
||||
| 'Checkbutton'
|
||||
| 'Radiobutton';
|
||||
@@ -24,9 +25,19 @@ export interface EventBinding {
|
||||
|
||||
export interface DesignData {
|
||||
form: {
|
||||
name: string;
|
||||
title: string;
|
||||
size: { width: number; height: number };
|
||||
className?: string;
|
||||
};
|
||||
widgets: Widget[];
|
||||
events: EventBinding[];
|
||||
}
|
||||
|
||||
export type WebviewMessage =
|
||||
| { type: 'designUpdated'; data: DesignData }
|
||||
| { type: 'getDesignData' }
|
||||
| { type: 'webviewReady' }
|
||||
| { type: 'generateCode'; data: DesignData }
|
||||
| { type: 'applyChanges'; data: DesignData };
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ export function useMessaging() {
|
||||
initializedRef.current = true;
|
||||
dispatch({ type: 'init', payload: message.data });
|
||||
break;
|
||||
case 'updateFormName':
|
||||
dispatch({
|
||||
type: 'setForm',
|
||||
payload: { name: message.name },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -25,7 +31,12 @@ export function useMessaging() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializedRef.current) return;
|
||||
vscode.postMessage({ type: 'designUpdated', data: design });
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
vscode.postMessage({ type: 'designUpdated', data: design });
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [design, vscode]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+306
-33
@@ -77,12 +77,13 @@ body {
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--vscode-button-foreground);
|
||||
color: white;
|
||||
border: 1px solid var(--vscode-button-border);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
background-color: black;
|
||||
border-color: black;
|
||||
}
|
||||
|
||||
.btn-outline:disabled {
|
||||
@@ -359,6 +360,24 @@ body {
|
||||
|
||||
.event-item .event-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-label-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-value-margin {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-item .event-name {
|
||||
@@ -434,22 +453,16 @@ body {
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
padding: 40px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.design-canvas {
|
||||
min-height: 600px;
|
||||
min-width: 800px;
|
||||
background-color: #f0f0f0;
|
||||
border: 2px dashed var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
background-color: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background-image:
|
||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
|
||||
linear-gradient(45deg, #d0d0d0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #d0d0d0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #d0d0d0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #d0d0d0 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position:
|
||||
0 0,
|
||||
@@ -458,9 +471,141 @@ body {
|
||||
-10px 0px;
|
||||
}
|
||||
|
||||
.design-canvas.drag-over {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
background-color: var(--vscode-list-dropBackground);
|
||||
.window-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #999;
|
||||
background-color: #f0f0f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window-title-bar {
|
||||
height: 30px;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0 0 10px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.window-control {
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.window-control:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.window-control.close:hover {
|
||||
background-color: #e81123;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Minimize Icon */
|
||||
.window-control.minimize::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
/* Maximize Icon */
|
||||
.window-control.maximize::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1px solid #000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Close Icon (X) */
|
||||
.window-control.close {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window-control.close::before,
|
||||
.window-control.close::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.window-control.close::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.window-control.close::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.window-control.close:hover::before,
|
||||
.window-control.close:hover::after {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.design-canvas {
|
||||
background-color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: var(--canvas-width);
|
||||
height: var(--canvas-height);
|
||||
}
|
||||
|
||||
.window-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.window-resize-handle.e {
|
||||
top: 0;
|
||||
right: -10px;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.window-resize-handle.s {
|
||||
bottom: -10px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
cursor: ns-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.window-resize-handle.se {
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
@@ -486,6 +631,10 @@ body {
|
||||
font-size: 12px;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
left: var(--widget-x);
|
||||
top: var(--widget-y);
|
||||
width: var(--widget-width);
|
||||
height: var(--widget-height);
|
||||
}
|
||||
|
||||
.canvas-widget:hover {
|
||||
@@ -497,6 +646,71 @@ body {
|
||||
box-shadow: 0 0 0 1px var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--vscode-button-background);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.resize-handle.n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handle.s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handle.e {
|
||||
top: 50%;
|
||||
right: -5px;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handle.w {
|
||||
top: 50%;
|
||||
left: -5px;
|
||||
transform: translateY(-50%);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handle.ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle.se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle.sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.canvas-widget.widget-label {
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
@@ -516,38 +730,27 @@ body {
|
||||
border: 1px solid #ced4da;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
resize: both;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-widget.widget-checkbutton {
|
||||
background-color: white;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.canvas-widget.widget-checkbutton::before {
|
||||
content: '☑️';
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.canvas-widget.widget-radiobutton {
|
||||
background-color: white;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.canvas-widget.widget-radiobutton::before {
|
||||
content: '🔘';
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -591,3 +794,73 @@ body {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Widget specific styles for Canvas content */
|
||||
.widget-label-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-button-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.widget-entry-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.widget-text-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.widget-check-content,
|
||||
.widget-radio-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-check-input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.widget-check-text {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Toolbar specific styles */
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user