refactor
This commit is contained in:
parent
c87e51053c
commit
d8b0c738c9
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
out
|
||||
.vscode-test
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.gitea
|
||||
README.md
|
||||
examples/
|
||||
.vscode/
|
||||
docs/
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,5 +2,4 @@
|
||||
out/
|
||||
docs/
|
||||
node_modules/
|
||||
README.md
|
||||
examples/
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
# Use Node.js LTS
|
||||
FROM node:18-slim
|
||||
|
||||
# Install Python 3 and git
|
||||
RUN apt-get update && apt-get install -y python3 python3-pip git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the extension
|
||||
RUN npm run compile
|
||||
|
||||
# Install vsce to package the extension
|
||||
RUN npm install -g @vscode/vsce
|
||||
|
||||
# Default command to package the extension
|
||||
CMD ["vsce", "package", "--out", "extension.vsix"]
|
||||
83
README.md
Normal file
83
README.md
Normal file
@ -0,0 +1,83 @@
|
||||
## Tkinter Visual Designer (VS Code Extension)
|
||||
|
||||
Tkinter Visual Designer — это расширение для Visual Studio Code, которое превращает
|
||||
разработку Tkinter‑интерфейсов в наглядный процесс: вместо ручного написания
|
||||
координат и вызовов `.place()` / `.grid()` вы работаете с визуальным конструктором.
|
||||
|
||||
Основная идея — **разводить логику и разметку**:
|
||||
|
||||
- разметка окна (виджеты, их положение и свойства) проектируется в визуальном редакторе;
|
||||
- логика приложения (обработчики событий, бизнес‑код) остаётся в Python‑файле.
|
||||
|
||||
### Что умеет приложение
|
||||
|
||||
- Визуальное редактирование формы:
|
||||
- перетаскивание виджетов (Label, Button, Entry, Text, Checkbutton, Radiobutton) на холст;
|
||||
- изменение размера и положения виджетов мышью;
|
||||
- настройка свойств (текст, размеры, позиция) через панель свойств.
|
||||
- Работа с событиями:
|
||||
- привязка обработчиков к виджетам (например, `command` у кнопки);
|
||||
- просмотр уже существующих привязок.
|
||||
- Генерация кода:
|
||||
- создание Python‑кода по текущему дизайну;
|
||||
- экспорт/импорт проекта в JSON.
|
||||
- Разбор существующего кода:
|
||||
- парсер анализирует ваш Tkinter‑код и восстанавливает дизайн формы;
|
||||
- поддерживаются классические приложения с классом‑обёрткой и методами `__init__`, `create_widgets` и т.п.
|
||||
|
||||
### Типичный сценарий работы
|
||||
|
||||
1. Открыть или создать Python‑файл с Tkinter‑приложением.
|
||||
2. Запустить визуальный дизайнер (см. раздел «Запуск приложения» ниже).
|
||||
3. В дизайнере:
|
||||
- перетаскивать виджеты из палитры на холст;
|
||||
- настраивать свойства и события в боковой панели;
|
||||
- при необходимости импортировать существующий дизайн из кода.
|
||||
4. Сгенерировать обновлённый Python‑код и сохранить его.
|
||||
5. Запустить приложение Python уже с новым интерфейсом.
|
||||
|
||||
### Требования
|
||||
|
||||
- Visual Studio Code ≥ 1.74
|
||||
- Node.js и npm
|
||||
- Python 3 (доступный в системе как `python`, `python3`, `py` или указанный в настройках VS Code)
|
||||
|
||||
### Установка зависимостей
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Сборка расширения и веб‑интерфейса
|
||||
|
||||
```bash
|
||||
npm run compile
|
||||
```
|
||||
|
||||
### Запуск приложения (расширения)
|
||||
|
||||
1. Откройте папку проекта в VS Code.
|
||||
2. Выполните `npm install` и `npm run compile`, если ещё не делали это.
|
||||
3. Нажмите `F5` или выберите в меню:
|
||||
- Run → Start Debugging (Запуск → Начать отладку).
|
||||
4. Откроется новое окно VS Code с установленным в нём расширением.
|
||||
|
||||
В этом окне вы можете:
|
||||
|
||||
- Открыть любой `.py` файл с Tkinter‑кодом.
|
||||
- Открыть визуальный дизайнер:
|
||||
- через командную палитру (`Ctrl+Shift+P` → `Tkinter: Open Tkinter Designer`), или
|
||||
- через контекстное меню в проводнике: правый клик по `.py` → `Open Tkinter Designer`.
|
||||
|
||||
### Основные команды
|
||||
|
||||
Расширение регистрирует следующие команды:
|
||||
|
||||
- `Tkinter: Open Tkinter Designer` — открыть визуальный дизайнер для текущего Python‑файла.
|
||||
- `Tkinter: Generate Python Code` — сгенерировать Python‑код из текущего дизайна.
|
||||
- `Tkinter: Parse Tkinter Code` — разобрать существующий Tkinter‑код и загрузить его в дизайнер.
|
||||
|
||||
### Примечания
|
||||
|
||||
- Для корректной работы парсера обязательно наличие установленного Python 3.
|
||||
- Генерация и разбор кода основаны на стандартной структуре Tkinter‑приложений (классы, методы `__init__`, `create_widgets`, и т.п.).
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "tkinter-designer",
|
||||
"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"
|
||||
},
|
||||
|
||||
50
src/constants.ts
Normal file
50
src/constants.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export enum WidgetType {
|
||||
Label = 'Label',
|
||||
Button = 'Button',
|
||||
Entry = 'Entry',
|
||||
Text = 'Text',
|
||||
Checkbutton = 'Checkbutton',
|
||||
Radiobutton = 'Radiobutton',
|
||||
Frame = 'Frame',
|
||||
Widget = 'Widget',
|
||||
}
|
||||
|
||||
export const DEFAULT_FORM_CONFIG = {
|
||||
TITLE: 'Parsed App',
|
||||
WIDTH: 800,
|
||||
HEIGHT: 600,
|
||||
CLASS_NAME: 'Application',
|
||||
FORM_NAME: 'Form',
|
||||
};
|
||||
|
||||
export const WIDGET_DIMENSIONS = {
|
||||
[WidgetType.Label]: { width: 100, height: 25 },
|
||||
[WidgetType.Button]: { width: 80, height: 30 },
|
||||
[WidgetType.Entry]: { width: 150, height: 25 },
|
||||
[WidgetType.Text]: { width: 200, height: 100 },
|
||||
[WidgetType.Checkbutton]: { width: 100, height: 25 },
|
||||
[WidgetType.Radiobutton]: { width: 100, height: 25 },
|
||||
[WidgetType.Frame]: { width: 200, height: 200 },
|
||||
[WidgetType.Widget]: { width: 100, height: 25 },
|
||||
DEFAULT: { width: 100, height: 25 },
|
||||
};
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
COMMAND: 'command',
|
||||
};
|
||||
|
||||
export const PYTHON_CODE = {
|
||||
IMPORTS: {
|
||||
TKINTER: 'import tkinter as tk',
|
||||
TTK: 'from tkinter import ttk',
|
||||
},
|
||||
METHODS: {
|
||||
INIT: '__init__',
|
||||
CREATE_WIDGETS: 'create_widgets',
|
||||
RUN: 'run',
|
||||
},
|
||||
VARIABLES: {
|
||||
ROOT: 'self.root',
|
||||
APP: 'app',
|
||||
}
|
||||
};
|
||||
104
src/extension.ts
104
src/extension.ts
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { DesignData, WidgetData, Event } from '../generator/types';
|
||||
import { DesignData, WidgetData, DesignEvent, WidgetProperties } from '../generator/types';
|
||||
import { ASTResult, ASTWidget, ASTMethodData } from './astTypes';
|
||||
import { getDefaultWidth, getDefaultHeight } from './utils';
|
||||
import { WidgetType, DEFAULT_FORM_CONFIG, EVENT_TYPES } from '../constants';
|
||||
|
||||
export function convertASTResultToDesignData(
|
||||
astResult: any
|
||||
): DesignData | null {
|
||||
if (!astResult || !astResult.widgets || astResult.widgets.length === 0) {
|
||||
export function convertASTResultToDesignData(astResult: ASTResult): DesignData | null {
|
||||
if (!astResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (!astResult.window && !astResult.widgets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -12,48 +17,44 @@ export function convertASTResultToDesignData(
|
||||
let formTitle =
|
||||
(astResult.window && astResult.window.title) ||
|
||||
astResult.title ||
|
||||
'Parsed App';
|
||||
DEFAULT_FORM_CONFIG.TITLE;
|
||||
let formWidth =
|
||||
(astResult.window && astResult.window.width) || astResult.width || 800;
|
||||
(astResult.window && astResult.window.width) || astResult.width || DEFAULT_FORM_CONFIG.WIDTH;
|
||||
let formHeight =
|
||||
(astResult.window && astResult.window.height) ||
|
||||
astResult.height ||
|
||||
600;
|
||||
DEFAULT_FORM_CONFIG.HEIGHT;
|
||||
let className =
|
||||
(astResult.window && astResult.window.className) || DEFAULT_FORM_CONFIG.CLASS_NAME;
|
||||
|
||||
let counter = 0;
|
||||
const allowedTypes = new Set([
|
||||
'Label',
|
||||
'Button',
|
||||
'Text',
|
||||
'Checkbutton',
|
||||
'Radiobutton',
|
||||
WidgetType.Label,
|
||||
WidgetType.Button,
|
||||
WidgetType.Entry,
|
||||
WidgetType.Text,
|
||||
WidgetType.Checkbutton,
|
||||
WidgetType.Radiobutton,
|
||||
]);
|
||||
for (const w of astResult.widgets) {
|
||||
|
||||
|
||||
const inputWidgets = astResult.widgets || [];
|
||||
|
||||
for (const w of inputWidgets) {
|
||||
counter++;
|
||||
const type = w.type || 'Widget';
|
||||
if (!allowedTypes.has(type)) {
|
||||
const type = w.type || WidgetType.Widget;
|
||||
if (!allowedTypes.has(type as WidgetType)) {
|
||||
continue;
|
||||
}
|
||||
const place = w.placement || {};
|
||||
const x = place.x !== undefined ? place.x : w.x !== undefined ? w.x : 0;
|
||||
const y = place.y !== undefined ? place.y : w.y !== undefined ? w.y : 0;
|
||||
const p = (w.properties || w.params || {}) as any;
|
||||
const width =
|
||||
place.width !== undefined
|
||||
? place.width
|
||||
: w.width !== undefined
|
||||
? w.width
|
||||
: p.width !== undefined
|
||||
? p.width
|
||||
: getDefaultWidth(type);
|
||||
const height =
|
||||
place.height !== undefined
|
||||
? place.height
|
||||
: w.height !== undefined
|
||||
? w.height
|
||||
: p.height !== undefined
|
||||
? p.height
|
||||
: getDefaultHeight(type);
|
||||
|
||||
|
||||
const p: Record<string, any> = w.properties || w.params || {};
|
||||
|
||||
const width = [place.width, w.width, p.width].find(v => v !== undefined) ?? getDefaultWidth(type);
|
||||
const height = [place.height, w.height, p.height].find(v => v !== undefined) ?? getDefaultHeight(type);
|
||||
const id = w.variable_name || `ast_widget_${counter}`;
|
||||
const widget: WidgetData = {
|
||||
id,
|
||||
@ -67,33 +68,58 @@ export function convertASTResultToDesignData(
|
||||
widgets.push(widget);
|
||||
}
|
||||
|
||||
const events: Event[] = [];
|
||||
const events: DesignEvent[] = [];
|
||||
const methods = astResult.methods || {};
|
||||
|
||||
if (astResult.command_callbacks) {
|
||||
for (const callback of astResult.command_callbacks) {
|
||||
const rawName = callback.command?.name;
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: `on_${callback.widget}_command`;
|
||||
events.push({
|
||||
widget: callback.widget,
|
||||
type: 'command',
|
||||
name: cleanName,
|
||||
code: callback.command?.lambda_body || '',
|
||||
});
|
||||
addDesignEvent(
|
||||
events,
|
||||
methods,
|
||||
callback.widget,
|
||||
EVENT_TYPES.COMMAND,
|
||||
callback.command?.name,
|
||||
() => `on_${callback.widget}_command`,
|
||||
callback.command?.lambda_body
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (astResult.bind_events) {
|
||||
for (const bindEvent of astResult.bind_events) {
|
||||
const rawName = bindEvent.callback?.name;
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`;
|
||||
events.push({
|
||||
widget: bindEvent.widget,
|
||||
type: bindEvent.event,
|
||||
name: cleanName,
|
||||
code: bindEvent.callback?.lambda_body || '',
|
||||
});
|
||||
addDesignEvent(
|
||||
events,
|
||||
methods,
|
||||
bindEvent.widget,
|
||||
bindEvent.event,
|
||||
bindEvent.callback?.name,
|
||||
() => `on_${bindEvent.widget}_${String(bindEvent.event).replace(/[<>]/g, '').replace(/-/g, '_')}`,
|
||||
bindEvent.callback?.lambda_body
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (astResult.widgets) {
|
||||
for (const w of astResult.widgets) {
|
||||
const p: Record<string, any> = w.properties || w.params || {};
|
||||
if (p.command) {
|
||||
const widgetId = w.variable_name;
|
||||
if (!widgetId) continue;
|
||||
|
||||
const cmd = p.command;
|
||||
const rawName = cmd.name || cmd;
|
||||
|
||||
addDesignEvent(
|
||||
events,
|
||||
methods,
|
||||
widgetId,
|
||||
EVENT_TYPES.COMMAND,
|
||||
rawName,
|
||||
() => `on_${widgetId}_command`,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,8 +128,10 @@ export function convertASTResultToDesignData(
|
||||
|
||||
const result: DesignData = {
|
||||
form: {
|
||||
name: DEFAULT_FORM_CONFIG.FORM_NAME,
|
||||
title: formTitle,
|
||||
size: { width: formWidth, height: formHeight },
|
||||
className: className,
|
||||
},
|
||||
widgets,
|
||||
events: filteredEvents.length ? filteredEvents : [],
|
||||
@ -111,8 +139,8 @@ export function convertASTResultToDesignData(
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractWidgetPropertiesFromAST(w: any): any {
|
||||
const props: any = {};
|
||||
function extractWidgetPropertiesFromAST(w: ASTWidget): WidgetProperties {
|
||||
const props: WidgetProperties = {};
|
||||
if (!w) return props;
|
||||
const p = w.properties || w.params || {};
|
||||
if (p.text) props.text = p.text;
|
||||
@ -125,3 +153,43 @@ function extractWidgetPropertiesFromAST(w: any): any {
|
||||
if (p.height !== undefined) props.height = p.height;
|
||||
return props;
|
||||
}
|
||||
|
||||
function addDesignEvent(
|
||||
events: DesignEvent[],
|
||||
methods: Record<string, any>,
|
||||
widgetId: string,
|
||||
eventType: string,
|
||||
rawName: string | undefined | null,
|
||||
defaultNameGen: () => string,
|
||||
lambdaBody: string | undefined,
|
||||
checkExists: boolean = false
|
||||
) {
|
||||
const cleanName = rawName
|
||||
? String(rawName).replace(/^self\./, '')
|
||||
: defaultNameGen();
|
||||
|
||||
if (checkExists) {
|
||||
const exists = events.find(
|
||||
(e) => e.widget === widgetId && e.type === eventType
|
||||
);
|
||||
if (exists) return;
|
||||
}
|
||||
|
||||
const methodData = methods[cleanName];
|
||||
const codeBody =
|
||||
typeof methodData === 'object' && methodData !== null
|
||||
? (methodData as ASTMethodData).body
|
||||
: (methodData as string);
|
||||
const signature =
|
||||
typeof methodData === 'object' && methodData !== null
|
||||
? (methodData as ASTMethodData).signature
|
||||
: undefined;
|
||||
|
||||
events.push({
|
||||
widget: widgetId,
|
||||
type: eventType,
|
||||
name: cleanName,
|
||||
code: codeBody || lambdaBody || '',
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
60
src/parser/astTypes.ts
Normal file
60
src/parser/astTypes.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { WidgetProperties } from '../generator/types';
|
||||
|
||||
export interface ASTPlacement {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface ASTWidget {
|
||||
type?: string;
|
||||
variable_name?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
placement?: ASTPlacement;
|
||||
properties?: Record<string, any>;
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ASTCommandCallback {
|
||||
widget: string;
|
||||
command?: {
|
||||
name?: string;
|
||||
lambda_body?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ASTBindEvent {
|
||||
widget: string;
|
||||
event: string;
|
||||
callback?: {
|
||||
name?: string;
|
||||
lambda_body?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ASTMethodData {
|
||||
body: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface ASTResult {
|
||||
window?: {
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
};
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
widgets: ASTWidget[];
|
||||
command_callbacks?: ASTCommandCallback[];
|
||||
bind_events?: ASTBindEvent[];
|
||||
methods?: { [key: string]: string | ASTMethodData };
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@ -2,79 +2,178 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import * as vscode from 'vscode';
|
||||
import { ASTResult } from './astTypes';
|
||||
|
||||
async function executePythonScript(
|
||||
pythonScriptPath: string,
|
||||
pythonFilePath: string
|
||||
): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const pythonCommand = getPythonCommand();
|
||||
const start = Date.now();
|
||||
console.log('[PythonRunner] Spawning:', pythonCommand, pythonScriptPath, pythonFilePath);
|
||||
const process = spawn(pythonCommand, [
|
||||
pythonScriptPath,
|
||||
pythonFilePath,
|
||||
]);
|
||||
let result = '';
|
||||
let errorOutput = '';
|
||||
class PythonExecutor {
|
||||
private static instance: PythonExecutor;
|
||||
private cachedPythonPath: string | null = null;
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
result += data.toString();
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): PythonExecutor {
|
||||
if (!PythonExecutor.instance) {
|
||||
PythonExecutor.instance = new PythonExecutor();
|
||||
}
|
||||
return PythonExecutor.instance;
|
||||
}
|
||||
|
||||
public async getPythonPath(): Promise<string> {
|
||||
if (this.cachedPythonPath) {
|
||||
return this.cachedPythonPath;
|
||||
}
|
||||
|
||||
const commands = await this.findPythonCommands();
|
||||
|
||||
for (const cmd of commands) {
|
||||
try {
|
||||
await this.verifyPython(cmd);
|
||||
this.cachedPythonPath = cmd;
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No suitable Python interpreter found. Please install Python or configure "python.defaultInterpreterPath" in VS Code.');
|
||||
}
|
||||
|
||||
private async verifyPython(cmd: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const process = spawn(cmd, ['--version'], { shell: false });
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Python check failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
private async findPythonCommands(): Promise<string[]> {
|
||||
const commands: string[] = [];
|
||||
|
||||
process.on('close', (code) => {
|
||||
const ms = Date.now() - start;
|
||||
console.log('[PythonRunner] Exit code:', code, 'time(ms):', ms);
|
||||
if (code === 0) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Python script failed with code ${code}: ${errorOutput}`
|
||||
)
|
||||
);
|
||||
|
||||
const extension = vscode.extensions.getExtension('ms-python.python');
|
||||
if (extension) {
|
||||
if (!extension.isActive) {
|
||||
await extension.activate();
|
||||
}
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('python');
|
||||
let pythonPath =
|
||||
config.get<string>('defaultInterpreterPath') ||
|
||||
config.get<string>('pythonPath');
|
||||
|
||||
if (pythonPath && pythonPath !== 'python') {
|
||||
if (pythonPath.includes('${workspaceFolder}')) {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||
pythonPath = pythonPath.replace(
|
||||
'${workspaceFolder}',
|
||||
workspaceFolders[0].uri.fsPath
|
||||
);
|
||||
}
|
||||
}
|
||||
commands.push(pythonPath);
|
||||
}
|
||||
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
commands.push('py');
|
||||
commands.push('python');
|
||||
} else {
|
||||
commands.push('python3');
|
||||
commands.push('python');
|
||||
}
|
||||
|
||||
return Array.from(new Set(commands));
|
||||
}
|
||||
|
||||
public async executeScript(scriptPath: string, args: string[] = [], stdinInput?: string): Promise<string> {
|
||||
const pythonPath = await this.getPythonPath();
|
||||
const allArgs = [scriptPath, ...args];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn(pythonPath, allArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr || `Process exited with code ${code}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (err) => {
|
||||
reject(new Error(`Failed to spawn python process: ${err.message}`));
|
||||
});
|
||||
|
||||
if (stdinInput) {
|
||||
try {
|
||||
process.stdin.write(stdinInput);
|
||||
process.stdin.end();
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Failed to write to stdin: ${e.message}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPythonCommand(): string {
|
||||
return process.platform === 'win32' ? 'python' : 'python3';
|
||||
}
|
||||
|
||||
function createTempPythonFile(pythonCode: string): string {
|
||||
const tempDir = os.tmpdir();
|
||||
const tempFilePath = path.join(tempDir, `tk_ast_${Date.now()}.py`);
|
||||
fs.writeFileSync(tempFilePath, pythonCode, 'utf8');
|
||||
return tempFilePath;
|
||||
}
|
||||
|
||||
function cleanupTempFile(tempFile: string): void {
|
||||
try {
|
||||
fs.unlinkSync(tempFile);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function runPythonAst(pythonCode: string): Promise<any | null> {
|
||||
const tempFilePath = createTempPythonFile(pythonCode);
|
||||
try {
|
||||
const pythonScriptPath = path.join(__dirname, 'tkinter_ast_parser.py');
|
||||
const output = await executePythonScript(
|
||||
pythonScriptPath,
|
||||
tempFilePath
|
||||
);
|
||||
console.log('[PythonRunner] Received AST JSON length:', output.length);
|
||||
const parsed = JSON.parse(output);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error('[PythonRunner] Error running Python AST:', err);
|
||||
return null;
|
||||
} finally {
|
||||
cleanupTempFile(tempFilePath);
|
||||
console.log('[PythonRunner] Temp file cleaned:', tempFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function executePythonScript(
|
||||
pythonScriptPath: string,
|
||||
pythonCode: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const executor = PythonExecutor.getInstance();
|
||||
return await executor.executeScript(pythonScriptPath, [], pythonCode);
|
||||
} catch (error: any) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPythonAst(code: string): Promise<ASTResult | { error: string }> {
|
||||
try {
|
||||
const executor = PythonExecutor.getInstance();
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'parser',
|
||||
'tkinter_ast_parser.py'
|
||||
);
|
||||
|
||||
const output = await executor.executeScript(scriptPath, [], code);
|
||||
try {
|
||||
return JSON.parse(output) as ASTResult;
|
||||
} catch (e) {
|
||||
return { error: 'Failed to parse JSON output: ' + e };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
BIN
src/parser/tk_ast/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/__pycache__/grid_layout.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/__pycache__/grid_layout.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/__pycache__/parser.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/__pycache__/parser.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/base.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/calls.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/calls.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/context.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/context.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/events.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/events.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/imports.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/imports.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/parser/tk_ast/analyzer/__pycache__/values.cpython-314.pyc
Normal file
BIN
src/parser/tk_ast/analyzer/__pycache__/values.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,4 +1,7 @@
|
||||
import ast
|
||||
import textwrap
|
||||
import tokenize
|
||||
import io
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from .imports import handle_import, handle_import_from
|
||||
@ -14,7 +17,15 @@ from .connections import create_widget_handler_connections
|
||||
|
||||
class TkinterAnalyzer(ast.NodeVisitor):
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, source_code: str = ""):
|
||||
self.source_code = source_code
|
||||
self.line_offsets = [0]
|
||||
current = 0
|
||||
if source_code:
|
||||
for line in source_code.splitlines(keepends=True):
|
||||
current += len(line)
|
||||
self.line_offsets.append(current)
|
||||
|
||||
self.widgets: List[Dict[str, Any]] = []
|
||||
self.window_config = {'title': 'App', 'width': 800, 'height': 600}
|
||||
self.imports: Dict[str, str] = {}
|
||||
@ -24,6 +35,7 @@ class TkinterAnalyzer(ast.NodeVisitor):
|
||||
self.event_handlers: List[Dict[str, Any]] = []
|
||||
self.command_callbacks: List[Dict[str, Any]] = []
|
||||
self.bind_events: List[Dict[str, Any]] = []
|
||||
self.methods: Dict[str, str] = {}
|
||||
|
||||
def visit_Import(self, node: ast.Import):
|
||||
handle_import(self, node)
|
||||
@ -36,12 +48,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')
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
95
src/webview/projectIO.ts
Normal file
95
src/webview/projectIO.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { DesignData } from '../generator/types';
|
||||
|
||||
export class ProjectIO {
|
||||
public static async exportProject(data: DesignData): Promise<void> {
|
||||
try {
|
||||
const options: vscode.SaveDialogOptions = {
|
||||
defaultUri: vscode.Uri.file(`tkinter-project-${new Date().toISOString().split('T')[0]}.json`),
|
||||
filters: {
|
||||
'JSON': ['json']
|
||||
}
|
||||
};
|
||||
const fileUri = await vscode.window.showSaveDialog(options);
|
||||
if (fileUri) {
|
||||
const jsonString = JSON.stringify(data, null, 2);
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(fileUri, encoder.encode(jsonString));
|
||||
vscode.window.showInformationMessage('Project exported successfully!');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
vscode.window.showErrorMessage(`Export failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async importProject(): Promise<DesignData | null> {
|
||||
try {
|
||||
const options: vscode.OpenDialogOptions = {
|
||||
canSelectMany: false,
|
||||
openLabel: 'Import',
|
||||
filters: {
|
||||
'JSON': ['json']
|
||||
}
|
||||
};
|
||||
const fileUris = await vscode.window.showOpenDialog(options);
|
||||
if (fileUris && fileUris[0]) {
|
||||
const fileUri = fileUris[0];
|
||||
const fileData = await vscode.workspace.fs.readFile(fileUri);
|
||||
const decoder = new TextDecoder();
|
||||
const jsonString = decoder.decode(fileData);
|
||||
const data = JSON.parse(jsonString);
|
||||
|
||||
|
||||
const designData = data.data || data;
|
||||
|
||||
if (ProjectIO.validateDesignData(designData)) {
|
||||
vscode.window.showInformationMessage('Project imported successfully!');
|
||||
return designData;
|
||||
} else {
|
||||
vscode.window.showErrorMessage('Invalid project file format.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
vscode.window.showErrorMessage(`Import failed: ${errorMessage}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static validateDesignData(data: any): data is DesignData {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (!data.form || typeof data.form !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (typeof data.form.name !== 'string' || typeof data.form.title !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (!data.form.size || typeof data.form.size.width !== 'number' || typeof data.form.size.height !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (!Array.isArray(data.widgets)) {
|
||||
return false;
|
||||
}
|
||||
for (const widget of data.widgets) {
|
||||
if (!widget.id || typeof widget.id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (!widget.type || typeof widget.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (typeof widget.x !== 'number' || typeof widget.y !== 'number') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -5,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>
|
||||
);
|
||||
|
||||
45
src/webview/react/components/ErrorBoundary.tsx
Normal file
45
src/webview/react/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: '20px', color: 'red', backgroundColor: '#ffe6e6', border: '1px solid red', borderRadius: '4px' }}>
|
||||
<h2>Something went wrong.</h2>
|
||||
<details style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{this.state.error && this.state.error.toString()}
|
||||
</details>
|
||||
<button
|
||||
style={{ marginTop: '10px', padding: '5px 10px', cursor: 'pointer' }}
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,7 @@ export function EventsPanel() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { design, selectedWidgetId, vscode } = useAppState();
|
||||
const [eventType, setEventType] = useState('command');
|
||||
const [eventName, setEventName] = useState('onClick');
|
||||
const [eventCode, setEventCode] = useState('print("clicked")');
|
||||
const [eventName, setEventName] = useState('on_click');
|
||||
|
||||
const w = design.widgets.find((x) => x.id === selectedWidgetId);
|
||||
const widgetEvents = (id: string | undefined) =>
|
||||
@ -20,10 +19,17 @@ export function EventsPanel() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!eventType || !eventName || !eventCode) {
|
||||
if (!eventName) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'Please fill in all fields: event type, name, and code',
|
||||
text: 'Please fill in handler name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!eventType) {
|
||||
vscode.postMessage({
|
||||
type: 'showError',
|
||||
text: 'Please fill in event type',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -33,21 +39,39 @@ export function EventsPanel() {
|
||||
widget: w.id,
|
||||
type: eventType,
|
||||
name: eventName,
|
||||
code: eventCode,
|
||||
code: 'pass',
|
||||
},
|
||||
});
|
||||
vscode.postMessage({
|
||||
type: 'showInfo',
|
||||
text: `Event added: ${eventType} -> ${eventName}`,
|
||||
text: `Event added: ${eventName}`,
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (type: string) => {
|
||||
const remove = (type: string, name: string) => {
|
||||
if (!w) return;
|
||||
dispatch({ type: 'removeEvent', payload: { widget: w.id, type } });
|
||||
dispatch({
|
||||
type: 'removeEvent',
|
||||
payload: { widget: w.id, type, name },
|
||||
});
|
||||
vscode.postMessage({ type: 'showInfo', text: 'Event removed' });
|
||||
};
|
||||
|
||||
const commonEvents = [
|
||||
'command',
|
||||
'<Button-1>',
|
||||
'<Button-2>',
|
||||
'<Button-3>',
|
||||
'<Double-Button-1>',
|
||||
'<Enter>',
|
||||
'<Leave>',
|
||||
'<FocusIn>',
|
||||
'<FocusOut>',
|
||||
'<Return>',
|
||||
'<Key>',
|
||||
'<Configure>',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="events-panel">
|
||||
<h3>Events & Commands</h3>
|
||||
@ -60,12 +84,41 @@ export function EventsPanel() {
|
||||
<label className="property-label">
|
||||
Event Type:
|
||||
</label>
|
||||
<input
|
||||
className="event-type-select"
|
||||
type="text"
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
/>
|
||||
<div className="mb-8">
|
||||
<select
|
||||
className="event-handler-input mb-4"
|
||||
value={
|
||||
commonEvents.includes(eventType)
|
||||
? eventType
|
||||
: 'custom'
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.value !== 'custom') {
|
||||
setEventType(e.target.value);
|
||||
} else {
|
||||
setEventType('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{commonEvents.map((evt) => (
|
||||
<option key={evt} value={evt}>
|
||||
{evt}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
{!commonEvents.includes(eventType) && (
|
||||
<input
|
||||
className="event-handler-input"
|
||||
type="text"
|
||||
placeholder="Enter custom event type"
|
||||
value={eventType}
|
||||
onChange={(e) =>
|
||||
setEventType(e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="property-label">
|
||||
Handler Name:
|
||||
</label>
|
||||
@ -75,14 +128,6 @@ export function EventsPanel() {
|
||||
value={eventName}
|
||||
onChange={(e) => setEventName(e.target.value)}
|
||||
/>
|
||||
<label className="property-label">
|
||||
Handler Code:
|
||||
</label>
|
||||
<textarea
|
||||
className="event-handler-input"
|
||||
value={eventCode}
|
||||
onChange={(e) => setEventCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="event-buttons">
|
||||
<button className="event-btn primary" onClick={add}>
|
||||
@ -97,23 +142,26 @@ export function EventsPanel() {
|
||||
widgetEvents(w.id).map((ev) => (
|
||||
<div
|
||||
className="event-item"
|
||||
key={`${ev.widget}-${ev.type}`}
|
||||
key={`${ev.widget}-${ev.type}-${ev.name}`}
|
||||
>
|
||||
<div className="event-info">
|
||||
<span className="event-name">
|
||||
{ev.name}
|
||||
</span>{' '}
|
||||
<span className="event-handler">
|
||||
{ev.type}
|
||||
<span
|
||||
className="event-name event-label-bold"
|
||||
>
|
||||
{ev.type}:
|
||||
</span>
|
||||
<span
|
||||
className="event-name event-value-margin"
|
||||
>
|
||||
{ev.name}
|
||||
</span>
|
||||
<div className="event-code">
|
||||
{ev.code}
|
||||
</div>
|
||||
</div>
|
||||
<div className="event-actions">
|
||||
<button
|
||||
className="event-btn secondary"
|
||||
onClick={() => remove(ev.type)}
|
||||
onClick={() =>
|
||||
remove(ev.type, ev.name)
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user