Initial commit: Tkinter Designer extension
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { generateTkinterCode } from './generator';
|
||||
import { parsePythonToGrapes } from './pythonParser';
|
||||
|
||||
export class TkinterEditorProvider implements vscode.CustomTextEditorProvider {
|
||||
|
||||
public static readonly viewType = 'tkinter-designer.editor';
|
||||
|
||||
public static register(context: vscode.ExtensionContext): vscode.Disposable {
|
||||
const provider = new TkinterEditorProvider(context);
|
||||
return vscode.window.registerCustomEditorProvider(TkinterEditorProvider.viewType, provider);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext
|
||||
) { }
|
||||
|
||||
private isSaving = false;
|
||||
|
||||
public async resolveCustomTextEditor(
|
||||
document: vscode.TextDocument,
|
||||
webviewPanel: vscode.WebviewPanel,
|
||||
_token: vscode.CancellationToken
|
||||
): Promise<void> {
|
||||
|
||||
webviewPanel.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'media'))]
|
||||
};
|
||||
|
||||
webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview);
|
||||
|
||||
webviewPanel.webview.onDidReceiveMessage(e => {
|
||||
switch (e.type) {
|
||||
case 'request-load':
|
||||
this.handleRequestLoad(document, webviewPanel);
|
||||
break;
|
||||
case 'update-code':
|
||||
this.isSaving = true;
|
||||
this.handleSave(document, e.payload);
|
||||
setTimeout(() => { this.isSaving = false; }, 500);
|
||||
break;
|
||||
case 'request-import':
|
||||
this.handleImport(webviewPanel);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const folderPath = path.dirname(document.uri.fsPath);
|
||||
const fileNameBase = path.basename(document.uri.fsPath, path.extname(document.uri.fsPath));
|
||||
const pyFilePath = path.join(folderPath, `${fileNameBase}.py`);
|
||||
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(pyFilePath);
|
||||
|
||||
watcher.onDidChange(() => {
|
||||
//
|
||||
});
|
||||
|
||||
webviewPanel.onDidDispose(() => watcher.dispose());
|
||||
}
|
||||
|
||||
private handleRequestLoad(document: vscode.TextDocument, panel: vscode.WebviewPanel) {
|
||||
const text = document.getText();
|
||||
let payload = {};
|
||||
try {
|
||||
if (text.trim().length > 0) payload = JSON.parse(text);
|
||||
} catch (e) { console.error(e); }
|
||||
panel.webview.postMessage({ type: 'load-data', payload: payload });
|
||||
}
|
||||
|
||||
private async handleSave(document: vscode.TextDocument, jsonPayload: any) {
|
||||
const folderPath = path.dirname(document.uri.fsPath);
|
||||
const fileNameBase = path.basename(document.uri.fsPath, path.extname(document.uri.fsPath));
|
||||
const pyFilePath = path.join(folderPath, `${fileNameBase}.py`);
|
||||
|
||||
const pythonCode = generateTkinterCode(jsonPayload);
|
||||
try {
|
||||
fs.writeFileSync(pyFilePath, pythonCode);
|
||||
} catch (e) {
|
||||
console.error(`Python Save error: ${e}`);
|
||||
}
|
||||
|
||||
// СОХРАНЕНИЕ JSON
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const fullRange = new vscode.Range(
|
||||
document.positionAt(0),
|
||||
document.positionAt(document.getText().length)
|
||||
);
|
||||
edit.replace(document.uri, fullRange, JSON.stringify(jsonPayload, null, 2));
|
||||
await vscode.workspace.applyEdit(edit);
|
||||
await document.save();
|
||||
}
|
||||
|
||||
private async handleImport(panel: vscode.WebviewPanel) {
|
||||
const uris = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
openLabel: 'Import Python',
|
||||
filters: { 'Python Files': ['py'] }
|
||||
});
|
||||
if (uris && uris[0]) {
|
||||
const content = fs.readFileSync(uris[0].fsPath, 'utf-8');
|
||||
const data = parsePythonToGrapes(content);
|
||||
panel.webview.postMessage({ type: 'import-data', payload: data });
|
||||
}
|
||||
}
|
||||
|
||||
private getHtmlForWebview(webview: vscode.Webview): string {
|
||||
const mediaPath = path.join(this.context.extensionPath, 'media');
|
||||
const scriptUri = webview.asWebviewUri(vscode.Uri.file(path.join(mediaPath, 'grapes.min.js')));
|
||||
const styleUri = webview.asWebviewUri(vscode.Uri.file(path.join(mediaPath, 'grapes.min.css')));
|
||||
const mainScriptUri = webview.asWebviewUri(vscode.Uri.file(path.join(mediaPath, 'main.js')));
|
||||
const fontUrl = "https://unpkg.com/grapesjs/dist/fonts/grapes.woff";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline' https:; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; font-src ${webview.cspSource} https: data:;">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="${styleUri}" rel="stylesheet">
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; overflow: hidden; }
|
||||
#editor { height: 100%; }
|
||||
@font-face { font-family: 'GrapesJS'; src: url('${fontUrl}') format('woff'); font-weight: normal; font-style: normal; }
|
||||
</style>
|
||||
<title>Tkinter Designer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor"></div>
|
||||
<script src="${scriptUri}"></script>
|
||||
<script src="${mainScriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { TkinterEditorProvider } from './TkinterEditorProvider';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
// Регистрируем наш Custom Editor
|
||||
context.subscriptions.push(TkinterEditorProvider.register(context));
|
||||
}
|
||||
|
||||
export function deactivate() {}
|
||||
@@ -0,0 +1,92 @@
|
||||
export function generateTkinterCode(json: any): string {
|
||||
|
||||
let width = 500;
|
||||
let height = 400;
|
||||
let title = 'Form';
|
||||
|
||||
// GrapesJS Wrapper data
|
||||
if (json && json.pages && json.pages[0] && json.pages[0].frames) {
|
||||
const wrapper = json.pages[0].frames[0].component;
|
||||
if (wrapper && wrapper.attributes) {
|
||||
if (wrapper.attributes['data-width']) width = wrapper.attributes['data-width'];
|
||||
if (wrapper.attributes['data-height']) height = wrapper.attributes['data-height'];
|
||||
if (wrapper.attributes['data-title']) title = wrapper.attributes['data-title'];
|
||||
}
|
||||
}
|
||||
|
||||
let pythonCode = `import tkinter as tk\n\nroot = tk.Tk()\nroot.title('${title}')\nroot.geometry('${width}x${height}')\n\n`;
|
||||
|
||||
let widgetCounter = 0;
|
||||
|
||||
function processComponent(component: any, parentName: string) {
|
||||
const attrs = component.attributes || {};
|
||||
const type = attrs['data-tk-type'];
|
||||
|
||||
if (!type) {
|
||||
if (component.components) {
|
||||
component.components.forEach((child: any) => processComponent(child, parentName));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
widgetCounter++;
|
||||
const widgetName = `${type.toLowerCase()}_${widgetCounter}`;
|
||||
|
||||
// 1. КОНСТРУКТОР
|
||||
let optionsParts = [parentName];
|
||||
|
||||
let text = attrs['data-text'];
|
||||
if (!text) text = component.traits?.find((t: any) => t.name === 'text')?.value;
|
||||
|
||||
const noTextTypes = ['Entry', 'Canvas', 'Listbox', 'Text', 'Frame'];
|
||||
if (!noTextTypes.includes(type) && text) optionsParts.push(`text="${text}"`);
|
||||
|
||||
if (attrs['data-bg']) optionsParts.push(`bg="${attrs['data-bg']}"`);
|
||||
if (attrs['data-fg']) optionsParts.push(`fg="${attrs['data-fg']}"`);
|
||||
|
||||
const optionsStr = optionsParts.join(', ');
|
||||
if (type === 'Frame') {
|
||||
pythonCode += `${widgetName} = tk.Frame(${optionsStr}, relief="groove", borderwidth=2)\n`;
|
||||
} else {
|
||||
pythonCode += `${widgetName} = tk.${type}(${optionsStr})\n`;
|
||||
}
|
||||
|
||||
// 2. ГЕОМЕТРИЯ (Place)
|
||||
const x = attrs['data-x'] || 0;
|
||||
const y = attrs['data-y'] || 0;
|
||||
const w = attrs['data-width'];
|
||||
const h = attrs['data-height'];
|
||||
|
||||
let placeParts = [`x=${x}`, `y=${y}`];
|
||||
if (w) placeParts.push(`width=${w}`);
|
||||
if (h) placeParts.push(`height=${h}`);
|
||||
pythonCode += `${widgetName}.place(${placeParts.join(', ')})\n`;
|
||||
|
||||
// 3. КОНТЕНТ
|
||||
if (type === 'Entry' && text) pythonCode += `${widgetName}.insert(0, "${text}")\n`;
|
||||
if (type === 'Text' && text) {
|
||||
const safeText = text.replace(/\n/g, '\\n');
|
||||
pythonCode += `${widgetName}.insert("1.0", "${safeText}")\n`;
|
||||
}
|
||||
if (type === 'Listbox' && attrs['data-items']) {
|
||||
const items = attrs['data-items'].split('\n');
|
||||
items.forEach((item: string) => { if(item.trim()) pythonCode += `${widgetName}.insert(tk.END, "${item}")\n`; });
|
||||
}
|
||||
|
||||
// 4. РЕКУРСИЯ
|
||||
if (component.components) {
|
||||
component.components.forEach((child: any) => processComponent(child, widgetName));
|
||||
}
|
||||
pythonCode += "\n";
|
||||
}
|
||||
|
||||
if (json && json.pages && json.pages[0] && json.pages[0].frames) {
|
||||
const rootComponents = json.pages[0].frames[0].component.components;
|
||||
if (rootComponents) {
|
||||
rootComponents.forEach((comp: any) => processComponent(comp, 'root'));
|
||||
}
|
||||
}
|
||||
|
||||
pythonCode += "\nroot.mainloop()";
|
||||
return pythonCode;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
export function parsePythonToGrapes(pythonCode: string): any {
|
||||
const lines = pythonCode.split('\n');
|
||||
|
||||
// 1. Настройки главного окна
|
||||
let rootWidth = 500;
|
||||
let rootHeight = 400;
|
||||
let rootTitle = 'Form';
|
||||
let rootBg = '#ffffff';
|
||||
|
||||
const geoRegex = /root\.geometry\(['"](\d+)x(\d+)['"]\)/;
|
||||
const titleRegex = /root\.title\(['"](.*)['"]\)/;
|
||||
const bgRootRegex = /root\.configure\(bg=['"](.*)['"]\)/;
|
||||
|
||||
lines.forEach(line => {
|
||||
const geo = line.match(geoRegex);
|
||||
if (geo) { rootWidth = parseInt(geo[1]); rootHeight = parseInt(geo[2]); }
|
||||
const tit = line.match(titleRegex);
|
||||
if (tit) rootTitle = tit[1];
|
||||
const bgr = line.match(bgRootRegex);
|
||||
if (bgr) rootBg = bgr[1];
|
||||
});
|
||||
|
||||
// 2. Хранилища
|
||||
const widgets: { [key: string]: any } = {};
|
||||
const hierarchy: { [key: string]: string } = {};
|
||||
|
||||
const creationRegex = /^\s*(\w+)\s*=\s*tk\.(\w+)\s*\(\s*([^,]+)(?:,\s*(.*))?\)/;
|
||||
const placeRegex = /^\s*(\w+)\.place\s*\(([^)]*)\)/;
|
||||
const insertRegex = /^\s*(\w+)\.insert\s*\([^,]+,\s*["'](.*)["']\)/;
|
||||
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'Label': 'tk-label', 'Button': 'tk-button', 'Entry': 'tk-entry',
|
||||
'Checkbutton': 'tk-check', 'Radiobutton': 'tk-radio',
|
||||
'Listbox': 'tk-listbox', 'Text': 'tk-text', 'Canvas': 'tk-canvas',
|
||||
'Frame': 'tk-frame'
|
||||
};
|
||||
|
||||
const getDefaultStyle = (className: string) => {
|
||||
const base = {
|
||||
'position': 'absolute',
|
||||
'padding': '5px',
|
||||
'border': '1px solid #999',
|
||||
'background-color': '#f0f0f0',
|
||||
'display': 'flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'center',
|
||||
'overflow': 'hidden',
|
||||
'color': 'black'
|
||||
};
|
||||
|
||||
if (className === 'Entry' || className === 'Text') {
|
||||
return {
|
||||
...base,
|
||||
'background-color': '#ffffff',
|
||||
'border': '2px inset #ccc',
|
||||
'align-items': 'flex-start',
|
||||
'justify-content': 'flex-start',
|
||||
'white-space': 'pre-wrap'
|
||||
};
|
||||
}
|
||||
if (className === 'Listbox') {
|
||||
return {
|
||||
...base,
|
||||
'background-color': '#ffffff',
|
||||
'border': '2px inset #ccc',
|
||||
'align-items': 'flex-start',
|
||||
'justify-content': 'flex-start',
|
||||
'padding': '0'
|
||||
};
|
||||
}
|
||||
if (className === 'Frame') {
|
||||
return {
|
||||
...base,
|
||||
'border': '2px dashed #555',
|
||||
'background-color': 'rgba(0,0,0,0.05)',
|
||||
'overflow': 'visible'
|
||||
};
|
||||
}
|
||||
if (className === 'Canvas') {
|
||||
return {
|
||||
...base,
|
||||
'border': '1px solid black',
|
||||
'background-color': '#ffffff'
|
||||
};
|
||||
}
|
||||
// Label, Button, Checkbox...
|
||||
return base;
|
||||
};
|
||||
|
||||
lines.forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
|
||||
const createMatch = line.match(creationRegex);
|
||||
if (createMatch) {
|
||||
const varName = createMatch[1];
|
||||
const className = createMatch[2];
|
||||
const parentName = createMatch[3].trim();
|
||||
const args = createMatch[4] || '';
|
||||
|
||||
if (typeMap[className]) {
|
||||
hierarchy[varName] = parentName;
|
||||
|
||||
const traits: any[] = [];
|
||||
const attrs: any = { 'data-tk-type': className };
|
||||
|
||||
const style = getDefaultStyle(className);
|
||||
|
||||
const paramRegex = /(\w+)=(?:["'](.*?)["']|(\d+))/g;
|
||||
let m;
|
||||
while ((m = paramRegex.exec(args)) !== null) {
|
||||
const key = m[1];
|
||||
const val = m[2] || m[3];
|
||||
|
||||
if (key === 'text') traits.push({ name: 'text', value: val });
|
||||
if (key === 'command') traits.push({ name: 'command', value: val });
|
||||
|
||||
if (key === 'bg') {
|
||||
traits.push({ name: 'bg', value: val });
|
||||
attrs['data-bg'] = val;
|
||||
style['background-color'] = val;
|
||||
}
|
||||
if (key === 'fg') {
|
||||
traits.push({ name: 'fg', value: val });
|
||||
attrs['data-fg'] = val;
|
||||
style['color'] = val;
|
||||
}
|
||||
}
|
||||
|
||||
widgets[varName] = {
|
||||
tagName: 'div',
|
||||
type: typeMap[className],
|
||||
attributes: attrs,
|
||||
traits: traits,
|
||||
components: [],
|
||||
style: style
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// B. Place
|
||||
const placeMatch = line.match(placeRegex);
|
||||
if (placeMatch) {
|
||||
const varName = placeMatch[1];
|
||||
const args = placeMatch[2];
|
||||
|
||||
if (widgets[varName]) {
|
||||
const paramRegex = /(\w+)=(\d+)/g;
|
||||
let m;
|
||||
while ((m = paramRegex.exec(args)) !== null) {
|
||||
const key = m[1];
|
||||
const val = parseInt(m[2]);
|
||||
|
||||
// Обновляем CSS
|
||||
if (key === 'x') widgets[varName].style.left = val + 'px';
|
||||
if (key === 'y') widgets[varName].style.top = val + 'px';
|
||||
if (key === 'width') widgets[varName].style.width = val + 'px';
|
||||
if (key === 'height') widgets[varName].style.height = val + 'px';
|
||||
|
||||
widgets[varName].attributes[`data-${key}`] = val;
|
||||
widgets[varName].traits.push({ name: key, value: val });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// C. Insert
|
||||
const insertMatch = line.match(insertRegex);
|
||||
if (insertMatch) {
|
||||
const varName = insertMatch[1];
|
||||
const textVal = insertMatch[2].replace(/\\n/g, '\n');
|
||||
|
||||
if (widgets[varName]) {
|
||||
const w = widgets[varName];
|
||||
const t = w.attributes['data-tk-type'];
|
||||
|
||||
if (t === 'Entry' || t === 'Text') {
|
||||
let trait = w.traits.find((tr:any) => tr.name === 'text');
|
||||
if (trait) trait.value = textVal;
|
||||
else w.traits.push({ name: 'text', value: textVal });
|
||||
w.attributes['data-text'] = textVal;
|
||||
}
|
||||
else if (t === 'Listbox') {
|
||||
if (!w.attributes['data-items']) w.attributes['data-items'] = [];
|
||||
w.attributes['data-items'].push(textVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Сборка дерева
|
||||
const rootComponents: any[] = [];
|
||||
|
||||
Object.keys(widgets).forEach(name => {
|
||||
const widget = widgets[name];
|
||||
const parent = hierarchy[name];
|
||||
|
||||
if (widget.attributes['data-tk-type'] === 'Listbox' && Array.isArray(widget.attributes['data-items'])) {
|
||||
const itemsStr = widget.attributes['data-items'].join('\n');
|
||||
widget.attributes['data-items'] = itemsStr;
|
||||
widget.traits.push({ name: 'items', value: itemsStr });
|
||||
widget.traits.push({ name: 'line_count', value: widget.attributes['data-items'].length });
|
||||
}
|
||||
|
||||
if (parent === 'root') {
|
||||
rootComponents.push(widget);
|
||||
} else if (widgets[parent]) {
|
||||
widgets[parent].components.push(widget);
|
||||
} else {
|
||||
rootComponents.push(widget);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
pages: [{
|
||||
frames: [{
|
||||
component: {
|
||||
type: 'wrapper',
|
||||
attributes: { 'data-width': rootWidth, 'data-height': rootHeight, 'data-title': rootTitle, 'data-bg': rootBg },
|
||||
style: { 'background-color': rootBg, 'width': rootWidth + 'px', 'height': rootHeight + 'px', 'position': 'relative', 'overflow': 'hidden' },
|
||||
components: rootComponents
|
||||
}
|
||||
}]
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as assert from 'assert';
|
||||
|
||||
// You can import and use all API from the 'vscode' module
|
||||
// as well as import your extension to test it
|
||||
import * as vscode from 'vscode';
|
||||
// import * as myExtension from '../../extension';
|
||||
|
||||
suite('Extension Test Suite', () => {
|
||||
vscode.window.showInformationMessage('Start all tests.');
|
||||
|
||||
test('Sample test', () => {
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user