diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..971eef0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info +.eggs +*.egg +dist +build +.venv +venv +ENV + +# IDE +.idea +.vscode +*.swp + +# Docker +Dockerfile* +docker-compose* +.docker + +# Misc +*.md +!README.md +.coverage +.pytest_cache +htmlcov diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..13ac310 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,175 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Test Commands + +```bash +# Install in development mode +pip install -e . + +# Install with all dependencies +pip install -e ".[dev]" + +# Run the editor +artifact-editor + +# Run from source (development) +PYTHONPATH=src python3 -m artifact_editor.cli + +# Run with specific format +artifact-editor --type svg --output /tmp/test.svg +``` + +## Architecture + +Artifact Editor is a standalone PyQt6-based GUI for creating visual artifacts (diagrams, 3D models, code snippets) from code-based formats. It's designed to integrate with other tools via stdin/stdout and exit codes. + +### Project Ecosystem + +This is part of a three-project stack: +1. **SmartTools** - AI provider abstraction and tool execution (dependency) +2. **Orchestrated Discussions** - Conversation orchestration (launches this editor) +3. **Artifact Editor** (this) - Visual artifact creation + +### Integration Protocol + +When launched by a parent application (e.g., Orchestrated Discussions): + +``` +Parent App Artifact Editor + │ │ + ├─ subprocess.run([ │ + │ "artifact-editor", │ + │ "--output", "path.puml" │ + │ ]) │ + │ ├─ User edits/creates artifact + │ ├─ User may switch format (e.g., .puml → .svg) + │ ├─ User clicks Save & Exit + │ │ + │ ◄──────────────────────────────── ├─ stdout: "ARTIFACT_SAVED:/actual/path.svg" + │ ├─ exit code: 0 (success) + │ │ + ├─ Parse stdout for ARTIFACT_SAVED │ + ├─ Use actual path (not suggested) │ +``` + +**Exit Codes:** +- `0` - Success, file saved. stdout contains `ARTIFACT_SAVED:/path` +- `1` - User cancelled (closed without saving) +- `2` - Error (stderr contains message) + +### Key Design Decisions + +1. **Code-based formats** - All artifacts are stored as text (PlantUML, Mermaid, SVG, etc.) so AI can read and modify them +2. **Format switching** - User can change format; `output_path` extension updates automatically +3. **Non-destructive** - Undo/redo works for both code and visual edits +4. **Subprocess integration** - Clean stdin/stdout contract, no shared state + +### Source Structure + +``` +src/artifact_editor/ +├── cli.py # Entry point, argument parsing +├── gui.py # PyQt6 main window (~2800 lines) +├── dialogs.py # Element editing dialogs per format +├── templates.py # Pre-built templates (100+) +├── parser.py # PlantUML parser +├── mermaid_parser.py # Mermaid parser +├── openscad_parser.py # OpenSCAD parser +├── excalidraw_parser.py # Excalidraw JSON parser +├── svg_parser.py # SVG XML parsing +├── svg_interactive.py # Qt-based SVG interaction +├── svg_scene.py # QGraphicsScene SVG rendering +└── renderers/ + ├── __init__.py # Renderer registry + ├── plantuml.py # Calls `plantuml` CLI + ├── mermaid.py # Calls `mmdc` CLI + ├── openscad.py # Calls `openscad` CLI + ├── code.py # Pygments syntax highlighting + ├── svg.py # Direct SVG rendering + └── excalidraw.py # Excalidraw JSON → SVG +``` + +### Key Classes + +- **`ArtifactEditorWindow`** - Main PyQt6 window with split view +- **`RenderThread`** - Background rendering to avoid UI freezes +- **`DictateThread`** - Voice input with countdown timer +- **`AIThread`** - AI generation in background +- **`SVGSceneManager`** - Interactive SVG editing with QGraphicsScene + +### AI Integration + +Uses the `artifact-ai` SmartTool for AI-powered generation: +```python +subprocess.run( + ["artifact-ai", "--format", format_type, "--instruction", instruction], + input=current_code, + capture_output=True +) +``` + +No direct AI provider imports - follows Unix philosophy. + +### SmartTools + +Artifact Editor includes SmartTools for CLI/scripting use: + +``` +smarttools/ +├── artifact-ai/ # AI-powered artifact generation/modification +│ └── config.yaml # Supports all formats: plantuml, mermaid, openscad, svg, excalidraw, code +└── artifact-export/ # Render source to binary formats (PNG, SVG, PDF) + └── config.yaml +``` + +**artifact-ai** - Generate or modify artifacts using AI: +```bash +# Create new artifact +echo "" | artifact-ai --format plantuml --instruction "Create a sequence diagram for login" + +# Modify existing artifact +cat diagram.puml | artifact-ai --format plantuml --instruction "Add a cache layer" +``` + +**artifact-export** - Render artifacts to binary formats: +```bash +cat diagram.puml | artifact-export --format plantuml --to output.svg +cat model.scad | artifact-export --format openscad --to output.png +``` + +These tools are installed to `~/.smarttools/` and `~/.local/bin/` by `./install.sh`. + +### Supported Formats + +| Format | Extension | Renderer | Visual Editing | +|--------|-----------|----------|----------------| +| PlantUML | `.puml` | `plantuml` CLI | Element dialogs | +| Mermaid | `.mmd` | `mmdc` CLI | Node/edge dialogs | +| OpenSCAD | `.scad` | `openscad` CLI | 3D rotation, primitives | +| Code | `.py`, etc. | Pygments | Syntax highlighting | +| SVG | `.svg` | Direct | Interactive drag/resize | +| Excalidraw | `.json` | Built-in | Hand-drawn style | + +### Toolbar Actions + +- **Format selector** - Switch between artifact types (updates file extension) +- **Save** - Save to current path +- **Save & Exit** - Save and close (for integration workflows) +- **Render** button by preview - Force re-render (Ctrl+R) + +### AI Panel + +Bottom of window: +- **Text input** - Natural language instructions +- **Dictate** - 10-second voice recording with countdown (`🎤 9s`, `🎤 8s`...) +- **AI Generate** - Sends to Claude, updates code editor + +### External Dependencies + +Required for rendering: +- `plantuml` - For PlantUML diagrams +- `mmdc` (mermaid-cli) - For Mermaid diagrams +- `openscad` - For 3D CAD models +- `pygments` - For code syntax highlighting diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82534a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,118 @@ +# Artifact Editor - AI-enhanced visual artifact creation +# +# Multi-stage build: +# Stage 1: Build SmartTools base +# Stage 2: Build Artifact Editor with SmartTools +# +# Build: docker build -t artifact-editor . +# Run: docker run -it --rm artifact-editor artifact-ai --help +# GUI: See usage examples at bottom + +# ============================================================================== +# Stage 1: SmartTools Base +# ============================================================================== +FROM python:3.12-slim AS smarttools + +WORKDIR /smarttools + +ARG SMARTTOOLS_REPO=https://gitea.brrd.tech/rob/SmartTools.git +RUN apt-get update && apt-get install -y --no-install-recommends git && \ + git clone ${SMARTTOOLS_REPO} . || \ + echo "Clone failed - will need COPY in next stage" + +RUN pip install --no-cache-dir -e . || true + +# ============================================================================== +# Stage 2: Artifact Editor +# ============================================================================== +FROM python:3.12-slim + +LABEL maintainer="rob" +LABEL description="Artifact Editor - AI-enhanced diagram and artifact creation" + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + plantuml \ + # PyQt6 dependencies (for GUI - optional for CLI use) + libgl1-mesa-glx \ + libegl1-mesa \ + libxkbcommon0 \ + libdbus-1-3 \ + libxcb-cursor0 \ + libxcb-icccm4 \ + libxcb-keysyms1 \ + libxcb-shape0 \ + libxcb-xinerama0 \ + libxcb-randr0 \ + libxcb-render-util0 \ + # For inkscape/rsvg (SVG to PNG conversion) + librsvg2-bin \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js and mermaid-cli (optional, for Mermaid diagrams) +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs && \ + npm install -g @mermaid-js/mermaid-cli 2>/dev/null || true && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy SmartTools from stage 1 +COPY --from=smarttools /smarttools /smarttools + +# Install SmartTools +RUN pip install --no-cache-dir -e /smarttools + +# Copy Artifact Editor files +COPY pyproject.toml README.md ./ +COPY src/ ./src/ +COPY smarttools/ ./smarttools/ +COPY install.sh ./ + +# Install Artifact Editor and dependencies +RUN pip install --no-cache-dir -e . && \ + pip install --no-cache-dir PyQt6 pygments + +# Create directories +RUN mkdir -p /root/.smarttools /root/.local/bin + +# Install artifact SmartTools +RUN ./install.sh + +# Install SmartTools example tools +RUN python /smarttools/examples/install.py 2>/dev/null || true && \ + smarttools refresh 2>/dev/null || true + +# Add local bin to PATH +ENV PATH="/root/.local/bin:${PATH}" + +# Healthcheck - verify CLI tools work +RUN artifact-ai --help && \ + artifact-export --help && \ + smarttools list | head -5 + +# Default: show help +CMD ["artifact-ai", "--help"] + +# ============================================================================== +# Usage Examples: +# ============================================================================== +# docker build -t artifact-editor . +# +# CLI (no display required): +# docker run -it --rm artifact-editor artifact-ai --format plantuml --instruction "Create class diagram" +# docker run -it --rm artifact-editor artifact-export --format plantuml --to /tmp/out.svg +# echo "@startuml\nA->B\n@enduml" | docker run -i --rm artifact-editor artifact-export --format plantuml --to /dev/stdout +# +# GUI (requires X11 forwarding): +# xhost +local:docker +# docker run -it --rm \ +# -e DISPLAY=$DISPLAY \ +# -e QT_QPA_PLATFORM=xcb \ +# -v /tmp/.X11-unix:/tmp/.X11-unix \ +# artifact-editor artifact-editor +# +# Interactive shell: +# docker run -it --rm artifact-editor bash diff --git a/README.md b/README.md index f3412bf..ae86507 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A standalone PyQt6-based editor for creating visual artifacts. Designed to integ - **Split-view interface** - Code editor on the left, live preview on the right - **Multi-format support** - PlantUML, Mermaid, OpenSCAD, Code, SVG, and Excalidraw +- **Format switching** - Change formats on the fly; file extension updates automatically - **Interactive SVG editing** - Click to select, drag to move, resize with handles - **Elements panel** - View and manage diagram elements - **Visual editing** - Add, edit, and delete elements via dialogs or directly in preview @@ -16,6 +17,9 @@ A standalone PyQt6-based editor for creating visual artifacts. Designed to integ - **Live preview** - See changes as you type (bidirectional sync) - **Undo/Redo** - Works for both code and visual changes - **3D controls** - Rotate/pan OpenSCAD models with mouse +- **AI-powered generation** - Describe what you want, AI generates the code +- **Voice input** - Dictation with visual countdown timer (10 seconds) +- **Save & Exit** - Quick toolbar button for integration workflows ## Supported Artifact Types @@ -134,18 +138,70 @@ artifact-editor/ │ ├── code.py # Pygments-based syntax highlighting │ ├── svg.py # Direct SVG editing with UI components │ └── excalidraw.py # Hand-drawn style diagrams +└── smarttools/ # CLI tools following Unix philosophy + ├── artifact-ai/ # AI-powered artifact generation/modification + └── artifact-export/ # Render source to binary formats ``` +## CLI SmartTools + +In addition to the GUI, artifact-editor provides command-line SmartTools: + +### artifact-ai + +Generate or modify artifacts using AI: + +```bash +# Create new artifact +echo "" | artifact-ai --format plantuml --instruction "Create a sequence diagram for user login" + +# Modify existing artifact +cat diagram.puml | artifact-ai --format plantuml --instruction "Add a cache layer between API and database" + +# Supported formats: plantuml, mermaid, openscad, svg, excalidraw, code +``` + +### artifact-export + +Render artifacts to binary formats: + +```bash +# PlantUML to SVG +cat diagram.puml | artifact-export --format plantuml --to output.svg + +# OpenSCAD to PNG +cat model.scad | artifact-export --format openscad --to output.png + +# Mermaid to PDF +cat flowchart.mmd | artifact-export --format mermaid --to output.pdf +``` + +Install SmartTools with `./install.sh` (requires SmartTools framework). + ## Keyboard Shortcuts - **Ctrl+S** - Save file - **Ctrl+Z** - Undo (works for both code and visual edits) - **Ctrl+Y** - Redo +- **Ctrl+R** - Force re-render preview - **Delete** - Remove selected element (SVG mode) - **Ctrl+Mouse wheel** - Zoom preview - **Click+Drag** - Move elements in SVG preview - **Corner handles** - Resize selected elements in SVG preview +## AI Input Panel + +The bottom panel provides AI-assisted artifact creation: + +- **Text input** - Describe what you want in natural language +- **Dictate button** - Voice input with 10-second countdown timer + - Shows `🎤 9s`, `🎤 8s`, etc. while recording + - Transcription appears in the input field for editing +- **AI Generate button** - Generates or modifies the artifact based on your description + - For new artifacts: Creates complete diagram code + - For existing content: Modifies based on instructions + - Handles retry with error context if generation fails + ## Integration The editor can be launched from other applications: diff --git a/install.sh b/install.sh index 4dd8f5c..be2c44f 100755 --- a/install.sh +++ b/install.sh @@ -5,10 +5,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" INSTALL_DIR="$HOME/.local/bin" +SMARTTOOLS_DIR="$HOME/.smarttools" mkdir -p "$INSTALL_DIR" +mkdir -p "$SMARTTOOLS_DIR" -# Create wrapper script +# Create wrapper script for artifact-editor GUI cat > "$INSTALL_DIR/artifact-editor" << 'WRAPPER' #!/bin/bash # artifact-editor wrapper - launches PyQt6 artifact editor @@ -21,12 +23,36 @@ WRAPPER sed -i "s|PLACEHOLDER_DIR|$SCRIPT_DIR|g" "$INSTALL_DIR/artifact-editor" chmod +x "$INSTALL_DIR/artifact-editor" +# Install SmartTools +if [ -d "$SCRIPT_DIR/smarttools" ]; then + echo "Installing SmartTools..." + for tool in "$SCRIPT_DIR/smarttools"/*/; do + tool_name=$(basename "$tool") + cp -r "$tool" "$SMARTTOOLS_DIR/" + + # Create CLI wrapper + cat > "$INSTALL_DIR/$tool_name" << WRAPPER +#!/bin/bash +# SmartTools wrapper for '$tool_name' +# Auto-generated +exec /usr/bin/python3 -m smarttools.runner $tool_name "\$@" +WRAPPER + chmod +x "$INSTALL_DIR/$tool_name" + echo " Installed: $tool_name" + done +fi + +echo "" echo "Installed artifact-editor to $INSTALL_DIR/artifact-editor" echo "" echo "Usage:" echo " artifact-editor diagram.puml # Edit existing file" echo " artifact-editor -o new.puml # Create new file" echo "" +echo "SmartTools:" +echo " artifact-ai --format plantuml --instruction 'Create a class diagram'" +echo " artifact-export --format plantuml --to output.svg" +echo "" if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then echo "NOTE: Add $INSTALL_DIR to your PATH:" echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" diff --git a/smarttools/artifact-ai/config.yaml b/smarttools/artifact-ai/config.yaml new file mode 100644 index 0000000..4f51825 --- /dev/null +++ b/smarttools/artifact-ai/config.yaml @@ -0,0 +1,165 @@ +# artifact-ai - Generate or modify artifacts using AI +# Usage: cat diagram.puml | artifact-ai --format plantuml --instruction "Add a cache layer" +# Usage: echo "" | artifact-ai --format mermaid --instruction "Create a flowchart for login" + +name: artifact-ai +description: Generate or modify artifacts (diagrams, 3D models, code) using AI +category: Artifact + +arguments: + - flag: --format + variable: format + required: true + description: "Artifact format: plantuml, mermaid, openscad, svg, excalidraw, code" + - flag: --instruction + variable: instruction + required: true + description: Natural language instruction for generating or modifying the artifact + +steps: + # Step 1: Build format-specific prompt and call AI + - type: code + output_var: prompt + code: | + import json + + format_prompts = { + 'plantuml': """You are a PlantUML expert. Create or modify PlantUML diagrams. + + Supported diagram types: + - Sequence diagrams: actor, participant, ->, --> + - Class diagrams: class, interface, extends, implements + - Activity diagrams: start, stop, :action;, if/then/else + - Component diagrams: component, package, [interface] + - State diagrams: state, [*], --> + + Always wrap with @startuml/@enduml.""", + + 'mermaid': """You are a Mermaid diagram expert. Create or modify Mermaid diagrams. + + Supported diagram types: + - Flowcharts: graph TD/LR, A-->B + - Sequence: sequenceDiagram, Alice->>Bob + - Class: classDiagram, class ClassName + - State: stateDiagram-v2 + - Entity Relationship: erDiagram + - Gantt: gantt, section, task + + Start with the diagram type declaration (graph TD, sequenceDiagram, etc.).""", + + 'openscad': """You are an OpenSCAD 3D modeling expert. Create or modify parametric 3D models. + + Core operations: + - Primitives: cube(), sphere(), cylinder(), polyhedron() + - Transformations: translate(), rotate(), scale(), mirror() + - Boolean: union(), difference(), intersection() + - 2D: circle(), square(), polygon(), linear_extrude(), rotate_extrude() + + Use modules for reusable components. Use $fn for smoothness.""", + + 'svg': """You are an SVG expert. Create or modify vector graphics. + + Core elements: + - Shapes: , , , , , , + - Text: , + - Groups: , , , + - Styling: fill, stroke, stroke-width, opacity, transform + + Include xmlns and viewBox. Use descriptive IDs.""", + + 'excalidraw': """You are an Excalidraw expert. Create hand-drawn style diagrams as JSON. + + Structure: + - type: element type (rectangle, diamond, ellipse, arrow, line, text) + - x, y: position + - width, height: dimensions + - strokeColor, backgroundColor, fillStyle + - roughness: 0-2 (0=smooth, 2=sketchy) + + Output valid JSON with "type": "excalidraw" and "elements" array.""", + + 'code': """You are a programming expert. Create or modify code in any language. + + Focus on: + - Clean, readable code + - Proper language conventions + - Comments for complex logic only + - Error handling where appropriate + + Detect the language from context or instruction.""" + } + + format_outputs = { + 'plantuml': "Output ONLY valid PlantUML code starting with @startuml and ending with @enduml.", + 'mermaid': "Output ONLY valid Mermaid code starting with the diagram type.", + 'openscad': "Output ONLY valid OpenSCAD code with proper syntax.", + 'svg': "Output ONLY valid SVG code starting with .", + 'excalidraw': 'Output ONLY valid Excalidraw JSON starting with { and ending with }.', + 'code': "Output ONLY the code, no markdown fences or explanations." + } + + current_code = input.strip() if input.strip() else "(empty - create from scratch)" + format_guide = format_prompts.get(format, f"You are a {format} expert.") + output_instruction = format_outputs.get(format, "Output ONLY the code, no explanations.") + + prompt = f"""{format_guide} + + CRITICAL: {output_instruction} + + Current code: + --- + {current_code} + --- + + User request: {instruction} + + {output_instruction}""" + + # Don't print - just set the variable + result = prompt + + - type: prompt + prompt: "{prompt}" + provider: claude + output_var: ai_output + + # Step 2: Clean up output based on format + - type: code + output_var: result + code: | + import re + + code = ai_output.strip() + + # Remove markdown code blocks if present (three backticks) + fence_pattern = r'`{3}(?:\w+)?\n(.*?)`{3}' + match = re.search(fence_pattern, code, re.DOTALL) + if match: + code = match.group(1).strip() + + # Format-specific cleanup + if format == 'svg': + # Remove any wrapper formats + code = re.sub(r'^@startuml\s*\n?', '', code) + code = re.sub(r'\n?@enduml\s*$', '', code) + # Find SVG content + svg_match = re.search(r'(<\?xml[^?]*\?>)?\s*()', code) + if svg_match: + xml_decl = svg_match.group(1) or '' + svg_content = svg_match.group(2) + code = (xml_decl + '\n' + svg_content).strip() if xml_decl else svg_content + + elif format == 'excalidraw': + # Find JSON object + json_match = re.search(r'\{[\s\S]*\}', code) + if json_match: + code = json_match.group(0) + + elif format == 'plantuml': + # Ensure proper tags + if not code.strip().startswith('@start'): + code = '@startuml\n' + code + if not code.strip().endswith('@enduml') and '@enduml' not in code: + code = code + '\n@enduml' + + print(code.strip()) diff --git a/smarttools/artifact-export/config.yaml b/smarttools/artifact-export/config.yaml new file mode 100644 index 0000000..07efbc4 --- /dev/null +++ b/smarttools/artifact-export/config.yaml @@ -0,0 +1,156 @@ +# artifact-export - Render artifacts to binary formats +# Usage: cat diagram.puml | artifact-export --format plantuml --to /tmp/diagram.svg +# Usage: cat model.scad | artifact-export --format openscad --to /tmp/model.png + +name: artifact-export +description: Render artifact source code to binary formats (PNG, SVG, PDF) +category: Artifact + +arguments: + - flag: --format + variable: format + required: true + description: "Source format: plantuml, mermaid, openscad, svg, excalidraw, code" + - flag: --to + variable: output_path + required: true + description: Output file path (extension determines output format, e.g. .png, .svg, .pdf) + +steps: + - type: code + output_var: result + code: | + import subprocess + import json + import os + from pathlib import Path + + source_code = input.strip() + output = Path(output_path) + output_ext = output.suffix.lower() + + # Ensure output directory exists + output.parent.mkdir(parents=True, exist_ok=True) + + success = False + message = "" + + if format == 'plantuml': + # Write to temp file + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.puml', delete=False) as f: + f.write(source_code) + temp_path = f.name + + format_flag = {'.svg': '-tsvg', '.png': '-tpng', '.eps': '-teps', '.pdf': '-tpdf'}.get(output_ext, '-tsvg') + + try: + result = subprocess.run( + ['plantuml', format_flag, '-o', str(output.parent), temp_path], + capture_output=True, text=True, timeout=30 + ) + + if result.returncode == 0: + # PlantUML outputs to same name with different extension + temp_base = Path(temp_path) + generated = output.parent / (temp_base.stem + output_ext) + if generated.exists(): + import shutil + shutil.move(str(generated), str(output)) + success = True + message = str(output) + else: + message = f"Generated file not found: {generated}" + else: + message = f"PlantUML error: {result.stderr}" + finally: + os.unlink(temp_path) + + elif format == 'mermaid': + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.mmd', delete=False) as f: + f.write(source_code) + temp_path = f.name + + try: + result = subprocess.run( + ['mmdc', '-i', temp_path, '-o', str(output)], + capture_output=True, text=True, timeout=30 + ) + + if result.returncode == 0 and output.exists(): + success = True + message = str(output) + else: + message = f"Mermaid error: {result.stderr}" + finally: + os.unlink(temp_path) + + elif format == 'openscad': + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.scad', delete=False) as f: + f.write(source_code) + temp_path = f.name + + try: + result = subprocess.run( + ['openscad', '-o', str(output), temp_path], + capture_output=True, text=True, timeout=60 + ) + + if result.returncode == 0 and output.exists(): + success = True + message = str(output) + else: + message = f"OpenSCAD error: {result.stderr}" + finally: + os.unlink(temp_path) + + elif format == 'svg': + if output_ext == '.svg': + output.write_text(source_code) + success = True + message = str(output) + elif output_ext == '.png': + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f: + f.write(source_code) + temp_path = f.name + + try: + for cmd in [ + ['inkscape', temp_path, '-o', str(output)], + ['rsvg-convert', temp_path, '-o', str(output)], + ]: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0 and output.exists(): + success = True + message = str(output) + break + except FileNotFoundError: + continue + if not success: + message = "SVG to PNG requires inkscape or rsvg-convert" + finally: + os.unlink(temp_path) + else: + message = f"Unsupported output format: {output_ext}" + + elif format == 'code': + output.write_text(source_code) + success = True + message = str(output) + + elif format == 'excalidraw': + if output_ext == '.json': + output.write_text(source_code) + success = True + message = str(output) + else: + message = "Excalidraw export currently only supports .json output" + + else: + message = f"Unknown format: {format}" + + print(json.dumps({'success': success, 'path' if success else 'error': message})) diff --git a/src/artifact_editor/dialogs.py b/src/artifact_editor/dialogs.py index 9069436..64c8031 100644 --- a/src/artifact_editor/dialogs.py +++ b/src/artifact_editor/dialogs.py @@ -5,11 +5,11 @@ from dataclasses import dataclass, field from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, + QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout, QLineEdit, QComboBox, QListWidget, QPushButton, QLabel, QDialogButtonBox, QGroupBox, QTextEdit, QListWidgetItem, QMessageBox, QCheckBox, QDoubleSpinBox, - QWidget + QWidget, QSpinBox, QColorDialog, QPlainTextEdit ) @@ -1919,3 +1919,1198 @@ class EditOpenSCADElementDialog(QDialog): def get_code(self) -> Optional[str]: return self.result_code + + +# ============================================================================= +# Excalidraw Dialogs +# ============================================================================= + +class ColorButton(QPushButton): + """A button that shows and allows selecting a color.""" + + def __init__(self, color: str = "#000000", parent=None): + super().__init__(parent) + self._color = color + self._update_style() + self.clicked.connect(self._pick_color) + self.setFixedSize(60, 30) + + def _update_style(self): + if self._color == 'transparent': + self.setStyleSheet(""" + QPushButton { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 white, stop:0.49 white, + stop:0.5 #ccc, stop:1 #ccc); + border: 1px solid #999; + border-radius: 3px; + } + """) + self.setText("None") + else: + # Calculate text color for contrast + r = int(self._color[1:3], 16) if len(self._color) >= 7 else 0 + g = int(self._color[3:5], 16) if len(self._color) >= 7 else 0 + b = int(self._color[5:7], 16) if len(self._color) >= 7 else 0 + brightness = (r * 299 + g * 587 + b * 114) / 1000 + text_color = "#000000" if brightness > 128 else "#ffffff" + + self.setStyleSheet(f""" + QPushButton {{ + background-color: {self._color}; + color: {text_color}; + border: 1px solid #999; + border-radius: 3px; + }} + """) + self.setText("") + + def _pick_color(self): + from PyQt6.QtGui import QColor + initial = QColor(self._color) if self._color != 'transparent' else QColor("#ffffff") + color = QColorDialog.getColor(initial, self, "Select Color") + if color.isValid(): + self._color = color.name() + self._update_style() + + def color(self) -> str: + return self._color + + def setColor(self, color: str): + self._color = color + self._update_style() + + +class AddExcalidrawElementDialog(QDialog): + """Dialog for adding a new Excalidraw element.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Add Excalidraw Element") + self.setMinimumWidth(400) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Element type + form = QFormLayout() + + self.type_combo = QComboBox() + from .excalidraw_parser import EXCALIDRAW_ELEMENT_TYPES + for type_id, type_name in EXCALIDRAW_ELEMENT_TYPES: + self.type_combo.addItem(type_name, type_id) + self.type_combo.currentIndexChanged.connect(self._on_type_changed) + form.addRow("Type:", self.type_combo) + + # Position + pos_layout = QHBoxLayout() + self.x_spin = QDoubleSpinBox() + self.x_spin.setRange(-10000, 10000) + self.x_spin.setValue(100) + pos_layout.addWidget(QLabel("X:")) + pos_layout.addWidget(self.x_spin) + + self.y_spin = QDoubleSpinBox() + self.y_spin.setRange(-10000, 10000) + self.y_spin.setValue(100) + pos_layout.addWidget(QLabel("Y:")) + pos_layout.addWidget(self.y_spin) + form.addRow("Position:", pos_layout) + + # Size + size_layout = QHBoxLayout() + self.width_spin = QDoubleSpinBox() + self.width_spin.setRange(1, 10000) + self.width_spin.setValue(150) + size_layout.addWidget(QLabel("W:")) + size_layout.addWidget(self.width_spin) + + self.height_spin = QDoubleSpinBox() + self.height_spin.setRange(1, 10000) + self.height_spin.setValue(100) + size_layout.addWidget(QLabel("H:")) + size_layout.addWidget(self.height_spin) + form.addRow("Size:", size_layout) + + layout.addLayout(form) + + # Appearance group + appearance_group = QGroupBox("Appearance") + appearance_layout = QFormLayout(appearance_group) + + # Stroke color + self.stroke_color_btn = ColorButton("#000000") + appearance_layout.addRow("Stroke:", self.stroke_color_btn) + + # Background color + bg_layout = QHBoxLayout() + self.bg_color_btn = ColorButton("transparent") + self.no_fill_check = QCheckBox("No fill") + self.no_fill_check.setChecked(True) + self.no_fill_check.stateChanged.connect(self._on_fill_changed) + bg_layout.addWidget(self.bg_color_btn) + bg_layout.addWidget(self.no_fill_check) + appearance_layout.addRow("Background:", bg_layout) + + # Stroke width + self.stroke_width_spin = QSpinBox() + self.stroke_width_spin.setRange(1, 10) + self.stroke_width_spin.setValue(1) + appearance_layout.addRow("Stroke Width:", self.stroke_width_spin) + + # Roughness + self.roughness_combo = QComboBox() + self.roughness_combo.addItem("Architect (smooth)", 0) + self.roughness_combo.addItem("Artist (normal)", 1) + self.roughness_combo.addItem("Cartoonist (rough)", 2) + self.roughness_combo.setCurrentIndex(1) + appearance_layout.addRow("Style:", self.roughness_combo) + + # Fill style + self.fill_style_combo = QComboBox() + from .excalidraw_parser import FILL_STYLES + for style_id, style_name in FILL_STYLES: + self.fill_style_combo.addItem(style_name, style_id) + appearance_layout.addRow("Fill Style:", self.fill_style_combo) + + layout.addWidget(appearance_group) + + # Text options (shown only for text elements) + self.text_group = QGroupBox("Text Options") + text_layout = QFormLayout(self.text_group) + + self.text_edit = QTextEdit() + self.text_edit.setMaximumHeight(80) + self.text_edit.setPlainText("Text") + text_layout.addRow("Text:", self.text_edit) + + self.font_size_spin = QSpinBox() + self.font_size_spin.setRange(8, 100) + self.font_size_spin.setValue(20) + text_layout.addRow("Font Size:", self.font_size_spin) + + self.font_family_combo = QComboBox() + from .excalidraw_parser import FONT_FAMILIES + for fam_id, fam_name in FONT_FAMILIES: + self.font_family_combo.addItem(fam_name, fam_id) + text_layout.addRow("Font:", self.font_family_combo) + + self.text_group.setVisible(False) + layout.addWidget(self.text_group) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _on_type_changed(self): + el_type = self.type_combo.currentData() + self.text_group.setVisible(el_type == 'text') + + # For lines/arrows, width/height represent endpoint offset + is_line = el_type in ('line', 'arrow') + self.width_spin.setPrefix("dX: " if is_line else "") + self.height_spin.setPrefix("dY: " if is_line else "") + + def _on_fill_changed(self): + if self.no_fill_check.isChecked(): + self.bg_color_btn.setColor("transparent") + self.bg_color_btn.setEnabled(False) + else: + self.bg_color_btn.setEnabled(True) + if self.bg_color_btn.color() == 'transparent': + self.bg_color_btn.setColor("#a5d8ff") + + def get_element(self): + """Return the created element.""" + from .excalidraw_parser import ExcalidrawElement, generate_element_id + + el_type = self.type_combo.currentData() + + element = ExcalidrawElement( + element_type=el_type, + id=generate_element_id(), + x=self.x_spin.value(), + y=self.y_spin.value(), + width=self.width_spin.value(), + height=self.height_spin.value(), + stroke_color=self.stroke_color_btn.color(), + background_color=self.bg_color_btn.color(), + stroke_width=self.stroke_width_spin.value(), + roughness=self.roughness_combo.currentData(), + fill_style=self.fill_style_combo.currentData(), + ) + + if el_type == 'text': + element.text = self.text_edit.toPlainText() + element.font_size = self.font_size_spin.value() + element.font_family = self.font_family_combo.currentData() + + if el_type in ('line', 'arrow'): + element.points = [[0, 0], [self.width_spin.value(), self.height_spin.value()]] + + return element + + +class EditExcalidrawElementDialog(QDialog): + """Dialog for editing an existing Excalidraw element.""" + + def __init__(self, element, parent=None): + super().__init__(parent) + self.element = element + self.setWindowTitle(f"Edit {element.element_type.title()}") + self.setMinimumWidth(400) + self._setup_ui() + self._populate_from_element() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Element info + info_label = QLabel(f"Type: {self.element.element_type.title()} | ID: {self.element.id}") + layout.addWidget(info_label) + + form = QFormLayout() + + # Position + pos_layout = QHBoxLayout() + self.x_spin = QDoubleSpinBox() + self.x_spin.setRange(-10000, 10000) + pos_layout.addWidget(QLabel("X:")) + pos_layout.addWidget(self.x_spin) + + self.y_spin = QDoubleSpinBox() + self.y_spin.setRange(-10000, 10000) + pos_layout.addWidget(QLabel("Y:")) + pos_layout.addWidget(self.y_spin) + form.addRow("Position:", pos_layout) + + # Size (not for text) + if self.element.element_type != 'text': + size_layout = QHBoxLayout() + self.width_spin = QDoubleSpinBox() + self.width_spin.setRange(1, 10000) + is_line = self.element.element_type in ('line', 'arrow') + size_layout.addWidget(QLabel("dX:" if is_line else "W:")) + size_layout.addWidget(self.width_spin) + + self.height_spin = QDoubleSpinBox() + self.height_spin.setRange(1, 10000) + size_layout.addWidget(QLabel("dY:" if is_line else "H:")) + size_layout.addWidget(self.height_spin) + form.addRow("Size:" if not is_line else "End Point:", size_layout) + else: + self.width_spin = None + self.height_spin = None + + layout.addLayout(form) + + # Appearance group + appearance_group = QGroupBox("Appearance") + appearance_layout = QFormLayout(appearance_group) + + # Stroke color + self.stroke_color_btn = ColorButton() + appearance_layout.addRow("Stroke:", self.stroke_color_btn) + + # Background color (not for lines/text) + if self.element.element_type not in ('line', 'arrow', 'text'): + bg_layout = QHBoxLayout() + self.bg_color_btn = ColorButton() + self.no_fill_check = QCheckBox("No fill") + self.no_fill_check.stateChanged.connect(self._on_fill_changed) + bg_layout.addWidget(self.bg_color_btn) + bg_layout.addWidget(self.no_fill_check) + appearance_layout.addRow("Background:", bg_layout) + + # Fill style + self.fill_style_combo = QComboBox() + from .excalidraw_parser import FILL_STYLES + for style_id, style_name in FILL_STYLES: + self.fill_style_combo.addItem(style_name, style_id) + appearance_layout.addRow("Fill Style:", self.fill_style_combo) + else: + self.bg_color_btn = None + self.no_fill_check = None + self.fill_style_combo = None + + # Stroke width + self.stroke_width_spin = QSpinBox() + self.stroke_width_spin.setRange(1, 10) + appearance_layout.addRow("Stroke Width:", self.stroke_width_spin) + + # Roughness + self.roughness_combo = QComboBox() + self.roughness_combo.addItem("Architect (smooth)", 0) + self.roughness_combo.addItem("Artist (normal)", 1) + self.roughness_combo.addItem("Cartoonist (rough)", 2) + appearance_layout.addRow("Style:", self.roughness_combo) + + layout.addWidget(appearance_group) + + # Text options (only for text elements) + if self.element.element_type == 'text': + text_group = QGroupBox("Text Options") + text_layout = QFormLayout(text_group) + + self.text_edit = QTextEdit() + self.text_edit.setMaximumHeight(80) + text_layout.addRow("Text:", self.text_edit) + + self.font_size_spin = QSpinBox() + self.font_size_spin.setRange(8, 100) + text_layout.addRow("Font Size:", self.font_size_spin) + + self.font_family_combo = QComboBox() + from .excalidraw_parser import FONT_FAMILIES + for fam_id, fam_name in FONT_FAMILIES: + self.font_family_combo.addItem(fam_name, fam_id) + text_layout.addRow("Font:", self.font_family_combo) + + layout.addWidget(text_group) + else: + self.text_edit = None + self.font_size_spin = None + self.font_family_combo = None + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _populate_from_element(self): + """Fill dialog with element's current values.""" + self.x_spin.setValue(self.element.x) + self.y_spin.setValue(self.element.y) + + if self.width_spin: + if self.element.element_type in ('line', 'arrow') and self.element.points: + # For lines, use endpoint offset + if len(self.element.points) >= 2: + self.width_spin.setValue(self.element.points[-1][0]) + self.height_spin.setValue(self.element.points[-1][1]) + else: + self.width_spin.setValue(self.element.width) + self.height_spin.setValue(self.element.height) + else: + self.width_spin.setValue(self.element.width) + self.height_spin.setValue(self.element.height) + + self.stroke_color_btn.setColor(self.element.stroke_color) + self.stroke_width_spin.setValue(self.element.stroke_width) + + # Set roughness + for i in range(self.roughness_combo.count()): + if self.roughness_combo.itemData(i) == self.element.roughness: + self.roughness_combo.setCurrentIndex(i) + break + + if self.bg_color_btn: + self.bg_color_btn.setColor(self.element.background_color) + self.no_fill_check.setChecked(self.element.background_color == 'transparent') + self.bg_color_btn.setEnabled(self.element.background_color != 'transparent') + + if self.fill_style_combo: + for i in range(self.fill_style_combo.count()): + if self.fill_style_combo.itemData(i) == self.element.fill_style: + self.fill_style_combo.setCurrentIndex(i) + break + + if self.text_edit: + self.text_edit.setPlainText(self.element.text) + self.font_size_spin.setValue(self.element.font_size) + for i in range(self.font_family_combo.count()): + if self.font_family_combo.itemData(i) == self.element.font_family: + self.font_family_combo.setCurrentIndex(i) + break + + def _on_fill_changed(self): + if self.no_fill_check.isChecked(): + self.bg_color_btn.setColor("transparent") + self.bg_color_btn.setEnabled(False) + else: + self.bg_color_btn.setEnabled(True) + if self.bg_color_btn.color() == 'transparent': + self.bg_color_btn.setColor("#a5d8ff") + + def get_element(self): + """Return the updated element.""" + from .excalidraw_parser import ExcalidrawElement + + element = ExcalidrawElement( + element_type=self.element.element_type, + id=self.element.id, + x=self.x_spin.value(), + y=self.y_spin.value(), + width=self.width_spin.value() if self.width_spin else self.element.width, + height=self.height_spin.value() if self.height_spin else self.element.height, + stroke_color=self.stroke_color_btn.color(), + background_color=self.bg_color_btn.color() if self.bg_color_btn else 'transparent', + stroke_width=self.stroke_width_spin.value(), + roughness=self.roughness_combo.currentData(), + fill_style=self.fill_style_combo.currentData() if self.fill_style_combo else 'hachure', + ) + + if self.text_edit: + element.text = self.text_edit.toPlainText() + element.font_size = self.font_size_spin.value() + element.font_family = self.font_family_combo.currentData() + + if self.element.element_type in ('line', 'arrow'): + if self.width_spin and self.height_spin: + element.points = [[0, 0], [self.width_spin.value(), self.height_spin.value()]] + else: + element.points = self.element.points + + return element + + +# ============================================================================= +# SVG Dialogs +# ============================================================================= + +class AddSVGElementDialog(QDialog): + """Dialog for adding a new SVG element.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Add SVG Element") + self.setMinimumWidth(450) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + from .svg_parser import SVG_ELEMENT_TYPES, SVG_FONT_FAMILIES, TEXT_ANCHORS, generate_element_id + + # Element type + type_layout = QHBoxLayout() + type_layout.addWidget(QLabel("Type:")) + self.type_combo = QComboBox() + for type_id, type_name in SVG_ELEMENT_TYPES: + self.type_combo.addItem(type_name, type_id) + self.type_combo.currentIndexChanged.connect(self._on_type_changed) + type_layout.addWidget(self.type_combo) + type_layout.addStretch() + layout.addLayout(type_layout) + + # Position + pos_group = QGroupBox("Position") + pos_layout = QGridLayout(pos_group) + + pos_layout.addWidget(QLabel("X:"), 0, 0) + self.x_spin = QSpinBox() + self.x_spin.setRange(-10000, 10000) + self.x_spin.setValue(100) + pos_layout.addWidget(self.x_spin, 0, 1) + + pos_layout.addWidget(QLabel("Y:"), 0, 2) + self.y_spin = QSpinBox() + self.y_spin.setRange(-10000, 10000) + self.y_spin.setValue(100) + pos_layout.addWidget(self.y_spin, 0, 3) + + layout.addWidget(pos_group) + + # Size group (for rect, ellipse) + self.size_group = QGroupBox("Size") + size_layout = QGridLayout(self.size_group) + + size_layout.addWidget(QLabel("Width:"), 0, 0) + self.width_spin = QSpinBox() + self.width_spin.setRange(1, 10000) + self.width_spin.setValue(100) + size_layout.addWidget(self.width_spin, 0, 1) + + size_layout.addWidget(QLabel("Height:"), 0, 2) + self.height_spin = QSpinBox() + self.height_spin.setRange(1, 10000) + self.height_spin.setValue(100) + size_layout.addWidget(self.height_spin, 0, 3) + + layout.addWidget(self.size_group) + + # Circle group + self.circle_group = QGroupBox("Circle") + circle_layout = QGridLayout(self.circle_group) + + circle_layout.addWidget(QLabel("Center X:"), 0, 0) + self.cx_spin = QSpinBox() + self.cx_spin.setRange(-10000, 10000) + self.cx_spin.setValue(150) + circle_layout.addWidget(self.cx_spin, 0, 1) + + circle_layout.addWidget(QLabel("Center Y:"), 0, 2) + self.cy_spin = QSpinBox() + self.cy_spin.setRange(-10000, 10000) + self.cy_spin.setValue(150) + circle_layout.addWidget(self.cy_spin, 0, 3) + + circle_layout.addWidget(QLabel("Radius:"), 1, 0) + self.r_spin = QSpinBox() + self.r_spin.setRange(1, 10000) + self.r_spin.setValue(50) + circle_layout.addWidget(self.r_spin, 1, 1) + + self.circle_group.setVisible(False) + layout.addWidget(self.circle_group) + + # Ellipse group + self.ellipse_group = QGroupBox("Ellipse") + ellipse_layout = QGridLayout(self.ellipse_group) + + ellipse_layout.addWidget(QLabel("Center X:"), 0, 0) + self.ecx_spin = QSpinBox() + self.ecx_spin.setRange(-10000, 10000) + self.ecx_spin.setValue(150) + ellipse_layout.addWidget(self.ecx_spin, 0, 1) + + ellipse_layout.addWidget(QLabel("Center Y:"), 0, 2) + self.ecy_spin = QSpinBox() + self.ecy_spin.setRange(-10000, 10000) + self.ecy_spin.setValue(150) + ellipse_layout.addWidget(self.ecy_spin, 0, 3) + + ellipse_layout.addWidget(QLabel("Radius X:"), 1, 0) + self.rx_spin = QSpinBox() + self.rx_spin.setRange(1, 10000) + self.rx_spin.setValue(60) + ellipse_layout.addWidget(self.rx_spin, 1, 1) + + ellipse_layout.addWidget(QLabel("Radius Y:"), 1, 2) + self.ry_spin = QSpinBox() + self.ry_spin.setRange(1, 10000) + self.ry_spin.setValue(40) + ellipse_layout.addWidget(self.ry_spin, 1, 3) + + self.ellipse_group.setVisible(False) + layout.addWidget(self.ellipse_group) + + # Line group + self.line_group = QGroupBox("Line") + line_layout = QGridLayout(self.line_group) + + line_layout.addWidget(QLabel("X1:"), 0, 0) + self.x1_spin = QSpinBox() + self.x1_spin.setRange(-10000, 10000) + self.x1_spin.setValue(100) + line_layout.addWidget(self.x1_spin, 0, 1) + + line_layout.addWidget(QLabel("Y1:"), 0, 2) + self.y1_spin = QSpinBox() + self.y1_spin.setRange(-10000, 10000) + self.y1_spin.setValue(100) + line_layout.addWidget(self.y1_spin, 0, 3) + + line_layout.addWidget(QLabel("X2:"), 1, 0) + self.x2_spin = QSpinBox() + self.x2_spin.setRange(-10000, 10000) + self.x2_spin.setValue(200) + line_layout.addWidget(self.x2_spin, 1, 1) + + line_layout.addWidget(QLabel("Y2:"), 1, 2) + self.y2_spin = QSpinBox() + self.y2_spin.setRange(-10000, 10000) + self.y2_spin.setValue(200) + line_layout.addWidget(self.y2_spin, 1, 3) + + self.line_group.setVisible(False) + layout.addWidget(self.line_group) + + # Text group + self.text_group = QGroupBox("Text") + text_layout = QVBoxLayout(self.text_group) + + self.text_edit = QPlainTextEdit() + self.text_edit.setMaximumHeight(60) + self.text_edit.setPlaceholderText("Enter text...") + text_layout.addWidget(self.text_edit) + + text_props = QHBoxLayout() + text_props.addWidget(QLabel("Font:")) + self.font_family_combo = QComboBox() + for font_id, font_name in SVG_FONT_FAMILIES: + self.font_family_combo.addItem(font_name, font_id) + text_props.addWidget(self.font_family_combo) + + text_props.addWidget(QLabel("Size:")) + self.font_size_spin = QSpinBox() + self.font_size_spin.setRange(8, 200) + self.font_size_spin.setValue(16) + text_props.addWidget(self.font_size_spin) + + text_props.addWidget(QLabel("Align:")) + self.text_anchor_combo = QComboBox() + for anchor_id, anchor_name in TEXT_ANCHORS: + self.text_anchor_combo.addItem(anchor_name, anchor_id) + text_props.addWidget(self.text_anchor_combo) + + text_layout.addLayout(text_props) + self.text_group.setVisible(False) + layout.addWidget(self.text_group) + + # Path group + self.path_group = QGroupBox("Path") + path_layout = QVBoxLayout(self.path_group) + + path_layout.addWidget(QLabel("Path data (d attribute):")) + self.path_edit = QPlainTextEdit() + self.path_edit.setMaximumHeight(60) + self.path_edit.setPlaceholderText("M 0 0 L 100 100...") + path_layout.addWidget(self.path_edit) + + self.path_group.setVisible(False) + layout.addWidget(self.path_group) + + # Polygon/Polyline group + self.points_group = QGroupBox("Points") + points_layout = QVBoxLayout(self.points_group) + + points_layout.addWidget(QLabel("Points (x1,y1 x2,y2 ...):")) + self.points_edit = QPlainTextEdit() + self.points_edit.setMaximumHeight(60) + self.points_edit.setPlaceholderText("100,100 150,50 200,100") + points_layout.addWidget(self.points_edit) + + self.points_group.setVisible(False) + layout.addWidget(self.points_group) + + # Style group + style_group = QGroupBox("Style") + style_layout = QGridLayout(style_group) + + style_layout.addWidget(QLabel("Fill:"), 0, 0) + self.fill_btn = ColorButton("#4a9eff") + style_layout.addWidget(self.fill_btn, 0, 1) + + self.no_fill_check = QCheckBox("No fill") + self.no_fill_check.stateChanged.connect(self._on_fill_changed) + style_layout.addWidget(self.no_fill_check, 0, 2) + + style_layout.addWidget(QLabel("Stroke:"), 1, 0) + self.stroke_btn = ColorButton("#000000") + style_layout.addWidget(self.stroke_btn, 1, 1) + + style_layout.addWidget(QLabel("Width:"), 1, 2) + self.stroke_width_spin = QDoubleSpinBox() + self.stroke_width_spin.setRange(0, 100) + self.stroke_width_spin.setValue(1) + self.stroke_width_spin.setSingleStep(0.5) + style_layout.addWidget(self.stroke_width_spin, 1, 3) + + style_layout.addWidget(QLabel("Opacity:"), 2, 0) + self.opacity_spin = QDoubleSpinBox() + self.opacity_spin.setRange(0, 1) + self.opacity_spin.setValue(1) + self.opacity_spin.setSingleStep(0.1) + style_layout.addWidget(self.opacity_spin, 2, 1) + + layout.addWidget(style_group) + + # Buttons + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(cancel_btn) + + add_btn = QPushButton("Add Element") + add_btn.setDefault(True) + add_btn.clicked.connect(self.accept) + btn_layout.addWidget(add_btn) + + layout.addLayout(btn_layout) + + # Initial visibility + self._on_type_changed() + + def _on_type_changed(self): + """Update UI based on element type.""" + elem_type = self.type_combo.currentData() + + # Hide all type-specific groups + self.size_group.setVisible(False) + self.circle_group.setVisible(False) + self.ellipse_group.setVisible(False) + self.line_group.setVisible(False) + self.text_group.setVisible(False) + self.path_group.setVisible(False) + self.points_group.setVisible(False) + + if elem_type == 'rect': + self.size_group.setVisible(True) + elif elem_type == 'circle': + self.circle_group.setVisible(True) + elif elem_type == 'ellipse': + self.ellipse_group.setVisible(True) + elif elem_type == 'line': + self.line_group.setVisible(True) + self.no_fill_check.setChecked(True) + elif elem_type == 'text': + self.text_group.setVisible(True) + elif elem_type == 'path': + self.path_group.setVisible(True) + elif elem_type in ('polygon', 'polyline'): + self.points_group.setVisible(True) + if elem_type == 'polyline': + self.no_fill_check.setChecked(True) + + def _on_fill_changed(self): + if self.no_fill_check.isChecked(): + self.fill_btn.setEnabled(False) + else: + self.fill_btn.setEnabled(True) + + def get_element(self): + """Return the created SVGElement.""" + from .svg_parser import SVGElement, generate_element_id + + elem_type = self.type_combo.currentData() + + element = SVGElement( + element_type=elem_type, + id=generate_element_id(), + x=self.x_spin.value(), + y=self.y_spin.value(), + fill='none' if self.no_fill_check.isChecked() else self.fill_btn.color(), + stroke=self.stroke_btn.color(), + stroke_width=self.stroke_width_spin.value(), + opacity=self.opacity_spin.value(), + ) + + if elem_type == 'rect': + element.width = self.width_spin.value() + element.height = self.height_spin.value() + + elif elem_type == 'circle': + element.cx = self.cx_spin.value() + element.cy = self.cy_spin.value() + element.r = self.r_spin.value() + + elif elem_type == 'ellipse': + element.cx = self.ecx_spin.value() + element.cy = self.ecy_spin.value() + element.rx = self.rx_spin.value() + element.ry = self.ry_spin.value() + + elif elem_type == 'line': + element.x1 = self.x1_spin.value() + element.y1 = self.y1_spin.value() + element.x2 = self.x2_spin.value() + element.y2 = self.y2_spin.value() + + elif elem_type == 'text': + element.text = self.text_edit.toPlainText() + element.font_family = self.font_family_combo.currentData() + element.font_size = self.font_size_spin.value() + element.text_anchor = self.text_anchor_combo.currentData() + # Text uses fill for color, not stroke + if not self.no_fill_check.isChecked(): + element.fill = self.fill_btn.color() + + elif elem_type == 'path': + element.d = self.path_edit.toPlainText().strip() + + elif elem_type in ('polygon', 'polyline'): + element.points = self.points_edit.toPlainText().strip() + + return element + + +class EditSVGElementDialog(QDialog): + """Dialog for editing an existing SVG element.""" + + def __init__(self, element, parent=None): + super().__init__(parent) + self.element = element + self.setWindowTitle(f"Edit SVG {element.element_type.title()}") + self.setMinimumWidth(450) + self._setup_ui() + self._populate_fields() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + from .svg_parser import SVG_FONT_FAMILIES, TEXT_ANCHORS + + elem_type = self.element.element_type + + # Type display (read-only) + type_layout = QHBoxLayout() + type_layout.addWidget(QLabel("Type:")) + type_label = QLabel(f"{elem_type.title()}") + type_layout.addWidget(type_label) + type_layout.addStretch() + layout.addLayout(type_layout) + + # Position (for most elements) + if elem_type in ('rect', 'text'): + pos_group = QGroupBox("Position") + pos_layout = QGridLayout(pos_group) + + pos_layout.addWidget(QLabel("X:"), 0, 0) + self.x_spin = QSpinBox() + self.x_spin.setRange(-10000, 10000) + pos_layout.addWidget(self.x_spin, 0, 1) + + pos_layout.addWidget(QLabel("Y:"), 0, 2) + self.y_spin = QSpinBox() + self.y_spin.setRange(-10000, 10000) + pos_layout.addWidget(self.y_spin, 0, 3) + + layout.addWidget(pos_group) + else: + self.x_spin = None + self.y_spin = None + + # Size (for rect) + if elem_type == 'rect': + size_group = QGroupBox("Size") + size_layout = QGridLayout(size_group) + + size_layout.addWidget(QLabel("Width:"), 0, 0) + self.width_spin = QSpinBox() + self.width_spin.setRange(1, 10000) + size_layout.addWidget(self.width_spin, 0, 1) + + size_layout.addWidget(QLabel("Height:"), 0, 2) + self.height_spin = QSpinBox() + self.height_spin.setRange(1, 10000) + size_layout.addWidget(self.height_spin, 0, 3) + + layout.addWidget(size_group) + else: + self.width_spin = None + self.height_spin = None + + # Circle + if elem_type == 'circle': + circle_group = QGroupBox("Circle") + circle_layout = QGridLayout(circle_group) + + circle_layout.addWidget(QLabel("Center X:"), 0, 0) + self.cx_spin = QSpinBox() + self.cx_spin.setRange(-10000, 10000) + circle_layout.addWidget(self.cx_spin, 0, 1) + + circle_layout.addWidget(QLabel("Center Y:"), 0, 2) + self.cy_spin = QSpinBox() + self.cy_spin.setRange(-10000, 10000) + circle_layout.addWidget(self.cy_spin, 0, 3) + + circle_layout.addWidget(QLabel("Radius:"), 1, 0) + self.r_spin = QSpinBox() + self.r_spin.setRange(1, 10000) + circle_layout.addWidget(self.r_spin, 1, 1) + + layout.addWidget(circle_group) + else: + self.cx_spin = None + self.cy_spin = None + self.r_spin = None + + # Ellipse + if elem_type == 'ellipse': + ellipse_group = QGroupBox("Ellipse") + ellipse_layout = QGridLayout(ellipse_group) + + ellipse_layout.addWidget(QLabel("Center X:"), 0, 0) + self.ecx_spin = QSpinBox() + self.ecx_spin.setRange(-10000, 10000) + ellipse_layout.addWidget(self.ecx_spin, 0, 1) + + ellipse_layout.addWidget(QLabel("Center Y:"), 0, 2) + self.ecy_spin = QSpinBox() + self.ecy_spin.setRange(-10000, 10000) + ellipse_layout.addWidget(self.ecy_spin, 0, 3) + + ellipse_layout.addWidget(QLabel("Radius X:"), 1, 0) + self.rx_spin = QSpinBox() + self.rx_spin.setRange(1, 10000) + ellipse_layout.addWidget(self.rx_spin, 1, 1) + + ellipse_layout.addWidget(QLabel("Radius Y:"), 1, 2) + self.ry_spin = QSpinBox() + self.ry_spin.setRange(1, 10000) + ellipse_layout.addWidget(self.ry_spin, 1, 3) + + layout.addWidget(ellipse_group) + else: + self.ecx_spin = None + self.ecy_spin = None + self.rx_spin = None + self.ry_spin = None + + # Line + if elem_type == 'line': + line_group = QGroupBox("Line") + line_layout = QGridLayout(line_group) + + line_layout.addWidget(QLabel("X1:"), 0, 0) + self.x1_spin = QSpinBox() + self.x1_spin.setRange(-10000, 10000) + line_layout.addWidget(self.x1_spin, 0, 1) + + line_layout.addWidget(QLabel("Y1:"), 0, 2) + self.y1_spin = QSpinBox() + self.y1_spin.setRange(-10000, 10000) + line_layout.addWidget(self.y1_spin, 0, 3) + + line_layout.addWidget(QLabel("X2:"), 1, 0) + self.x2_spin = QSpinBox() + self.x2_spin.setRange(-10000, 10000) + line_layout.addWidget(self.x2_spin, 1, 1) + + line_layout.addWidget(QLabel("Y2:"), 1, 2) + self.y2_spin = QSpinBox() + self.y2_spin.setRange(-10000, 10000) + line_layout.addWidget(self.y2_spin, 1, 3) + + layout.addWidget(line_group) + else: + self.x1_spin = None + self.y1_spin = None + self.x2_spin = None + self.y2_spin = None + + # Text + if elem_type == 'text': + text_group = QGroupBox("Text") + text_layout = QVBoxLayout(text_group) + + self.text_edit = QPlainTextEdit() + self.text_edit.setMaximumHeight(60) + text_layout.addWidget(self.text_edit) + + text_props = QHBoxLayout() + text_props.addWidget(QLabel("Font:")) + self.font_family_combo = QComboBox() + for font_id, font_name in SVG_FONT_FAMILIES: + self.font_family_combo.addItem(font_name, font_id) + text_props.addWidget(self.font_family_combo) + + text_props.addWidget(QLabel("Size:")) + self.font_size_spin = QSpinBox() + self.font_size_spin.setRange(8, 200) + text_props.addWidget(self.font_size_spin) + + text_props.addWidget(QLabel("Align:")) + self.text_anchor_combo = QComboBox() + for anchor_id, anchor_name in TEXT_ANCHORS: + self.text_anchor_combo.addItem(anchor_name, anchor_id) + text_props.addWidget(self.text_anchor_combo) + + text_layout.addLayout(text_props) + layout.addWidget(text_group) + else: + self.text_edit = None + self.font_family_combo = None + self.font_size_spin = None + self.text_anchor_combo = None + + # Path + if elem_type == 'path': + path_group = QGroupBox("Path") + path_layout = QVBoxLayout(path_group) + + path_layout.addWidget(QLabel("Path data (d attribute):")) + self.path_edit = QPlainTextEdit() + self.path_edit.setMaximumHeight(80) + path_layout.addWidget(self.path_edit) + + layout.addWidget(path_group) + else: + self.path_edit = None + + # Polygon/Polyline + if elem_type in ('polygon', 'polyline'): + points_group = QGroupBox("Points") + points_layout = QVBoxLayout(points_group) + + points_layout.addWidget(QLabel("Points (x1,y1 x2,y2 ...):")) + self.points_edit = QPlainTextEdit() + self.points_edit.setMaximumHeight(80) + points_layout.addWidget(self.points_edit) + + layout.addWidget(points_group) + else: + self.points_edit = None + + # Style group + style_group = QGroupBox("Style") + style_layout = QGridLayout(style_group) + + style_layout.addWidget(QLabel("Fill:"), 0, 0) + self.fill_btn = ColorButton("#4a9eff") + style_layout.addWidget(self.fill_btn, 0, 1) + + self.no_fill_check = QCheckBox("No fill") + self.no_fill_check.stateChanged.connect(self._on_fill_changed) + style_layout.addWidget(self.no_fill_check, 0, 2) + + style_layout.addWidget(QLabel("Stroke:"), 1, 0) + self.stroke_btn = ColorButton("#000000") + style_layout.addWidget(self.stroke_btn, 1, 1) + + style_layout.addWidget(QLabel("Width:"), 1, 2) + self.stroke_width_spin = QDoubleSpinBox() + self.stroke_width_spin.setRange(0, 100) + self.stroke_width_spin.setSingleStep(0.5) + style_layout.addWidget(self.stroke_width_spin, 1, 3) + + style_layout.addWidget(QLabel("Opacity:"), 2, 0) + self.opacity_spin = QDoubleSpinBox() + self.opacity_spin.setRange(0, 1) + self.opacity_spin.setSingleStep(0.1) + style_layout.addWidget(self.opacity_spin, 2, 1) + + layout.addWidget(style_group) + + # Buttons + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(cancel_btn) + + save_btn = QPushButton("Save Changes") + save_btn.setDefault(True) + save_btn.clicked.connect(self.accept) + btn_layout.addWidget(save_btn) + + layout.addLayout(btn_layout) + + def _populate_fields(self): + """Fill in current element values.""" + elem = self.element + + # Position + if self.x_spin: + self.x_spin.setValue(int(elem.x)) + self.y_spin.setValue(int(elem.y)) + + # Size + if self.width_spin: + self.width_spin.setValue(int(elem.width)) + self.height_spin.setValue(int(elem.height)) + + # Circle + if self.cx_spin: + self.cx_spin.setValue(int(elem.cx)) + self.cy_spin.setValue(int(elem.cy)) + self.r_spin.setValue(int(elem.r)) + + # Ellipse + if self.ecx_spin: + self.ecx_spin.setValue(int(elem.cx)) + self.ecy_spin.setValue(int(elem.cy)) + self.rx_spin.setValue(int(elem.rx)) + self.ry_spin.setValue(int(elem.ry)) + + # Line + if self.x1_spin: + self.x1_spin.setValue(int(elem.x1)) + self.y1_spin.setValue(int(elem.y1)) + self.x2_spin.setValue(int(elem.x2)) + self.y2_spin.setValue(int(elem.y2)) + + # Text + if self.text_edit: + self.text_edit.setPlainText(elem.text) + self.font_size_spin.setValue(elem.font_size) + for i in range(self.font_family_combo.count()): + if self.font_family_combo.itemData(i) == elem.font_family: + self.font_family_combo.setCurrentIndex(i) + break + for i in range(self.text_anchor_combo.count()): + if self.text_anchor_combo.itemData(i) == elem.text_anchor: + self.text_anchor_combo.setCurrentIndex(i) + break + + # Path + if self.path_edit: + self.path_edit.setPlainText(elem.d) + + # Points + if self.points_edit: + self.points_edit.setPlainText(elem.points) + + # Style + if elem.fill == 'none': + self.no_fill_check.setChecked(True) + self.fill_btn.setEnabled(False) + else: + self.fill_btn.setColor(elem.fill) + + self.stroke_btn.setColor(elem.stroke) + self.stroke_width_spin.setValue(elem.stroke_width) + self.opacity_spin.setValue(elem.opacity) + + def _on_fill_changed(self): + if self.no_fill_check.isChecked(): + self.fill_btn.setEnabled(False) + else: + self.fill_btn.setEnabled(True) + + def get_element(self): + """Return the updated SVGElement.""" + from .svg_parser import SVGElement + + elem_type = self.element.element_type + + element = SVGElement( + element_type=elem_type, + id=self.element.id, + fill='none' if self.no_fill_check.isChecked() else self.fill_btn.color(), + stroke=self.stroke_btn.color(), + stroke_width=self.stroke_width_spin.value(), + opacity=self.opacity_spin.value(), + ) + + if self.x_spin: + element.x = self.x_spin.value() + element.y = self.y_spin.value() + + if elem_type == 'rect': + element.width = self.width_spin.value() + element.height = self.height_spin.value() + + elif elem_type == 'circle': + element.cx = self.cx_spin.value() + element.cy = self.cy_spin.value() + element.r = self.r_spin.value() + + elif elem_type == 'ellipse': + element.cx = self.ecx_spin.value() + element.cy = self.ecy_spin.value() + element.rx = self.rx_spin.value() + element.ry = self.ry_spin.value() + + elif elem_type == 'line': + element.x1 = self.x1_spin.value() + element.y1 = self.y1_spin.value() + element.x2 = self.x2_spin.value() + element.y2 = self.y2_spin.value() + + elif elem_type == 'text': + element.text = self.text_edit.toPlainText() + element.font_family = self.font_family_combo.currentData() + element.font_size = self.font_size_spin.value() + element.text_anchor = self.text_anchor_combo.currentData() + + elif elem_type == 'path': + element.d = self.path_edit.toPlainText().strip() + + elif elem_type in ('polygon', 'polyline'): + element.points = self.points_edit.toPlainText().strip() + + return element diff --git a/src/artifact_editor/excalidraw_parser.py b/src/artifact_editor/excalidraw_parser.py new file mode 100644 index 0000000..9baa8b8 --- /dev/null +++ b/src/artifact_editor/excalidraw_parser.py @@ -0,0 +1,263 @@ +"""Parser for Excalidraw JSON format.""" + +import json +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional, Tuple + + +@dataclass +class ExcalidrawElement: + """Represents an Excalidraw element.""" + element_type: str # rectangle, ellipse, diamond, line, arrow, text, freedraw + id: str + x: float = 0 + y: float = 0 + width: float = 100 + height: float = 100 + stroke_color: str = "#000000" + background_color: str = "transparent" + stroke_width: int = 1 + roughness: int = 1 + fill_style: str = "hachure" + text: str = "" # For text elements + font_size: int = 20 + font_family: int = 1 # 1=hand-drawn, 2=normal, 3=monospace + points: List[List[float]] = field(default_factory=list) # For line/arrow/freedraw + # Position tracking for in-place replacement + raw_data: Dict[str, Any] = field(default_factory=dict) + + def get_label(self) -> str: + """Get a display label for this element.""" + if self.element_type == 'text': + preview = self.text[:20] + ('...' if len(self.text) > 20 else '') + return f"Text: {preview}" + elif self.element_type in ('line', 'arrow'): + return f"{self.element_type.title()}" + else: + return f"{self.element_type.title()} ({self.width:.0f}x{self.height:.0f})" + + def to_dict(self) -> Dict[str, Any]: + """Convert to Excalidraw JSON element format.""" + element = { + 'type': self.element_type, + 'id': self.id, + 'x': self.x, + 'y': self.y, + 'width': self.width, + 'height': self.height, + 'strokeColor': self.stroke_color, + 'backgroundColor': self.background_color, + 'strokeWidth': self.stroke_width, + 'roughness': self.roughness, + 'fillStyle': self.fill_style, + 'opacity': 100, + 'angle': 0, + 'isDeleted': False, + 'version': 1, + } + + if self.element_type == 'text': + element['text'] = self.text + element['fontSize'] = self.font_size + element['fontFamily'] = self.font_family + + if self.element_type in ('line', 'arrow', 'freedraw'): + element['points'] = self.points if self.points else [[0, 0], [self.width, self.height]] + + return element + + +def parse_excalidraw_elements(source: str) -> List[ExcalidrawElement]: + """Parse Excalidraw JSON and return list of elements. + + Args: + source: Excalidraw JSON string + + Returns: + List of ExcalidrawElement objects + """ + if not source.strip(): + return [] + + try: + data = json.loads(source) + except json.JSONDecodeError: + return [] + + # Handle different formats + if isinstance(data, list): + elements_data = data + else: + elements_data = data.get('elements', []) + + elements = [] + for el in elements_data: + if el.get('isDeleted', False): + continue + + element = ExcalidrawElement( + element_type=el.get('type', 'rectangle'), + id=el.get('id', ''), + x=el.get('x', 0), + y=el.get('y', 0), + width=el.get('width', 100), + height=el.get('height', 100), + stroke_color=el.get('strokeColor', '#000000'), + background_color=el.get('backgroundColor', 'transparent'), + stroke_width=el.get('strokeWidth', 1), + roughness=el.get('roughness', 1), + fill_style=el.get('fillStyle', 'hachure'), + text=el.get('text', ''), + font_size=el.get('fontSize', 20), + font_family=el.get('fontFamily', 1), + points=el.get('points', []), + raw_data=el, + ) + elements.append(element) + + return elements + + +def get_excalidraw_document(source: str) -> Dict[str, Any]: + """Parse and return the full Excalidraw document structure.""" + if not source.strip(): + return {'type': 'excalidraw', 'version': 2, 'elements': [], 'appState': {}} + + try: + data = json.loads(source) + if isinstance(data, list): + return {'type': 'excalidraw', 'version': 2, 'elements': data, 'appState': {}} + return data + except json.JSONDecodeError: + return {'type': 'excalidraw', 'version': 2, 'elements': [], 'appState': {}} + + +def add_excalidraw_element(source: str, element: ExcalidrawElement) -> str: + """Add a new element to the Excalidraw document. + + Args: + source: Current Excalidraw JSON + element: Element to add + + Returns: + Updated JSON string + """ + doc = get_excalidraw_document(source) + doc['elements'].append(element.to_dict()) + return json.dumps(doc, indent=2) + + +def update_excalidraw_element(source: str, element_id: str, updated: ExcalidrawElement) -> str: + """Update an existing element in the Excalidraw document. + + Args: + source: Current Excalidraw JSON + element_id: ID of element to update + updated: Updated element data + + Returns: + Updated JSON string + """ + doc = get_excalidraw_document(source) + + for i, el in enumerate(doc['elements']): + if el.get('id') == element_id: + # Preserve some original properties + new_el = updated.to_dict() + new_el['id'] = element_id # Keep original ID + # Preserve version and increment + new_el['version'] = el.get('version', 1) + 1 + doc['elements'][i] = new_el + break + + return json.dumps(doc, indent=2) + + +def delete_excalidraw_element(source: str, element_id: str) -> str: + """Delete an element from the Excalidraw document. + + Args: + source: Current Excalidraw JSON + element_id: ID of element to delete + + Returns: + Updated JSON string + """ + doc = get_excalidraw_document(source) + + # Option 1: Remove completely + doc['elements'] = [el for el in doc['elements'] if el.get('id') != element_id] + + # Option 2: Mark as deleted (preserves undo history) + # for el in doc['elements']: + # if el.get('id') == element_id: + # el['isDeleted'] = True + # break + + return json.dumps(doc, indent=2) + + +def generate_element_id() -> str: + """Generate a unique element ID.""" + import uuid + return str(uuid.uuid4())[:8] + + +# Color palette for Excalidraw (matches their UI) +EXCALIDRAW_COLORS = { + 'stroke': [ + ('#000000', 'Black'), + ('#343a40', 'Dark Gray'), + ('#495057', 'Gray'), + ('#c92a2a', 'Red'), + ('#a61e4d', 'Pink'), + ('#862e9c', 'Grape'), + ('#5f3dc4', 'Violet'), + ('#364fc7', 'Indigo'), + ('#1864ab', 'Blue'), + ('#0b7285', 'Cyan'), + ('#087f5b', 'Teal'), + ('#2b8a3e', 'Green'), + ('#5c940d', 'Lime'), + ('#e67700', 'Yellow'), + ('#d9480f', 'Orange'), + ], + 'background': [ + ('transparent', 'None'), + ('#ffe3e3', 'Light Red'), + ('#fcc2d7', 'Light Pink'), + ('#eebefa', 'Light Grape'), + ('#d0bfff', 'Light Violet'), + ('#bac8ff', 'Light Indigo'), + ('#a5d8ff', 'Light Blue'), + ('#99e9f2', 'Light Cyan'), + ('#96f2d7', 'Light Teal'), + ('#b2f2bb', 'Light Green'), + ('#d8f5a2', 'Light Lime'), + ('#ffec99', 'Light Yellow'), + ('#ffd8a8', 'Light Orange'), + ('#ffffff', 'White'), + ('#f8f9fa', 'Off White'), + ], +} + +EXCALIDRAW_ELEMENT_TYPES = [ + ('rectangle', 'Rectangle'), + ('ellipse', 'Ellipse'), + ('diamond', 'Diamond'), + ('line', 'Line'), + ('arrow', 'Arrow'), + ('text', 'Text'), +] + +FILL_STYLES = [ + ('hachure', 'Hachure'), + ('cross-hatch', 'Cross-Hatch'), + ('solid', 'Solid'), +] + +FONT_FAMILIES = [ + (1, 'Hand-drawn'), + (2, 'Normal'), + (3, 'Monospace'), +] diff --git a/src/artifact_editor/gui.py b/src/artifact_editor/gui.py index 927f716..24f61b0 100644 --- a/src/artifact_editor/gui.py +++ b/src/artifact_editor/gui.py @@ -187,176 +187,52 @@ class RenderThread(QThread): class DictateThread(QThread): """Background thread for voice dictation.""" finished = pyqtSignal(str) # transcribed text + tick = pyqtSignal(int) # seconds remaining def __init__(self, duration: int = 10): super().__init__() self.duration = duration def run(self): + import time try: dictate_path = Path.home() / ".local" / "bin" / "dictate" if not dictate_path.exists(): self.finished.emit("[Error: dictate tool not found]") return - result = subprocess.run( + # Start the dictate process + import subprocess as sp + proc = sp.Popen( [str(dictate_path), "--duration", str(self.duration)], - capture_output=True, text=True, timeout=self.duration + 30 + stdout=sp.PIPE, stderr=sp.PIPE, text=True ) - transcript = result.stdout.strip() + # Emit countdown ticks while waiting + for remaining in range(self.duration, 0, -1): + self.tick.emit(remaining) + time.sleep(1) + if proc.poll() is not None: + break # Process finished early + + self.tick.emit(0) + + # Wait for process to finish (with timeout) + try: + stdout, stderr = proc.communicate(timeout=5) + except sp.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + + transcript = stdout.strip() if transcript: self.finished.emit(transcript) else: self.finished.emit("[No speech detected]") - except subprocess.TimeoutExpired: - self.finished.emit("[Dictation timed out]") except Exception as e: self.finished.emit(f"[Error: {e}]") -# Format-specific AI prompts with syntax examples -AI_PROMPTS = { - 'plantuml': """You are a PlantUML expert. Generate valid PlantUML code. - -PlantUML syntax rules: -- Must start with @startuml and end with @enduml -- For class diagrams: class ClassName { +method() -field } -- For sequence: participant A -> B : message -- For components: [Component] --> [Other] -- For activity: start, :action;, if (cond) then, endif, stop - -Example: -```plantuml -@startuml -class User { - +name: String - +login() -} -class Order { - +items: List - +total(): Float -} -User --> Order : places -@enduml -```""", - - 'mermaid': """You are a Mermaid diagram expert. Generate valid Mermaid code. - -Mermaid syntax rules: -- Flowchart: graph TD or graph LR, then A[Text] --> B{Decision} -- Sequence: sequenceDiagram, then participant A, A->>B: message -- Class: classDiagram, then class Name { +method() } -- State: stateDiagram-v2, then [*] --> State1 - -Example: -```mermaid -graph TD - A[Start] --> B{Is valid?} - B -->|Yes| C[Process] - B -->|No| D[Error] - C --> E[End] - D --> E -```""", - - 'openscad': """You are an OpenSCAD 3D modeling expert. Generate valid OpenSCAD code. - -OpenSCAD syntax rules: -- 3D primitives: cube([x,y,z]), sphere(r), cylinder(h, r1, r2) -- 2D primitives: circle(r), square([x,y]), polygon(points) -- Transforms: translate([x,y,z]), rotate([x,y,z]), scale([x,y,z]) -- Boolean: union(), difference(), intersection() -- Extrusion: linear_extrude(height), rotate_extrude() -- Modules: module name() { ... } - -Example: -```openscad -difference() { - cube([30, 30, 10], center=true); - translate([0, 0, 2]) - cylinder(h=10, r=8, center=true); -} -```""", - - 'code': """You are a code formatting expert. Generate clean, well-formatted code. - -Rules: -- Maintain proper indentation -- Use consistent style -- Include comments where helpful -- The code will be syntax highlighted for display - -Just output the code with no markdown fences unless the user specifically asks for a particular language.""", - - 'svg': """You are an SVG graphics expert. Generate valid SVG code. - -SVG syntax rules: -- Root element: -- Shapes: -- Circle: -- Ellipse: -- Line: -- Text: content -- Path: -- Groups: ... - -Example: -```svg - - - - - Hello SVG - -```""", - - 'excalidraw': """You are an Excalidraw JSON expert. Generate valid Excalidraw JSON. - -Excalidraw format: -- JSON object with "type": "excalidraw", "version": 2, "elements": [] -- Each element needs: type, id (8 char), x, y, width, height -- Element types: rectangle, ellipse, diamond, line, arrow, text -- Colors: strokeColor, backgroundColor (hex or "transparent") -- For text: include "text", "fontSize", "fontFamily" (1=hand-drawn, 2=normal, 3=mono) -- For lines/arrows: include "points" array like [[0,0], [100,50]] - -Example: -```json -{ - "type": "excalidraw", - "version": 2, - "elements": [ - { - "type": "rectangle", - "id": "rect001", - "x": 100, - "y": 100, - "width": 200, - "height": 100, - "strokeColor": "#1e88e5", - "backgroundColor": "#e3f2fd", - "strokeWidth": 1, - "roughness": 1, - "fillStyle": "hachure" - }, - { - "type": "text", - "id": "text001", - "x": 150, - "y": 140, - "width": 100, - "height": 25, - "text": "Hello", - "fontSize": 20, - "fontFamily": 1, - "strokeColor": "#1e88e5" - } - ] -} -```""" -} - - class AIThread(QThread): """Background thread for AI generation with validation and retry.""" finished = pyqtSignal(bool, str) # success, result/error @@ -381,114 +257,29 @@ class AIThread(QThread): return renderer.validate(code) return True, None # No validation available - def _build_prompt(self, retry_error: str = None, previous_output: str = None) -> str: - """Build the AI prompt, optionally including retry context.""" - format_guide = AI_PROMPTS.get(self.artifact_type, f"You are a {self.artifact_type} expert.") + def _call_ai(self, instruction: str, current_code: str) -> tuple: + """Call artifact-ai SmartTool and return (success, response_or_error). - # Format-specific output instructions - format_instructions = { - 'plantuml': "Output ONLY valid PlantUML code starting with @startuml and ending with @enduml.", - 'mermaid': "Output ONLY valid Mermaid code starting with the diagram type (graph, sequenceDiagram, etc.).", - 'openscad': "Output ONLY valid OpenSCAD code with proper syntax.", - 'svg': "Output ONLY valid SVG code starting with . Do NOT wrap in any other format.", - 'excalidraw': "Output ONLY valid Excalidraw JSON starting with { and ending with }. Do NOT wrap in any other format.", - 'code': "Output ONLY the code, no markdown fences or explanations.", - } - output_instruction = format_instructions.get(self.artifact_type, "Output ONLY the code, no explanations.") + Uses the artifact-ai SmartTool which handles prompt building and cleanup. + """ + tool_path = Path.home() / ".local" / "bin" / "artifact-ai" - if retry_error and previous_output: - # Retry prompt with error feedback - return f"""{format_guide} + if tool_path.exists(): + result = subprocess.run( + [str(tool_path), "--format", self.artifact_type, "--instruction", instruction], + input=current_code, + capture_output=True, + text=True, + timeout=120 + ) + if result.returncode == 0: + return True, result.stdout.strip() + else: + return False, f"artifact-ai error: {result.stderr}" -CRITICAL: {output_instruction} - -Original request: {self.instruction} - -Current code in editor: -``` -{self.current_code} -``` - -Your previous output: -``` -{previous_output} -``` - -However, validation failed with this error: -{retry_error} - -Please fix the error and provide corrected output. {output_instruction}""" - else: - # Initial prompt - return f"""{format_guide} - -CRITICAL: {output_instruction} - -Current code: -``` -{self.current_code} -``` - -User request: {self.instruction} - -{output_instruction}""" - - def _extract_code(self, response: str) -> str: - """Extract code from AI response, handling markdown fences and errant wrappers.""" - code = response.strip() - - # Remove markdown code fences - if "```" in code: - match = re.search(r'```(?:\w+)?\n(.*?)```', code, re.DOTALL) - if match: - code = match.group(1).strip() - - # For SVG: extract just the SVG content if wrapped in other formats - if self.artifact_type == 'svg': - # Remove any @startuml/@enduml wrappers - code = re.sub(r'^@startuml\s*\n?', '', code) - code = re.sub(r'\n?@enduml\s*$', '', code) - # Find the SVG content - svg_match = re.search(r'(<\?xml[^?]*\?>)?\s*()', code) - if svg_match: - xml_decl = svg_match.group(1) or '' - svg_content = svg_match.group(2) - code = (xml_decl + '\n' + svg_content).strip() if xml_decl else svg_content - - # For Excalidraw: extract just the JSON - elif self.artifact_type == 'excalidraw': - # Find JSON object - json_match = re.search(r'\{[\s\S]*\}', code) - if json_match: - code = json_match.group(0) - - # For PlantUML: ensure it has proper markers - elif self.artifact_type == 'plantuml': - if not code.strip().startswith('@start'): - code = '@startuml\n' + code - if not code.strip().endswith('@enduml') and '@enduml' not in code: - code = code + '\n@enduml' - - return code.strip() - - def _call_ai(self, prompt: str) -> tuple: - """Call AI and return (success, response_or_error).""" - # Only use discussion-diagram-editor for PlantUML (it's PlantUML-specific) - if self.artifact_type == 'plantuml': - tool_path = Path.home() / ".local" / "bin" / "discussion-diagram-editor" - if tool_path.exists(): - result = subprocess.run( - [str(tool_path), "--instruction", self.instruction], - input=self.current_code, - capture_output=True, - text=True, - timeout=60 - ) - if result.returncode == 0: - return True, result.stdout - - # Use claude directly for all formats + # Fallback: Use claude directly if SmartTool not available if subprocess.run(["which", "claude"], capture_output=True).returncode == 0: + prompt = f"You are a {self.artifact_type} expert. {instruction}\n\nCurrent code:\n{current_code}" result = subprocess.run( ["claude", "-p", "--model", "sonnet"], input=prompt, @@ -496,38 +287,32 @@ User request: {self.instruction} text=True, timeout=120 ) - if result.returncode == 0: - return True, result.stdout + return True, result.stdout.strip() - return False, "No AI tools available (claude CLI not found)" + return False, "No AI tools available (artifact-ai SmartTool or claude CLI not found)" def run(self): try: retry_count = 0 last_error = None - last_output = None + current_code = self.current_code while retry_count <= self.MAX_RETRIES: - # Build prompt (with error context if retrying) - prompt = self._build_prompt( - retry_error=last_error if retry_count > 0 else None, - previous_output=last_output if retry_count > 0 else None - ) - - if retry_count > 0: + # Build instruction (with error context if retrying) + if retry_count > 0 and last_error: + instruction = f"{self.instruction}\n\nPrevious attempt failed validation with: {last_error}\nPlease fix the error." self.status_update.emit(f"Retry {retry_count}/{self.MAX_RETRIES}: fixing validation error...") + else: + instruction = self.instruction - # Call AI - success, response = self._call_ai(prompt) + # Call AI via SmartTool + success, code = self._call_ai(instruction, current_code) if not success: - self.finished.emit(False, response) + self.finished.emit(False, code) return - # Extract code from response - code = self._extract_code(response) - # Validate output is_valid, error = self._validate_output(code) @@ -537,7 +322,7 @@ User request: {self.instruction} else: # Validation failed - prepare for retry last_error = error - last_output = code + current_code = code # Use the generated code for retry context retry_count += 1 if retry_count > self.MAX_RETRIES: @@ -958,10 +743,13 @@ class AIPanel(QWidget): def _on_dictate(self): self.dictate_requested.emit() - def set_dictating(self, is_dictating: bool): + def set_dictating(self, is_dictating: bool, seconds_remaining: int = 0): """Update UI state during dictation.""" self.dictate_btn.setEnabled(not is_dictating) - self.dictate_btn.setText("🎤 Listening..." if is_dictating else "🎤 Dictate") + if is_dictating: + self.dictate_btn.setText(f"🎤 {seconds_remaining}s") + else: + self.dictate_btn.setText("🎤 Dictate") def set_ai_processing(self, is_processing: bool): """Update UI state during AI processing.""" @@ -1619,15 +1407,15 @@ class ArtifactEditorWindow(QMainWindow): toolbar.addSeparator() - # Action buttons - render_action = QAction("Render", self) - render_action.triggered.connect(self._render_preview) - toolbar.addAction(render_action) - + # Action buttons (removed redundant Render - use Ctrl+R or button by preview) save_action = QAction("Save", self) save_action.triggered.connect(self._save) toolbar.addAction(save_action) + save_exit_action = QAction("Save && Exit", self) + save_exit_action.triggered.connect(self._save_and_exit) + toolbar.addAction(save_exit_action) + def _connect_signals(self): """Connect widget signals to slots.""" self.code_editor.textChanged.connect(self._on_text_changed) @@ -1700,6 +1488,20 @@ class ArtifactEditorWindow(QMainWindow): self.artifact_type = artifact_type self.renderer = get_renderer(artifact_type) + # Update output_path extension to match new format + format_to_ext = { + 'plantuml': '.puml', + 'mermaid': '.mmd', + 'svg': '.svg', + 'openscad': '.scad', + 'excalidraw': '.json', + 'code': '.txt', + } + if artifact_type in format_to_ext: + new_ext = format_to_ext[artifact_type] + self.output_path = self.output_path.with_suffix(new_ext) + self._update_title() + # Show/hide 3D rotation controls and enable drag rotation for OpenSCAD is_openscad = artifact_type == "openscad" self.rotation_panel.setVisible(is_openscad) @@ -1862,12 +1664,17 @@ class ArtifactEditorWindow(QMainWindow): if self.dictate_thread and self.dictate_thread.isRunning(): return - self.ai_panel.set_dictating(True) + self.ai_panel.set_dictating(True, 10) self.dictate_thread = DictateThread(duration=10) + self.dictate_thread.tick.connect(self._on_dictate_tick) self.dictate_thread.finished.connect(self._on_dictate_finished) self.dictate_thread.start() + def _on_dictate_tick(self, seconds_remaining: int): + """Update countdown during dictation.""" + self.ai_panel.set_dictating(True, seconds_remaining) + def _on_dictate_finished(self, transcript: str): """Handle dictation completion.""" self.ai_panel.set_dictating(False) @@ -2548,6 +2355,13 @@ class ArtifactEditorWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Save Error", f"Failed to save: {e}") + return False + return True + + def _save_and_exit(self): + """Save artifact and close the editor.""" + if self._save(): + self.close() def _save_as(self): """Save artifact to a new path.""" @@ -2776,8 +2590,10 @@ class ArtifactEditorWindow(QMainWindow): ) if reply == QMessageBox.StandardButton.Save: - self._save() - event.accept() + if self._save(): + event.accept() + else: + event.ignore() # Save failed, don't close elif reply == QMessageBox.StandardButton.Discard: event.accept() else: diff --git a/src/artifact_editor/renderers/__init__.py b/src/artifact_editor/renderers/__init__.py index dd74ed7..51482b5 100644 --- a/src/artifact_editor/renderers/__init__.py +++ b/src/artifact_editor/renderers/__init__.py @@ -78,3 +78,6 @@ def list_renderers() -> list[str]: from . import mermaid from . import openscad from . import plantuml +from . import code +from . import svg +from . import excalidraw diff --git a/src/artifact_editor/renderers/code.py b/src/artifact_editor/renderers/code.py new file mode 100644 index 0000000..1b2645b --- /dev/null +++ b/src/artifact_editor/renderers/code.py @@ -0,0 +1,452 @@ +"""Code syntax highlighting renderer using Pygments.""" + +import tempfile +import subprocess +from pathlib import Path +from typing import Optional, Tuple, List + +from . import Renderer, register_renderer + +try: + from pygments import highlight + from pygments.lexers import get_lexer_by_name, get_all_lexers, guess_lexer + from pygments.formatters import HtmlFormatter, SvgFormatter, ImageFormatter + from pygments.styles import get_all_styles, get_style_by_name + PYGMENTS_AVAILABLE = True +except ImportError: + PYGMENTS_AVAILABLE = False + + +# Popular languages to show at the top of the list +POPULAR_LANGUAGES = [ + ('python', 'Python'), + ('javascript', 'JavaScript'), + ('typescript', 'TypeScript'), + ('html', 'HTML'), + ('css', 'CSS'), + ('json', 'JSON'), + ('yaml', 'YAML'), + ('bash', 'Bash/Shell'), + ('sql', 'SQL'), + ('java', 'Java'), + ('c', 'C'), + ('cpp', 'C++'), + ('csharp', 'C#'), + ('go', 'Go'), + ('rust', 'Rust'), + ('ruby', 'Ruby'), + ('php', 'PHP'), + ('swift', 'Swift'), + ('kotlin', 'Kotlin'), + ('scala', 'Scala'), + ('r', 'R'), + ('matlab', 'MATLAB'), + ('markdown', 'Markdown'), + ('xml', 'XML'), + ('toml', 'TOML'), + ('ini', 'INI'), + ('dockerfile', 'Dockerfile'), + ('makefile', 'Makefile'), +] + +# Popular themes +POPULAR_THEMES = [ + 'monokai', + 'dracula', + 'github-dark', + 'one-dark', + 'nord', + 'gruvbox-dark', + 'solarized-dark', + 'solarized-light', + 'vs', + 'friendly', + 'default', +] + + +def is_available() -> bool: + """Check if Pygments is available.""" + return PYGMENTS_AVAILABLE + + +def get_languages() -> List[Tuple[str, str]]: + """Get list of available languages as (id, name) tuples.""" + if not PYGMENTS_AVAILABLE: + return [] + + # Start with popular languages + languages = list(POPULAR_LANGUAGES) + popular_ids = {lang[0] for lang in POPULAR_LANGUAGES} + + # Add all other languages + all_lexers = [] + for name, aliases, filetypes, mimetypes in get_all_lexers(): + if aliases: + lexer_id = aliases[0] + if lexer_id not in popular_ids: + all_lexers.append((lexer_id, name)) + + # Sort the rest alphabetically by name + all_lexers.sort(key=lambda x: x[1].lower()) + languages.extend(all_lexers) + + return languages + + +def get_themes() -> List[str]: + """Get list of available themes.""" + if not PYGMENTS_AVAILABLE: + return ['default'] + + # Start with popular themes that exist + all_styles = set(get_all_styles()) + themes = [t for t in POPULAR_THEMES if t in all_styles] + + # Add remaining themes + for style in sorted(all_styles): + if style not in themes: + themes.append(style) + + return themes + + +def render_to_html( + code: str, + language: str = 'python', + theme: str = 'monokai', + line_numbers: bool = True, + font_size: int = 14, +) -> str: + """Render code to HTML with syntax highlighting. + + Args: + code: Source code to highlight + language: Language identifier (e.g., 'python', 'javascript') + theme: Pygments style name + line_numbers: Whether to show line numbers + font_size: Font size in pixels + + Returns: + Complete HTML document with styled code + """ + if not PYGMENTS_AVAILABLE: + return f"
{code}
" + + try: + lexer = get_lexer_by_name(language) + except: + # Fall back to guessing or plain text + try: + lexer = guess_lexer(code) + except: + lexer = get_lexer_by_name('text') + + formatter = HtmlFormatter( + style=theme, + linenos='table' if line_numbers else False, + cssclass='highlight', + full=False, + ) + + highlighted = highlight(code, lexer, formatter) + css = formatter.get_style_defs('.highlight') + + # Build complete HTML + html = f""" + + + + + + +{highlighted} + +""" + + return html + + +def _get_bg_color(theme: str) -> str: + """Get background color for a theme.""" + if not PYGMENTS_AVAILABLE: + return '#1e1e1e' + + try: + style = get_style_by_name(theme) + bg = style.background_color + return bg if bg else '#1e1e1e' + except: + return '#1e1e1e' + + +def render_to_svg( + code: str, + language: str = 'python', + theme: str = 'monokai', + line_numbers: bool = True, + font_size: int = 14, +) -> str: + """Render code to SVG with syntax highlighting.""" + if not PYGMENTS_AVAILABLE: + return f'{code}' + + try: + lexer = get_lexer_by_name(language) + except: + try: + lexer = guess_lexer(code) + except: + lexer = get_lexer_by_name('text') + + formatter = SvgFormatter( + style=theme, + linenos=line_numbers, + fontsize=f'{font_size}px', + ) + + return highlight(code, lexer, formatter) + + +def render_to_png( + code: str, + language: str = 'python', + theme: str = 'monokai', + line_numbers: bool = True, + font_size: int = 14, + output_path: Optional[str] = None, +) -> Optional[str]: + """Render code to PNG image. + + Requires Pillow to be installed. + + Args: + code: Source code to highlight + language: Language identifier + theme: Pygments style name + line_numbers: Whether to show line numbers + font_size: Font size in pixels + output_path: Path to save PNG (uses temp file if None) + + Returns: + Path to the generated PNG file, or None on error + """ + if not PYGMENTS_AVAILABLE: + return None + + try: + from PIL import Image + except ImportError: + # Pillow not available, fall back to HTML screenshot or return None + return None + + try: + lexer = get_lexer_by_name(language) + except: + try: + lexer = guess_lexer(code) + except: + lexer = get_lexer_by_name('text') + + if output_path is None: + output_path = tempfile.mktemp(suffix='.png') + + formatter = ImageFormatter( + style=theme, + linenos=line_numbers, + font_size=font_size, + line_number_bg='#2d2d2d', + line_number_fg='#666666', + ) + + try: + with open(output_path, 'wb') as f: + f.write(highlight(code, lexer, formatter)) + return output_path + except Exception as e: + print(f"Error rendering PNG: {e}") + return None + + +def render( + code: str, + output_format: str = 'html', + language: str = 'python', + theme: str = 'monokai', + line_numbers: bool = True, + font_size: int = 14, + output_path: Optional[str] = None, +) -> Tuple[bool, str]: + """Render code to the specified format. + + Args: + code: Source code to highlight + output_format: Output format ('html', 'svg', 'png') + language: Language identifier + theme: Pygments style name + line_numbers: Whether to show line numbers + font_size: Font size + output_path: Path for output file + + Returns: + Tuple of (success, result_or_error) + """ + if not PYGMENTS_AVAILABLE: + return False, "Pygments is not installed. Run: pip install pygments" + + try: + if output_format == 'html': + html = render_to_html(code, language, theme, line_numbers, font_size) + if output_path: + Path(output_path).write_text(html) + return True, output_path + return True, html + + elif output_format == 'svg': + svg = render_to_svg(code, language, theme, line_numbers, font_size) + if output_path: + Path(output_path).write_text(svg) + return True, output_path + return True, svg + + elif output_format == 'png': + result = render_to_png(code, language, theme, line_numbers, font_size, output_path) + if result: + return True, result + return False, "PNG rendering failed. Make sure Pillow is installed." + + else: + return False, f"Unknown output format: {output_format}" + + except Exception as e: + return False, str(e) + + +def detect_language(code: str, filename: Optional[str] = None) -> str: + """Try to detect the language of the code. + + Args: + code: Source code + filename: Optional filename for better detection + + Returns: + Language identifier + """ + if not PYGMENTS_AVAILABLE: + return 'text' + + try: + if filename: + from pygments.lexers import get_lexer_for_filename + lexer = get_lexer_for_filename(filename, code) + else: + lexer = guess_lexer(code) + return lexer.aliases[0] if lexer.aliases else 'text' + except: + return 'text' + + +@register_renderer +class CodeRenderer(Renderer): + """Renderer for syntax-highlighted code snippets.""" + + name = "code" + extensions = [".html", ".svg", ".png"] + + def render( + self, + source: str, + output_path: Path, + code_options: Optional[dict] = None + ) -> Tuple[bool, str]: + """Render code to output file. + + Args: + source: Source code to highlight + output_path: Where to save the rendered output + code_options: Optional dict with keys: + - language: Language identifier (default: 'python') + - theme: Pygments style name (default: 'monokai') + - line_numbers: Show line numbers (default: True) + - font_size: Font size in pixels (default: 14) + """ + ext = output_path.suffix.lower() + format_map = { + '.html': 'html', + '.svg': 'svg', + '.png': 'png', + } + output_format = format_map.get(ext, 'html') + + # Extract options with defaults + options = code_options or {} + language = options.get('language', 'python') + theme = options.get('theme', 'monokai') + line_numbers = options.get('line_numbers', True) + font_size = options.get('font_size', 14) + + return render( + source, + output_format=output_format, + language=language, + theme=theme, + line_numbers=line_numbers, + font_size=font_size, + output_path=str(output_path), + ) + + def validate(self, source: str) -> Tuple[bool, Optional[str]]: + """Code is always valid - just text.""" + return True, None + + def get_template(self) -> str: + """Return a starter template.""" + return '''def hello_world(): + """A simple example function.""" + print("Hello, World!") + +if __name__ == "__main__": + hello_world() +''' + + def check_available(self) -> Tuple[bool, str]: + """Check if Pygments is available.""" + if PYGMENTS_AVAILABLE: + return True, "" + return False, "Pygments is not installed. Run: pip install pygments" diff --git a/src/artifact_editor/renderers/excalidraw.py b/src/artifact_editor/renderers/excalidraw.py new file mode 100644 index 0000000..42696e9 --- /dev/null +++ b/src/artifact_editor/renderers/excalidraw.py @@ -0,0 +1,454 @@ +"""Excalidraw renderer for hand-drawn style diagrams. + +Excalidraw uses a JSON format that this renderer converts to SVG for preview. +The hand-drawn style is simulated using rough edges and imperfect lines. +""" + +import json +import math +import random +import tempfile +from pathlib import Path +from typing import Optional, Tuple, List, Dict, Any + +from . import Renderer, register_renderer + + +def validate_excalidraw(source: str) -> Tuple[bool, Optional[str]]: + """Validate Excalidraw JSON source. + + Returns: + (is_valid, error_message) + """ + if not source.strip(): + return False, "Empty content" + + try: + data = json.loads(source) + + # Check for required fields + if 'type' not in data: + # It might be a raw elements array or have elements directly + if 'elements' not in data and not isinstance(data, list): + return False, "Missing 'type' or 'elements' field" + + return True, None + except json.JSONDecodeError as e: + return False, f"JSON parse error: {e}" + + +def parse_excalidraw(source: str) -> Dict[str, Any]: + """Parse Excalidraw JSON and return normalized structure.""" + data = json.loads(source) + + # Handle different formats + if isinstance(data, list): + # Raw elements array + return {'elements': data, 'appState': {}} + + if 'elements' not in data: + data['elements'] = [] + + if 'appState' not in data: + data['appState'] = {} + + return data + + +def rough_line(x1: float, y1: float, x2: float, y2: float, roughness: float = 1.0) -> str: + """Generate a rough/hand-drawn line path.""" + # Add slight waviness to simulate hand-drawing + dx = x2 - x1 + dy = y2 - y1 + length = math.sqrt(dx * dx + dy * dy) + + if length < 1: + return f"M {x1} {y1} L {x2} {y2}" + + # Perpendicular offset direction + px = -dy / length + py = dx / length + + # Create control points with small random offsets + segments = max(2, int(length / 30)) + points = [] + + for i in range(segments + 1): + t = i / segments + x = x1 + dx * t + y = y1 + dy * t + + if 0 < i < segments: + # Add roughness + offset = (random.random() - 0.5) * roughness * 2 + x += px * offset + y += py * offset + + points.append((x, y)) + + # Build path + path = f"M {points[0][0]:.1f} {points[0][1]:.1f}" + for x, y in points[1:]: + path += f" L {x:.1f} {y:.1f}" + + return path + + +def rough_rect(x: float, y: float, width: float, height: float, roughness: float = 1.0) -> str: + """Generate a rough/hand-drawn rectangle path.""" + paths = [ + rough_line(x, y, x + width, y, roughness), + rough_line(x + width, y, x + width, y + height, roughness), + rough_line(x + width, y + height, x, y + height, roughness), + rough_line(x, y + height, x, y, roughness), + ] + return " ".join(paths) + + +def rough_ellipse(cx: float, cy: float, rx: float, ry: float, roughness: float = 1.0) -> str: + """Generate a rough/hand-drawn ellipse path.""" + points = [] + segments = 24 + + for i in range(segments + 1): + angle = (i / segments) * 2 * math.pi + x = cx + rx * math.cos(angle) + y = cy + ry * math.sin(angle) + + if i > 0 and i < segments: + # Add roughness perpendicular to the radius + offset = (random.random() - 0.5) * roughness * 2 + x += offset * math.cos(angle) + y += offset * math.sin(angle) + + points.append((x, y)) + + path = f"M {points[0][0]:.1f} {points[0][1]:.1f}" + for x, y in points[1:]: + path += f" L {x:.1f} {y:.1f}" + path += " Z" + + return path + + +def rough_diamond(x: float, y: float, width: float, height: float, roughness: float = 1.0) -> str: + """Generate a rough/hand-drawn diamond path.""" + cx, cy = x + width / 2, y + height / 2 + paths = [ + rough_line(cx, y, x + width, cy, roughness), + rough_line(x + width, cy, cx, y + height, roughness), + rough_line(cx, y + height, x, cy, roughness), + rough_line(x, cy, cx, y, roughness), + ] + return " ".join(paths) + + +def element_to_svg(element: Dict[str, Any], seed: int = 42) -> str: + """Convert an Excalidraw element to SVG.""" + random.seed(seed + hash(element.get('id', ''))) + + el_type = element.get('type', '') + x = element.get('x', 0) + y = element.get('y', 0) + width = element.get('width', 100) + height = element.get('height', 100) + + stroke_color = element.get('strokeColor', '#000000') + bg_color = element.get('backgroundColor', 'transparent') + fill_style = element.get('fillStyle', 'hachure') + stroke_width = element.get('strokeWidth', 1) + roughness = element.get('roughness', 1) + + # Handle fill + fill = 'none' + if bg_color != 'transparent': + fill = bg_color + + # Common style for shapes with fill + style = f'stroke="{stroke_color}" stroke-width="{stroke_width}" fill="{fill}"' + # Style for lines (no fill) + line_style = f'stroke="{stroke_color}" stroke-width="{stroke_width}" fill="none"' + + if el_type == 'rectangle': + path = rough_rect(x, y, width, height, roughness) + return f'' + + elif el_type == 'ellipse': + rx, ry = width / 2, height / 2 + cx, cy = x + rx, y + ry + path = rough_ellipse(cx, cy, rx, ry, roughness) + return f'' + + elif el_type == 'diamond': + path = rough_diamond(x, y, width, height, roughness) + return f'' + + elif el_type == 'line' or el_type == 'arrow': + points = element.get('points', [[0, 0], [width, height]]) + if len(points) < 2: + return '' + + # Build path from points + path_parts = [] + for i, point in enumerate(points): + px, py = x + point[0], y + point[1] + if i == 0: + path_parts.append(f"M {px:.1f} {py:.1f}") + else: + # Add roughness between points + prev = points[i - 1] + prev_x, prev_y = x + prev[0], y + prev[1] + rough_path = rough_line(prev_x, prev_y, px, py, roughness) + # Skip the M command from rough_line + path_parts.append(rough_path.split(' ', 3)[3] if ' ' in rough_path else f"L {px:.1f} {py:.1f}") + + path = " ".join(path_parts) + + result = f'' + + # Add arrowhead for arrows + if el_type == 'arrow' and len(points) >= 2: + end = points[-1] + prev = points[-2] if len(points) > 1 else [0, 0] + + end_x, end_y = x + end[0], y + end[1] + dx = end[0] - prev[0] + dy = end[1] - prev[1] + length = math.sqrt(dx * dx + dy * dy) + + if length > 0: + # Arrowhead points + arrow_size = 10 + angle = math.atan2(dy, dx) + a1 = angle + math.pi * 0.8 + a2 = angle - math.pi * 0.8 + + ax1 = end_x + arrow_size * math.cos(a1) + ay1 = end_y + arrow_size * math.sin(a1) + ax2 = end_x + arrow_size * math.cos(a2) + ay2 = end_y + arrow_size * math.sin(a2) + + result += f'\n ' + + return result + + elif el_type == 'text': + text = element.get('text', '') + font_size = element.get('fontSize', 20) + font_family = element.get('fontFamily', 1) # 1 = hand-drawn, 2 = normal, 3 = monospace + + font_map = { + 1: "'Virgil', 'Comic Sans MS', cursive", + 2: "'Helvetica', 'Arial', sans-serif", + 3: "'Cascadia', 'Consolas', monospace", + } + font = font_map.get(font_family, font_map[1]) + + # Handle multi-line text + lines = text.split('\n') + result = f'' + for i, line in enumerate(lines): + ty = font_size * (i + 1) + # Escape any special characters in the line + escaped_line = line.replace('&', '&').replace('<', '<').replace('>', '>') + result += f'\n {escaped_line}' + result += '\n' + return result + + elif el_type == 'freedraw': + points = element.get('points', []) + if len(points) < 2: + return '' + + path = f"M {x + points[0][0]:.1f} {y + points[0][1]:.1f}" + for point in points[1:]: + path += f" L {x + point[0]:.1f} {y + point[1]:.1f}" + + return f'' + + return '' + + +def render_to_svg(source: str) -> str: + """Render Excalidraw JSON to SVG.""" + data = parse_excalidraw(source) + elements = data.get('elements', []) + + if not elements: + return ''' + + + Empty canvas +''' + + # Calculate bounds + min_x, min_y = float('inf'), float('inf') + max_x, max_y = float('-inf'), float('-inf') + + for el in elements: + if el.get('isDeleted', False): + continue + x = el.get('x', 0) + y = el.get('y', 0) + w = el.get('width', 0) + h = el.get('height', 0) + + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x + w) + max_y = max(max_y, y + h) + + # Add padding + padding = 50 + min_x -= padding + min_y -= padding + max_x += padding + max_y += padding + + width = max(max_x - min_x, 100) + height = max(max_y - min_y, 100) + + # Build SVG + svg_parts = [ + f'', + f'', + f' ', + ] + + # Render elements (sorted by layer order if available) + sorted_elements = sorted( + [e for e in elements if not e.get('isDeleted', False)], + key=lambda e: (e.get('groupIds', []) or [''])[0] + str(e.get('id', '')) + ) + + for el in sorted_elements: + svg = element_to_svg(el) + if svg: + svg_parts.append(f' {svg}') + + svg_parts.append('') + return '\n'.join(svg_parts) + + +def create_element( + element_type: str, + x: float = 0, + y: float = 0, + width: float = 100, + height: float = 100, + **kwargs +) -> Dict[str, Any]: + """Create an Excalidraw element dict.""" + import uuid + + element = { + 'type': element_type, + 'id': str(uuid.uuid4())[:8], + 'x': x, + 'y': y, + 'width': width, + 'height': height, + 'strokeColor': kwargs.get('strokeColor', '#000000'), + 'backgroundColor': kwargs.get('backgroundColor', 'transparent'), + 'fillStyle': kwargs.get('fillStyle', 'hachure'), + 'strokeWidth': kwargs.get('strokeWidth', 1), + 'roughness': kwargs.get('roughness', 1), + 'opacity': kwargs.get('opacity', 100), + 'angle': kwargs.get('angle', 0), + 'isDeleted': False, + 'version': 1, + } + + if element_type == 'text': + element['text'] = kwargs.get('text', 'Text') + element['fontSize'] = kwargs.get('fontSize', 20) + element['fontFamily'] = kwargs.get('fontFamily', 1) + + if element_type in ('line', 'arrow'): + element['points'] = kwargs.get('points', [[0, 0], [width, height]]) + element['width'] = 0 + element['height'] = 0 + + if element_type == 'freedraw': + element['points'] = kwargs.get('points', []) + + return element + + +def create_excalidraw_doc(elements: List[Dict[str, Any]], app_state: Dict[str, Any] = None) -> Dict[str, Any]: + """Create a full Excalidraw document.""" + return { + 'type': 'excalidraw', + 'version': 2, + 'source': 'artifact-editor', + 'elements': elements, + 'appState': app_state or { + 'viewBackgroundColor': '#ffffff', + 'currentItemStrokeColor': '#000000', + 'currentItemBackgroundColor': 'transparent', + }, + } + + +@register_renderer +class ExcalidrawRenderer(Renderer): + """Renderer for Excalidraw hand-drawn diagrams.""" + + name = "excalidraw" + extensions = [".excalidraw", ".svg", ".png"] + + def render(self, source: str, output_path: Path) -> Tuple[bool, str]: + """Render Excalidraw to output file.""" + # Validate + is_valid, error = validate_excalidraw(source) + if not is_valid: + return False, error or "Invalid Excalidraw format" + + ext = output_path.suffix.lower() + + try: + svg_content = render_to_svg(source) + + if ext == '.svg': + output_path.write_text(svg_content) + return True, str(output_path) + + elif ext == '.excalidraw': + # Save as Excalidraw JSON + output_path.write_text(source) + return True, str(output_path) + + elif ext == '.png': + # Try to convert SVG to PNG + from .svg import svg_to_png + if svg_to_png(svg_content, str(output_path)): + return True, str(output_path) + else: + # Fallback: save as SVG + svg_path = output_path.with_suffix('.svg') + svg_path.write_text(svg_content) + return True, str(svg_path) + + return False, f"Unsupported format: {ext}" + + except Exception as e: + return False, str(e) + + def validate(self, source: str) -> Tuple[bool, Optional[str]]: + """Validate Excalidraw JSON syntax.""" + return validate_excalidraw(source) + + def get_template(self) -> str: + """Return a starter Excalidraw template.""" + elements = [ + create_element('rectangle', 50, 50, 200, 100, strokeColor='#1e88e5', backgroundColor='#e3f2fd'), + create_element('text', 100, 90, text='Hello!', fontSize=24, strokeColor='#1e88e5'), + create_element('ellipse', 300, 50, 120, 100, strokeColor='#43a047', backgroundColor='#e8f5e9'), + create_element('arrow', 260, 100, points=[[0, 0], [30, 0]], strokeColor='#666666'), + ] + doc = create_excalidraw_doc(elements) + return json.dumps(doc, indent=2) + + def check_available(self) -> Tuple[bool, str]: + """Excalidraw is always available (pure Python rendering).""" + return True, "" diff --git a/src/artifact_editor/renderers/svg.py b/src/artifact_editor/renderers/svg.py new file mode 100644 index 0000000..918a546 --- /dev/null +++ b/src/artifact_editor/renderers/svg.py @@ -0,0 +1,530 @@ +"""SVG renderer for direct vector graphics editing.""" + +import re +import tempfile +import subprocess +from pathlib import Path +from typing import Optional, Tuple, List, Dict, Any +from xml.etree import ElementTree as ET + +from . import Renderer, register_renderer + + +def validate_svg(source: str) -> Tuple[bool, Optional[str]]: + """Validate SVG source. + + Returns: + (is_valid, error_message) + """ + if not source.strip(): + return False, "Empty SVG" + + # Check for SVG tag + if ' element" + + try: + # Try to parse as XML + ET.fromstring(source) + return True, None + except ET.ParseError as e: + return False, f"XML parse error: {e}" + + +def create_element( + element_type: str, + x: float = 0, + y: float = 0, + width: float = 100, + height: float = 50, + **attrs +) -> str: + """Create an SVG element. + + Args: + element_type: rect, circle, ellipse, line, text, path, etc. + x, y: Position + width, height: Size (for rect, ellipse) + **attrs: Additional attributes (fill, stroke, etc.) + + Returns: + SVG element string + """ + if element_type == 'rect': + base = f'{text_content}
' + elif element_type == 'path': + d = attrs.pop('d', f'M {x} {y} L {x+width} {y+height}') + base = f' stroke-width + base += f' {key}="{val}"' + + base += '/>' + return base + + +def create_group(elements: List[str], **attrs) -> str: + """Create an SVG group () containing multiple elements.""" + attr_str = '' + for key, val in attrs.items(): + key = key.replace('_', '-') + attr_str += f' {key}="{val}"' + + content = '\n '.join(elements) + return f'\n {content}\n' + + +# UI Wireframe Components +def create_button( + x: float, y: float, + text: str = "Button", + width: float = 100, + height: float = 36, + style: str = "primary" +) -> str: + """Create a button wireframe element.""" + styles = { + 'primary': {'fill': '#3b82f6', 'text_fill': '#ffffff'}, + 'secondary': {'fill': '#6b7280', 'text_fill': '#ffffff'}, + 'outline': {'fill': 'none', 'stroke': '#3b82f6', 'text_fill': '#3b82f6'}, + 'ghost': {'fill': 'none', 'text_fill': '#3b82f6'}, + } + s = styles.get(style, styles['primary']) + + elements = [ + f'', + f'{text}' + ] + return create_group(elements, id=f'button-{text.lower().replace(" ", "-")}') + + +def create_input( + x: float, y: float, + placeholder: str = "Enter text...", + width: float = 200, + height: float = 40, + label: str = "" +) -> str: + """Create an input field wireframe element.""" + elements = [] + + label_offset = 0 + if label: + elements.append( + f'{label}' + ) + + elements.extend([ + f'', + f'{placeholder}' + ]) + + return create_group(elements, id=f'input-{label.lower().replace(" ", "-") or "field"}') + + +def create_card( + x: float, y: float, + title: str = "Card Title", + content: str = "Card content goes here", + width: float = 300, + height: float = 150 +) -> str: + """Create a card wireframe element.""" + elements = [ + f'', + f'{title}', + f'', + f'{content}' + ] + return create_group(elements, id=f'card-{title.lower().replace(" ", "-")}') + + +def create_navbar( + x: float, y: float, + brand: str = "Logo", + links: List[str] = None, + width: float = 800, + height: float = 60 +) -> str: + """Create a navigation bar wireframe.""" + links = links or ["Home", "About", "Contact"] + + elements = [ + f'', + f'{brand}' + ] + + # Add nav links + link_x = x + width - 20 + for link in reversed(links): + link_x -= len(link) * 10 + 20 + elements.append( + f'{link}' + ) + + return create_group(elements, id='navbar') + + +def create_modal( + x: float, y: float, + title: str = "Modal Title", + width: float = 400, + height: float = 250 +) -> str: + """Create a modal dialog wireframe.""" + elements = [ + # Backdrop (semi-transparent) + f'', + # Modal box + f'', + # Header + f'{title}', + # Close button + f'×', + # Divider + f'', + # Content area placeholder + f'Modal content...', + # Footer buttons + f'', + f'Cancel', + f'', + f'Confirm', + ] + return create_group(elements, id='modal') + + +def create_icon( + x: float, y: float, + icon_type: str = "placeholder", + size: float = 24 +) -> str: + """Create an icon placeholder.""" + icons = { + 'placeholder': f'', + 'user': f'' + f'', + 'menu': f'' + f'' + f'', + 'search': f'' + f'', + } + return icons.get(icon_type, icons['placeholder']) + + +def create_checkbox( + x: float, y: float, + label: str = "Option", + checked: bool = False, + size: float = 20 +) -> str: + """Create a checkbox wireframe.""" + elements = [ + f'', + ] + if checked: + elements.append( + f'' + ) + elements.append( + f'{label}' + ) + return create_group(elements) + + +def create_radio( + x: float, y: float, + label: str = "Option", + selected: bool = False, + size: float = 20 +) -> str: + """Create a radio button wireframe.""" + r = size / 2 + elements = [ + f'', + ] + if selected: + elements.append( + f'' + ) + elements.append( + f'{label}' + ) + return create_group(elements) + + +def create_toggle( + x: float, y: float, + label: str = "", + on: bool = False, + width: float = 44, + height: float = 24 +) -> str: + """Create a toggle switch wireframe.""" + r = height / 2 - 2 + knob_x = x + width - r - 4 if on else x + r + 4 + + elements = [ + f'', + f'', + ] + if label: + elements.append( + f'{label}' + ) + return create_group(elements) + + +def create_avatar( + x: float, y: float, + initials: str = "", + size: float = 40 +) -> str: + """Create an avatar placeholder.""" + elements = [ + f'', + ] + if initials: + elements.append( + f'{initials[:2].upper()}' + ) + else: + # User icon + elements.append( + f'' + ) + elements.append( + f'' + ) + return create_group(elements) + + +def create_progress( + x: float, y: float, + progress: float = 0.6, + width: float = 200, + height: float = 8 +) -> str: + """Create a progress bar wireframe.""" + elements = [ + f'', + f'', + ] + return create_group(elements) + + +def wrap_svg( + content: str, + width: int = 800, + height: int = 600, + background: str = "#f9fafb" +) -> str: + """Wrap content in a complete SVG document.""" + return f''' + + + {content} +''' + + +def svg_to_png(svg_content: str, output_path: str) -> bool: + """Convert SVG to PNG using available tools.""" + # Try different conversion methods + + # Method 1: Use Inkscape + try: + result = subprocess.run( + ['which', 'inkscape'], + capture_output=True, + timeout=5 + ) + if result.returncode == 0: + with tempfile.NamedTemporaryFile(suffix='.svg', delete=False, mode='w') as f: + f.write(svg_content) + temp_svg = f.name + + subprocess.run([ + 'inkscape', temp_svg, + '--export-type=png', + f'--export-filename={output_path}' + ], capture_output=True, timeout=30) + + Path(temp_svg).unlink(missing_ok=True) + if Path(output_path).exists(): + return True + except: + pass + + # Method 2: Use rsvg-convert + try: + result = subprocess.run( + ['which', 'rsvg-convert'], + capture_output=True, + timeout=5 + ) + if result.returncode == 0: + with tempfile.NamedTemporaryFile(suffix='.svg', delete=False, mode='w') as f: + f.write(svg_content) + temp_svg = f.name + + subprocess.run([ + 'rsvg-convert', '-o', output_path, temp_svg + ], capture_output=True, timeout=30) + + Path(temp_svg).unlink(missing_ok=True) + if Path(output_path).exists(): + return True + except: + pass + + # Method 3: Use cairosvg Python library + try: + import cairosvg + cairosvg.svg2png(bytestring=svg_content.encode(), write_to=output_path) + return True + except ImportError: + pass + except Exception: + pass + + return False + + +@register_renderer +class SVGRenderer(Renderer): + """Renderer for direct SVG graphics.""" + + name = "svg" + extensions = [".svg", ".png"] + + def render(self, source: str, output_path: Path) -> Tuple[bool, str]: + """Render SVG to output file.""" + # Validate + is_valid, error = validate_svg(source) + if not is_valid: + return False, error or "Invalid SVG" + + ext = output_path.suffix.lower() + + if ext == '.svg': + # Just write the SVG + output_path.write_text(source) + return True, str(output_path) + + elif ext == '.png': + # Convert to PNG + if svg_to_png(source, str(output_path)): + return True, str(output_path) + else: + # Fallback: save as SVG and let the viewer handle it + svg_path = output_path.with_suffix('.svg') + svg_path.write_text(source) + return True, str(svg_path) + + return False, f"Unsupported format: {ext}" + + def validate(self, source: str) -> Tuple[bool, Optional[str]]: + """Validate SVG syntax.""" + return validate_svg(source) + + def get_template(self) -> str: + """Return a starter SVG template.""" + return ''' + + + + + + + Hello SVG! + + + + +''' + + def check_available(self) -> Tuple[bool, str]: + """SVG is always available (just XML).""" + return True, "" + + +# Export UI component creators +UI_COMPONENTS = { + 'button': create_button, + 'input': create_input, + 'card': create_card, + 'navbar': create_navbar, + 'modal': create_modal, + 'icon': create_icon, + 'checkbox': create_checkbox, + 'radio': create_radio, + 'toggle': create_toggle, + 'avatar': create_avatar, + 'progress': create_progress, +} diff --git a/src/artifact_editor/svg_interactive.py b/src/artifact_editor/svg_interactive.py new file mode 100644 index 0000000..a95c122 --- /dev/null +++ b/src/artifact_editor/svg_interactive.py @@ -0,0 +1,575 @@ +"""Interactive SVG selection using QGraphicsScene items. + +This module provides scene-based selection that naturally scrolls and zooms +with the content, avoiding coordinate transformation issues. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, List + +from PyQt6.QtCore import Qt, QRectF, QPointF, pyqtSignal, QSizeF, QObject +from PyQt6.QtGui import QPen, QBrush, QColor, QCursor +from PyQt6.QtWidgets import ( + QGraphicsScene, QGraphicsView, QGraphicsRectItem, + QGraphicsItem, QMenu, QGraphicsSceneMouseEvent +) + +from .svg_parser import SVGElement, parse_svg_elements + + +class HandlePosition(Enum): + """Position of resize handles.""" + TOP_LEFT = "tl" + TOP = "t" + TOP_RIGHT = "tr" + RIGHT = "r" + BOTTOM_RIGHT = "br" + BOTTOM = "b" + BOTTOM_LEFT = "bl" + LEFT = "l" + + +class ResizeHandle(QGraphicsRectItem): + """A resize handle for selected elements.""" + + HANDLE_SIZE = 8 + + def __init__(self, position: HandlePosition, parent=None): + super().__init__(parent) + self.position = position + self.setRect(-self.HANDLE_SIZE/2, -self.HANDLE_SIZE/2, + self.HANDLE_SIZE, self.HANDLE_SIZE) + self.setPen(QPen(QColor(0, 120, 215), 1)) + self.setBrush(QBrush(QColor(255, 255, 255))) + self.setZValue(1001) # Above selection rect + self.setAcceptHoverEvents(True) + + # Set cursor based on handle position + cursors = { + HandlePosition.TOP_LEFT: Qt.CursorShape.SizeFDiagCursor, + HandlePosition.TOP_RIGHT: Qt.CursorShape.SizeBDiagCursor, + HandlePosition.BOTTOM_LEFT: Qt.CursorShape.SizeBDiagCursor, + HandlePosition.BOTTOM_RIGHT: Qt.CursorShape.SizeFDiagCursor, + HandlePosition.TOP: Qt.CursorShape.SizeVerCursor, + HandlePosition.BOTTOM: Qt.CursorShape.SizeVerCursor, + HandlePosition.LEFT: Qt.CursorShape.SizeHorCursor, + HandlePosition.RIGHT: Qt.CursorShape.SizeHorCursor, + } + self.setCursor(cursors[position]) + + +class SelectionRect(QGraphicsRectItem): + """Selection rectangle with resize handles.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setPen(QPen(QColor(0, 120, 215), 2, Qt.PenStyle.DashLine)) + self.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + self.setZValue(1000) # Above content + + # Create resize handles + self.handles = {} + for pos in HandlePosition: + handle = ResizeHandle(pos, self) + self.handles[pos] = handle + + self.setVisible(False) + + def update_handles(self): + """Position handles at the corners and edges of the rect.""" + rect = self.rect() + cx, cy = rect.center().x(), rect.center().y() + + positions = { + HandlePosition.TOP_LEFT: (rect.left(), rect.top()), + HandlePosition.TOP: (cx, rect.top()), + HandlePosition.TOP_RIGHT: (rect.right(), rect.top()), + HandlePosition.RIGHT: (rect.right(), cy), + HandlePosition.BOTTOM_RIGHT: (rect.right(), rect.bottom()), + HandlePosition.BOTTOM: (cx, rect.bottom()), + HandlePosition.BOTTOM_LEFT: (rect.left(), rect.bottom()), + HandlePosition.LEFT: (rect.left(), cy), + } + + for pos, (x, y) in positions.items(): + self.handles[pos].setPos(x, y) + + +@dataclass +class ElementInfo: + """Cached info about an SVG element.""" + element: SVGElement + scene_rect: QRectF # Bounding box in scene coordinates + + +class SVGSelectionManager(QObject): + """Manages SVG element selection in a QGraphicsScene. + + This uses scene-based graphics items for selection, which naturally + scroll and zoom with the content. + """ + + # Signals + element_selected = pyqtSignal(object) # SVGElement or None + element_moved = pyqtSignal(object, float, float) # element, new_x, new_y + element_resized = pyqtSignal(object, float, float, float, float) # element, x, y, w, h + + # Context menu signals + bring_to_front = pyqtSignal(object) + send_to_back = pyqtSignal(object) + bring_forward = pyqtSignal(object) + send_backward = pyqtSignal(object) + add_to_group = pyqtSignal(object, str) + remove_from_group = pyqtSignal(object) + create_group = pyqtSignal(object) + delete_element = pyqtSignal(object) + + def __init__(self, scene: QGraphicsScene, view: QGraphicsView): + super().__init__() + self.scene = scene + self.view = view + + # Enabled state - only process events when True + self._enabled = False + + # SVG coordinate system + self.view_box = QRectF(0, 0, 800, 600) + self.pixmap_size = QSizeF(800, 600) + + # Element tracking + self.elements: List[ElementInfo] = [] + self.selected_element: Optional[SVGElement] = None + + # Selection graphics (created but not added to scene until enabled) + self.selection_rect = SelectionRect() + + # Interaction state + self.dragging = False + self.resizing = False + self.active_handle: Optional[HandlePosition] = None + self.drag_start_scene: Optional[QPointF] = None + self.drag_start_svg: Optional[QPointF] = None + self.element_start_rect: Optional[QRectF] = None + + # Groups for context menu + self.groups: List[str] = [] + + # Install event filter on view's viewport + self.view.viewport().installEventFilter(self) + + def set_enabled(self, enabled: bool): + """Enable or disable the selection manager.""" + self._enabled = enabled + if enabled: + # Add selection rect to scene if not already there + if self.selection_rect.scene() != self.scene: + self.scene.addItem(self.selection_rect) + else: + # Remove selection rect from scene and clear selection + if self.selection_rect.scene(): + self.scene.removeItem(self.selection_rect) + self.clear_selection() + + def is_enabled(self) -> bool: + """Check if selection manager is enabled.""" + return self._enabled + + def set_coordinate_system(self, view_box: QRectF, pixmap_size: QSizeF): + """Set the SVG coordinate system mapping.""" + self.view_box = view_box + self.pixmap_size = pixmap_size + + # Update selection if active + if self.selected_element: + self._update_selection_rect() + + def update_elements(self, svg_source: str): + """Parse SVG and update element list.""" + self.elements = [] + parsed = parse_svg_elements(svg_source) + + for elem in parsed: + scene_rect = self._get_element_scene_rect(elem) + if scene_rect: + self.elements.append(ElementInfo(element=elem, scene_rect=scene_rect)) + + # Update selection rect if element still exists + if self.selected_element: + found = False + for info in self.elements: + if info.element.id == self.selected_element.id: + self.selected_element = info.element + self._update_selection_rect() + found = True + break + if not found: + self.clear_selection() + + def set_groups(self, groups: List[str]): + """Update available groups for context menu.""" + self.groups = groups + + def _svg_to_scene(self, svg_x: float, svg_y: float) -> QPointF: + """Convert SVG coordinates to scene coordinates.""" + if self.view_box.width() == 0 or self.view_box.height() == 0: + return QPointF(svg_x, svg_y) + + # Scale from viewBox to pixmap + scale_x = self.pixmap_size.width() / self.view_box.width() + scale_y = self.pixmap_size.height() / self.view_box.height() + + scene_x = (svg_x - self.view_box.x()) * scale_x + scene_y = (svg_y - self.view_box.y()) * scale_y + + return QPointF(scene_x, scene_y) + + def _scene_to_svg(self, scene_x: float, scene_y: float) -> QPointF: + """Convert scene coordinates to SVG coordinates.""" + if self.pixmap_size.width() == 0 or self.pixmap_size.height() == 0: + return QPointF(scene_x, scene_y) + + scale_x = self.pixmap_size.width() / self.view_box.width() + scale_y = self.pixmap_size.height() / self.view_box.height() + + svg_x = scene_x / scale_x + self.view_box.x() + svg_y = scene_y / scale_y + self.view_box.y() + + return QPointF(svg_x, svg_y) + + def _get_element_scene_rect(self, elem: SVGElement) -> Optional[QRectF]: + """Get element bounding box in scene coordinates.""" + svg_rect = self._get_element_svg_rect(elem) + if not svg_rect: + return None + + top_left = self._svg_to_scene(svg_rect.x(), svg_rect.y()) + bottom_right = self._svg_to_scene( + svg_rect.x() + svg_rect.width(), + svg_rect.y() + svg_rect.height() + ) + + return QRectF(top_left, bottom_right) + + def _get_element_svg_rect(self, elem: SVGElement) -> Optional[QRectF]: + """Get element bounding box in SVG coordinates.""" + if elem.element_type == 'rect': + return QRectF(elem.x, elem.y, elem.width, elem.height) + elif elem.element_type == 'circle': + return QRectF(elem.cx - elem.r, elem.cy - elem.r, elem.r * 2, elem.r * 2) + elif elem.element_type == 'ellipse': + return QRectF(elem.cx - elem.rx, elem.cy - elem.ry, elem.rx * 2, elem.ry * 2) + elif elem.element_type == 'line': + x = min(elem.x1, elem.x2) + y = min(elem.y1, elem.y2) + w = abs(elem.x2 - elem.x1) + h = abs(elem.y2 - elem.y1) + return QRectF(x, y, max(w, 10), max(h, 10)) + elif elem.element_type == 'text': + text_width = len(elem.text) * elem.font_size * 0.6 + text_height = elem.font_size * 1.2 + return QRectF(elem.x, elem.y - text_height, text_width, text_height) + return None + + def _update_selection_rect(self): + """Update the selection rectangle to match selected element.""" + if not self.selected_element: + self.selection_rect.setVisible(False) + return + + # Find element info + for info in self.elements: + if info.element.id == self.selected_element.id: + self.selection_rect.setRect(info.scene_rect) + self.selection_rect.update_handles() + self.selection_rect.setVisible(True) + return + + self.selection_rect.setVisible(False) + + def select_element(self, element: Optional[SVGElement]): + """Select an element.""" + self.selected_element = element + self._update_selection_rect() + self.element_selected.emit(element) + + def select_element_by_id(self, element_id: str): + """Select element by ID.""" + for info in self.elements: + if info.element.id == element_id: + self.select_element(info.element) + return + self.select_element(None) + + def clear_selection(self): + """Clear current selection.""" + self.select_element(None) + + def _hit_test(self, scene_pos: QPointF) -> Optional[ElementInfo]: + """Find element at scene position.""" + # Check in reverse order (top elements first) + for info in reversed(self.elements): + if info.scene_rect.contains(scene_pos): + return info + return None + + def _hit_test_handle(self, scene_pos: QPointF) -> Optional[HandlePosition]: + """Check if position hits a resize handle.""" + if not self.selection_rect.isVisible(): + return None + + for pos, handle in self.selection_rect.handles.items(): + # Get handle rect in scene coordinates + handle_rect = handle.mapRectToScene(handle.rect()) + if handle_rect.contains(scene_pos): + return pos + return None + + def eventFilter(self, obj, event): + """Handle mouse events on the viewport.""" + from PyQt6.QtCore import QEvent + + if obj != self.view.viewport(): + return False + + # Only process events when enabled + if not self._enabled: + return False + + if event.type() == QEvent.Type.MouseButtonPress: + if event.button() == Qt.MouseButton.LeftButton: + return self._handle_mouse_press(event) + elif event.button() == Qt.MouseButton.RightButton: + return self._handle_context_menu(event) + + elif event.type() == QEvent.Type.MouseMove: + return self._handle_mouse_move(event) + + elif event.type() == QEvent.Type.MouseButtonRelease: + if event.button() == Qt.MouseButton.LeftButton: + return self._handle_mouse_release(event) + + return False + + def _handle_mouse_press(self, event) -> bool: + """Handle mouse press event.""" + view_pos = event.position() + scene_pos = self.view.mapToScene(int(view_pos.x()), int(view_pos.y())) + + # Check for handle hit first + handle = self._hit_test_handle(scene_pos) + if handle and self.selected_element: + self.resizing = True + self.active_handle = handle + self.drag_start_scene = scene_pos + + # Store original rect + for info in self.elements: + if info.element.id == self.selected_element.id: + svg_rect = self._get_element_svg_rect(info.element) + self.element_start_rect = svg_rect + break + return True + + # Check for element hit + hit = self._hit_test(scene_pos) + if hit: + self.select_element(hit.element) + self.dragging = True + self.drag_start_scene = scene_pos + self.drag_start_svg = self._scene_to_svg(scene_pos.x(), scene_pos.y()) + + svg_rect = self._get_element_svg_rect(hit.element) + self.element_start_rect = svg_rect + return True + else: + self.clear_selection() + + return False + + def _handle_mouse_move(self, event) -> bool: + """Handle mouse move event.""" + view_pos = event.position() + scene_pos = self.view.mapToScene(int(view_pos.x()), int(view_pos.y())) + + if self.dragging and self.selected_element and self.element_start_rect: + # Calculate movement in SVG coordinates + current_svg = self._scene_to_svg(scene_pos.x(), scene_pos.y()) + + delta_x = current_svg.x() - self.drag_start_svg.x() + delta_y = current_svg.y() - self.drag_start_svg.y() + + new_x = self.element_start_rect.x() + delta_x + new_y = self.element_start_rect.y() + delta_y + + self.element_moved.emit(self.selected_element, new_x, new_y) + return True + + elif self.resizing and self.selected_element and self.element_start_rect: + # Calculate resize in SVG coordinates + current_svg = self._scene_to_svg(scene_pos.x(), scene_pos.y()) + start_svg = self._scene_to_svg( + self.drag_start_scene.x(), + self.drag_start_scene.y() + ) + + delta_x = current_svg.x() - start_svg.x() + delta_y = current_svg.y() - start_svg.y() + + x = self.element_start_rect.x() + y = self.element_start_rect.y() + w = self.element_start_rect.width() + h = self.element_start_rect.height() + + handle = self.active_handle + if handle in (HandlePosition.TOP_LEFT, HandlePosition.LEFT, HandlePosition.BOTTOM_LEFT): + x += delta_x + w -= delta_x + if handle in (HandlePosition.TOP_RIGHT, HandlePosition.RIGHT, HandlePosition.BOTTOM_RIGHT): + w += delta_x + if handle in (HandlePosition.TOP_LEFT, HandlePosition.TOP, HandlePosition.TOP_RIGHT): + y += delta_y + h -= delta_y + if handle in (HandlePosition.BOTTOM_LEFT, HandlePosition.BOTTOM, HandlePosition.BOTTOM_RIGHT): + h += delta_y + + # Minimum size + if w < 10: + w = 10 + if h < 10: + h = 10 + + self.element_resized.emit(self.selected_element, x, y, w, h) + return True + + else: + # Update cursor + handle = self._hit_test_handle(scene_pos) + if handle: + cursors = { + HandlePosition.TOP_LEFT: Qt.CursorShape.SizeFDiagCursor, + HandlePosition.TOP_RIGHT: Qt.CursorShape.SizeBDiagCursor, + HandlePosition.BOTTOM_LEFT: Qt.CursorShape.SizeBDiagCursor, + HandlePosition.BOTTOM_RIGHT: Qt.CursorShape.SizeFDiagCursor, + HandlePosition.TOP: Qt.CursorShape.SizeVerCursor, + HandlePosition.BOTTOM: Qt.CursorShape.SizeVerCursor, + HandlePosition.LEFT: Qt.CursorShape.SizeHorCursor, + HandlePosition.RIGHT: Qt.CursorShape.SizeHorCursor, + } + self.view.viewport().setCursor(cursors[handle]) + elif self._hit_test(scene_pos): + self.view.viewport().setCursor(Qt.CursorShape.SizeAllCursor) + else: + self.view.viewport().setCursor(Qt.CursorShape.ArrowCursor) + + return False + + def _handle_mouse_release(self, event) -> bool: + """Handle mouse release event.""" + was_interacting = self.dragging or self.resizing + + self.dragging = False + self.resizing = False + self.active_handle = None + self.drag_start_scene = None + self.drag_start_svg = None + self.element_start_rect = None + + return was_interacting + + def _handle_context_menu(self, event) -> bool: + """Show context menu for element.""" + view_pos = event.position() + scene_pos = self.view.mapToScene(int(view_pos.x()), int(view_pos.y())) + + hit = self._hit_test(scene_pos) + if not hit: + return False + + self.select_element(hit.element) + element = hit.element + + # Create context menu + menu = QMenu(self.view) + menu.setStyleSheet(""" + QMenu { + background-color: #2d2d2d; + color: #ffffff; + border: 1px solid #555; + } + QMenu::item { + padding: 6px 20px; + } + QMenu::item:selected { + background-color: #094771; + } + QMenu::separator { + height: 1px; + background-color: #555; + margin: 4px 8px; + } + """) + + # Z-order actions + front_action = menu.addAction("Bring to Front") + forward_action = menu.addAction("Bring Forward") + backward_action = menu.addAction("Send Backward") + back_action = menu.addAction("Send to Back") + + menu.addSeparator() + + # Group actions + group_menu = menu.addMenu("Add to Group") + + if self.groups: + for group_id in self.groups: + action = group_menu.addAction(group_id) + action.setData(("add_to_group", group_id)) + else: + no_groups = group_menu.addAction("(No groups)") + no_groups.setEnabled(False) + + group_menu.addSeparator() + new_group_action = group_menu.addAction("Create New Group...") + new_group_action.setData(("create_group", None)) + + # Check if element is in a group + remove_group_action = None + if element.parent_group: + remove_group_action = menu.addAction(f"Remove from '{element.parent_group}'") + + menu.addSeparator() + + # Delete action + delete_action = menu.addAction("Delete") + + # Show menu + global_pos = self.view.viewport().mapToGlobal(event.position().toPoint()) + action = menu.exec(global_pos) + + if action == front_action: + self.bring_to_front.emit(element) + elif action == forward_action: + self.bring_forward.emit(element) + elif action == backward_action: + self.send_backward.emit(element) + elif action == back_action: + self.send_to_back.emit(element) + elif action == remove_group_action: + self.remove_from_group.emit(element) + elif action == delete_action: + self.delete_element.emit(element) + elif action and action.data(): + data = action.data() + if data[0] == "add_to_group": + self.add_to_group.emit(element, data[1]) + elif data[0] == "create_group": + self.create_group.emit(element) + + return True + + def cleanup(self): + """Remove selection graphics from scene.""" + if self.selection_rect.scene(): + self.scene.removeItem(self.selection_rect) + self.view.viewport().removeEventFilter(self) diff --git a/src/artifact_editor/templates.py b/src/artifact_editor/templates.py index 55e8177..70e9a09 100644 --- a/src/artifact_editor/templates.py +++ b/src/artifact_editor/templates.py @@ -772,6 +772,912 @@ bracket();''' }, } +CODE_TEMPLATES = { + 'python': { + 'Hello World': { + 'description': 'Simple Python hello world', + 'language': 'python', + 'code': '''def hello_world(): + """A simple example function.""" + print("Hello, World!") + +if __name__ == "__main__": + hello_world()''' + }, + 'Class Example': { + 'description': 'Python class with methods', + 'language': 'python', + 'code': '''class Rectangle: + """A simple rectangle class.""" + + def __init__(self, width: float, height: float): + self.width = width + self.height = height + + @property + def area(self) -> float: + """Calculate the area of the rectangle.""" + return self.width * self.height + + @property + def perimeter(self) -> float: + """Calculate the perimeter of the rectangle.""" + return 2 * (self.width + self.height) + + def __repr__(self) -> str: + return f"Rectangle({self.width}, {self.height})" + + +# Example usage +rect = Rectangle(10, 5) +print(f"Area: {rect.area}") +print(f"Perimeter: {rect.perimeter}")''' + }, + 'FastAPI Endpoint': { + 'description': 'FastAPI route handler example', + 'language': 'python', + 'code': '''from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from typing import List, Optional + +app = FastAPI() + +class Item(BaseModel): + id: int + name: str + description: Optional[str] = None + price: float + +items_db: List[Item] = [] + +@app.get("/items", response_model=List[Item]) +async def get_items(): + """Get all items.""" + return items_db + +@app.get("/items/{item_id}", response_model=Item) +async def get_item(item_id: int): + """Get a specific item by ID.""" + for item in items_db: + if item.id == item_id: + return item + raise HTTPException(status_code=404, detail="Item not found") + +@app.post("/items", response_model=Item) +async def create_item(item: Item): + """Create a new item.""" + items_db.append(item) + return item''' + }, + }, + 'javascript': { + 'Hello World': { + 'description': 'Simple JavaScript hello world', + 'language': 'javascript', + 'code': '''// Hello World in JavaScript +function greet(name) { + return `Hello, ${name}!`; +} + +console.log(greet("World"));''' + }, + 'Async/Await Example': { + 'description': 'Async function with fetch', + 'language': 'javascript', + 'code': '''// Async/Await API fetch example +async function fetchUserData(userId) { + try { + const response = await fetch(`https://api.example.com/users/${userId}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const userData = await response.json(); + return userData; + } catch (error) { + console.error("Failed to fetch user data:", error); + throw error; + } +} + +// Usage +async function main() { + const user = await fetchUserData(123); + console.log("User:", user); +} + +main();''' + }, + 'React Component': { + 'description': 'React functional component with hooks', + 'language': 'jsx', + 'code': '''import React, { useState, useEffect } from 'react'; + +function Counter({ initialValue = 0 }) { + const [count, setCount] = useState(initialValue); + + useEffect(() => { + document.title = `Count: ${count}`; + }, [count]); + + const increment = () => setCount(c => c + 1); + const decrement = () => setCount(c => c - 1); + const reset = () => setCount(initialValue); + + return ( +
+

Count: {count}

+
+ + + +
+
+ ); +} + +export default Counter;''' + }, + }, + 'rust': { + 'Hello World': { + 'description': 'Simple Rust hello world', + 'language': 'rust', + 'code': '''fn main() { + println!("Hello, World!"); +}''' + }, + 'Struct Example': { + 'description': 'Rust struct with implementation', + 'language': 'rust', + 'code': '''use std::fmt; + +#[derive(Debug)] +struct Rectangle { + width: f64, + height: f64, +} + +impl Rectangle { + fn new(width: f64, height: f64) -> Self { + Rectangle { width, height } + } + + fn area(&self) -> f64 { + self.width * self.height + } + + fn perimeter(&self) -> f64 { + 2.0 * (self.width + self.height) + } + + fn is_square(&self) -> bool { + (self.width - self.height).abs() < f64::EPSILON + } +} + +impl fmt::Display for Rectangle { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Rectangle({}x{})", self.width, self.height) + } +} + +fn main() { + let rect = Rectangle::new(10.0, 5.0); + println!("{}", rect); + println!("Area: {}", rect.area()); + println!("Perimeter: {}", rect.perimeter()); + println!("Is square: {}", rect.is_square()); +}''' + }, + }, + 'go': { + 'Hello World': { + 'description': 'Simple Go hello world', + 'language': 'go', + 'code': '''package main + +import "fmt" + +func main() { + fmt.Println("Hello, World!") +}''' + }, + 'HTTP Server': { + 'description': 'Simple Go HTTP server', + 'language': 'go', + 'code': '''package main + +import ( + "encoding/json" + "log" + "net/http" +) + +type Response struct { + Message string `json:"message"` + Status int `json:"status"` +} + +func helloHandler(w http.ResponseWriter, r *http.Request) { + response := Response{ + Message: "Hello, World!", + Status: 200, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func main() { + http.HandleFunc("/", helloHandler) + + log.Println("Server starting on :8080...") + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } +}''' + }, + }, + 'sql': { + 'Create Table': { + 'description': 'SQL table creation with constraints', + 'language': 'sql', + 'code': '''-- Create users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create orders table with foreign key +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + total_amount DECIMAL(10, 2) NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create index for faster lookups +CREATE INDEX idx_orders_user_id ON orders(user_id); +CREATE INDEX idx_orders_status ON orders(status);''' + }, + 'Common Queries': { + 'description': 'Common SQL query patterns', + 'language': 'sql', + 'code': '''-- Select with join +SELECT + u.username, + u.email, + COUNT(o.id) as order_count, + COALESCE(SUM(o.total_amount), 0) as total_spent +FROM users u +LEFT JOIN orders o ON u.id = o.user_id +GROUP BY u.id, u.username, u.email +ORDER BY total_spent DESC; + +-- Subquery example +SELECT * FROM users +WHERE id IN ( + SELECT DISTINCT user_id + FROM orders + WHERE total_amount > 100 +); + +-- Window function +SELECT + username, + total_amount, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as order_rank +FROM users u +JOIN orders o ON u.id = o.user_id;''' + }, + }, + 'bash': { + 'Script Template': { + 'description': 'Bash script with common patterns', + 'language': 'bash', + 'code': '''#!/bin/bash + +# Script description +# Usage: ./script.sh [options] + +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# Constants +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly LOG_FILE="/tmp/script.log" + +# Functions +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" +} + +die() { + log "ERROR: $*" + exit 1 +} + +usage() { + cat < + + + + + My Page + + + +
+
+

Welcome

+
+
+ +
+

Hello, World!

+

This is a basic HTML5 template.

+
+ + + +''' + }, + }, +} + +# SVG Templates for wireframes and diagrams +SVG_TEMPLATES = { + 'wireframes': { + 'Login Page': { + 'description': 'Simple login form wireframe', + 'code': ''' + + + + + + + + + Logo + + + Welcome Back + + + Email + + you@example.com + + + Password + + •••••••• + + + + Sign In + + + Forgot password? +''' + }, + 'Dashboard Layout': { + 'description': 'Dashboard with sidebar and cards', + 'code': ''' + + + + + + Dashboard + + + + Overview + + Analytics + Reports + Settings + + + + Welcome back, User + + + + Search... + + + + + + + Total Users + 12,345 + + + Revenue + $54,321 + + + Orders + 1,234 + + + + Recent Activity + + + + Chart Placeholder +''' + }, + 'Mobile App Screen': { + 'description': 'Mobile app layout wireframe', + 'code': ''' + + + + + + + 9:41 + + + + + Profile + + + + Photo + John Doe + john@example.com + + + + + + Edit Profile + + + + + Notifications + + + + + Privacy + + + + Help & Support + + + + + + Log Out + + + + Home + Search + Profile + Settings +''' + }, + }, + 'diagrams': { + 'Simple Flowchart': { + 'description': 'Basic flowchart with shapes and arrows', + 'code': ''' + + + + + + + + + + + + Start + + + + + + + Process Data + + + + + + + Valid? + + + + Yes + + Save + + + + No + + Error + + + + + End +''' + }, + 'System Architecture': { + 'description': 'Simple system architecture diagram', + 'code': ''' + + + + + + + + + + + System Architecture + + + + Client Layer + + + Web App + + + Mobile App + + + Desktop App + + + + + + + + + API Gateway + + + + + + + Microservices + + + Auth Service + + + User Service + + + Order Service + + + Payment Service + + + + Data Layer + + + + PostgreSQL + + + Redis Cache + + + MongoDB + + + + + +''' + }, + }, + 'basic': { + 'Shapes Gallery': { + 'description': 'Common SVG shapes reference', + 'code': ''' + + + + SVG Shapes Reference + + + + Rectangle + + + + Circle + + + + Ellipse + + + + Line + + + + Polygon + + + + Path (Curve) + + + Text + Text Element + + + + Polyline + + + + + + + Group +''' + }, + }, +} + +# Excalidraw Templates (JSON format) +EXCALIDRAW_TEMPLATES = { + 'diagrams': { + 'Simple Flowchart': { + 'description': 'Hand-drawn flowchart with shapes and arrows', + 'code': '''{ + "type": "excalidraw", + "version": 2, + "elements": [ + {"type": "rectangle", "id": "start", "x": 200, "y": 20, "width": 120, "height": 50, "strokeColor": "#087f5b", "backgroundColor": "#c3fae8", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "start-txt", "x": 230, "y": 35, "width": 60, "height": 25, "text": "Start", "fontSize": 20, "strokeColor": "#087f5b"}, + + {"type": "arrow", "id": "a1", "x": 260, "y": 70, "width": 0, "height": 40, "points": [[0, 0], [0, 40]], "strokeColor": "#495057"}, + + {"type": "rectangle", "id": "proc1", "x": 180, "y": 120, "width": 160, "height": 60, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "proc1-txt", "x": 200, "y": 140, "width": 120, "height": 25, "text": "Process", "fontSize": 20, "strokeColor": "#1864ab"}, + + {"type": "arrow", "id": "a2", "x": 260, "y": 180, "width": 0, "height": 40, "points": [[0, 0], [0, 40]], "strokeColor": "#495057"}, + + {"type": "diamond", "id": "decision", "x": 185, "y": 230, "width": 150, "height": 100, "strokeColor": "#e67700", "backgroundColor": "#fff3bf", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "dec-txt", "x": 225, "y": 270, "width": 70, "height": 25, "text": "Valid?", "fontSize": 18, "strokeColor": "#e67700"}, + + {"type": "arrow", "id": "a3-yes", "x": 335, "y": 280, "width": 80, "height": 0, "points": [[0, 0], [80, 0]], "strokeColor": "#2b8a3e"}, + {"type": "text", "id": "yes-lbl", "x": 355, "y": 260, "width": 40, "height": 20, "text": "Yes", "fontSize": 14, "strokeColor": "#2b8a3e"}, + + {"type": "rectangle", "id": "success", "x": 420, "y": 255, "width": 100, "height": 50, "strokeColor": "#2b8a3e", "backgroundColor": "#d3f9d8", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "succ-txt", "x": 445, "y": 270, "width": 50, "height": 25, "text": "Done", "fontSize": 18, "strokeColor": "#2b8a3e"}, + + {"type": "arrow", "id": "a3-no", "x": 185, "y": 280, "width": -80, "height": 0, "points": [[0, 0], [-80, 0]], "strokeColor": "#c92a2a"}, + {"type": "text", "id": "no-lbl", "x": 130, "y": 260, "width": 40, "height": 20, "text": "No", "fontSize": 14, "strokeColor": "#c92a2a"}, + + {"type": "rectangle", "id": "error", "x": 0, "y": 255, "width": 100, "height": 50, "strokeColor": "#c92a2a", "backgroundColor": "#ffe3e3", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "err-txt", "x": 25, "y": 270, "width": 50, "height": 25, "text": "Error", "fontSize": 18, "strokeColor": "#c92a2a"} + ], + "appState": {"viewBackgroundColor": "#ffffff"} +}''' + }, + 'Mind Map': { + 'description': 'Simple mind map with central topic', + 'code': '''{ + "type": "excalidraw", + "version": 2, + "elements": [ + {"type": "ellipse", "id": "center", "x": 300, "y": 200, "width": 160, "height": 80, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1, "strokeWidth": 2}, + {"type": "text", "id": "center-txt", "x": 335, "y": 230, "width": 90, "height": 30, "text": "Main Topic", "fontSize": 20, "strokeColor": "#1864ab"}, + + {"type": "line", "id": "l1", "x": 380, "y": 200, "width": 100, "height": -80, "points": [[0, 0], [100, -80]], "strokeColor": "#495057"}, + {"type": "rectangle", "id": "t1", "x": 480, "y": 80, "width": 120, "height": 50, "strokeColor": "#087f5b", "backgroundColor": "#c3fae8", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "t1-txt", "x": 505, "y": 95, "width": 70, "height": 25, "text": "Topic 1", "fontSize": 16, "strokeColor": "#087f5b"}, + + {"type": "line", "id": "l2", "x": 460, "y": 240, "width": 80, "height": 0, "points": [[0, 0], [80, 0]], "strokeColor": "#495057"}, + {"type": "rectangle", "id": "t2", "x": 540, "y": 215, "width": 120, "height": 50, "strokeColor": "#e67700", "backgroundColor": "#fff3bf", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "t2-txt", "x": 565, "y": 230, "width": 70, "height": 25, "text": "Topic 2", "fontSize": 16, "strokeColor": "#e67700"}, + + {"type": "line", "id": "l3", "x": 380, "y": 280, "width": 100, "height": 80, "points": [[0, 0], [100, 80]], "strokeColor": "#495057"}, + {"type": "rectangle", "id": "t3", "x": 480, "y": 350, "width": 120, "height": 50, "strokeColor": "#862e9c", "backgroundColor": "#f3d9fa", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "t3-txt", "x": 505, "y": 365, "width": 70, "height": 25, "text": "Topic 3", "fontSize": 16, "strokeColor": "#862e9c"}, + + {"type": "line", "id": "l4", "x": 300, "y": 240, "width": -80, "height": 60, "points": [[0, 0], [-80, 60]], "strokeColor": "#495057"}, + {"type": "rectangle", "id": "t4", "x": 100, "y": 290, "width": 120, "height": 50, "strokeColor": "#c92a2a", "backgroundColor": "#ffe3e3", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "t4-txt", "x": 125, "y": 305, "width": 70, "height": 25, "text": "Topic 4", "fontSize": 16, "strokeColor": "#c92a2a"} + ], + "appState": {"viewBackgroundColor": "#ffffff"} +}''' + }, + }, + 'wireframes': { + 'Login Sketch': { + 'description': 'Hand-drawn login form sketch', + 'code': '''{ + "type": "excalidraw", + "version": 2, + "elements": [ + {"type": "rectangle", "id": "card", "x": 100, "y": 50, "width": 280, "height": 350, "strokeColor": "#495057", "backgroundColor": "#f8f9fa", "fillStyle": "solid", "roughness": 1, "strokeWidth": 2}, + + {"type": "ellipse", "id": "logo", "x": 200, "y": 80, "width": 80, "height": 80, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "logo-txt", "x": 220, "y": 110, "width": 40, "height": 25, "text": "Logo", "fontSize": 14, "strokeColor": "#1864ab"}, + + {"type": "text", "id": "title", "x": 175, "y": 180, "width": 130, "height": 30, "text": "Welcome Back", "fontSize": 20, "strokeColor": "#212529"}, + + {"type": "rectangle", "id": "email", "x": 130, "y": 220, "width": 220, "height": 40, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "email-txt", "x": 145, "y": 233, "width": 50, "height": 20, "text": "Email...", "fontSize": 14, "strokeColor": "#adb5bd"}, + + {"type": "rectangle", "id": "pass", "x": 130, "y": 275, "width": 220, "height": 40, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "pass-txt", "x": 145, "y": 288, "width": 70, "height": 20, "text": "Password...", "fontSize": 14, "strokeColor": "#adb5bd"}, + + {"type": "rectangle", "id": "btn", "x": 130, "y": 335, "width": 220, "height": 45, "strokeColor": "#1864ab", "backgroundColor": "#339af0", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "btn-txt", "x": 200, "y": 350, "width": 80, "height": 20, "text": "Sign In", "fontSize": 16, "strokeColor": "#ffffff"} + ], + "appState": {"viewBackgroundColor": "#ffffff"} +}''' + }, + 'Mobile Screen': { + 'description': 'Hand-drawn mobile app screen', + 'code': '''{ + "type": "excalidraw", + "version": 2, + "elements": [ + {"type": "rectangle", "id": "phone", "x": 100, "y": 20, "width": 240, "height": 480, "strokeColor": "#212529", "backgroundColor": "#f8f9fa", "fillStyle": "solid", "roughness": 1, "strokeWidth": 3}, + + {"type": "rectangle", "id": "statusbar", "x": 100, "y": 20, "width": 240, "height": 30, "strokeColor": "#212529", "backgroundColor": "#e9ecef", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "time", "x": 195, "y": 30, "width": 50, "height": 20, "text": "9:41", "fontSize": 14, "strokeColor": "#212529"}, + + {"type": "rectangle", "id": "header", "x": 100, "y": 50, "width": 240, "height": 50, "strokeColor": "#1864ab", "backgroundColor": "#339af0", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "header-txt", "x": 180, "y": 68, "width": 80, "height": 20, "text": "My App", "fontSize": 18, "strokeColor": "#ffffff"}, + + {"type": "rectangle", "id": "card1", "x": 115, "y": 115, "width": 210, "height": 80, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "card1-txt", "x": 130, "y": 140, "width": 100, "height": 20, "text": "Item 1", "fontSize": 16, "strokeColor": "#212529"}, + + {"type": "rectangle", "id": "card2", "x": 115, "y": 210, "width": 210, "height": 80, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "card2-txt", "x": 130, "y": 235, "width": 100, "height": 20, "text": "Item 2", "fontSize": 16, "strokeColor": "#212529"}, + + {"type": "rectangle", "id": "card3", "x": 115, "y": 305, "width": 210, "height": 80, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "card3-txt", "x": 130, "y": 330, "width": 100, "height": 20, "text": "Item 3", "fontSize": 16, "strokeColor": "#212529"}, + + {"type": "rectangle", "id": "tabbar", "x": 100, "y": 450, "width": 240, "height": 50, "strokeColor": "#212529", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "tab1", "x": 130, "y": 468, "width": 40, "height": 15, "text": "Home", "fontSize": 12, "strokeColor": "#1864ab"}, + {"type": "text", "id": "tab2", "x": 200, "y": 468, "width": 40, "height": 15, "text": "Search", "fontSize": 12, "strokeColor": "#868e96"}, + {"type": "text", "id": "tab3", "x": 275, "y": 468, "width": 40, "height": 15, "text": "Profile", "fontSize": 12, "strokeColor": "#868e96"} + ], + "appState": {"viewBackgroundColor": "#ffffff"} +}''' + }, + }, + 'basic': { + 'Shapes Demo': { + 'description': 'Basic shapes in Excalidraw style', + 'code': '''{ + "type": "excalidraw", + "version": 2, + "elements": [ + {"type": "rectangle", "id": "rect", "x": 50, "y": 50, "width": 120, "height": 80, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "rect-lbl", "x": 75, "y": 150, "width": 70, "height": 20, "text": "Rectangle", "fontSize": 12, "strokeColor": "#495057"}, + + {"type": "ellipse", "id": "ellipse", "x": 220, "y": 50, "width": 100, "height": 80, "strokeColor": "#087f5b", "backgroundColor": "#c3fae8", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "ell-lbl", "x": 245, "y": 150, "width": 50, "height": 20, "text": "Ellipse", "fontSize": 12, "strokeColor": "#495057"}, + + {"type": "diamond", "id": "diamond", "x": 370, "y": 40, "width": 100, "height": 100, "strokeColor": "#e67700", "backgroundColor": "#fff3bf", "fillStyle": "solid", "roughness": 1}, + {"type": "text", "id": "dia-lbl", "x": 390, "y": 150, "width": 60, "height": 20, "text": "Diamond", "fontSize": 12, "strokeColor": "#495057"}, + + {"type": "line", "id": "line", "x": 50, "y": 200, "width": 100, "height": 60, "points": [[0, 0], [100, 60]], "strokeColor": "#862e9c", "strokeWidth": 2, "roughness": 1}, + {"type": "text", "id": "line-lbl", "x": 75, "y": 280, "width": 50, "height": 20, "text": "Line", "fontSize": 12, "strokeColor": "#495057"}, + + {"type": "arrow", "id": "arrow", "x": 220, "y": 200, "width": 100, "height": 60, "points": [[0, 0], [100, 60]], "strokeColor": "#c92a2a", "strokeWidth": 2, "roughness": 1}, + {"type": "text", "id": "arr-lbl", "x": 250, "y": 280, "width": 50, "height": 20, "text": "Arrow", "fontSize": 12, "strokeColor": "#495057"}, + + {"type": "text", "id": "text-demo", "x": 370, "y": 220, "width": 100, "height": 40, "text": "Text\\nElement", "fontSize": 20, "strokeColor": "#212529"}, + {"type": "text", "id": "txt-lbl", "x": 395, "y": 280, "width": 50, "height": 20, "text": "Text", "fontSize": 12, "strokeColor": "#495057"} + ], + "appState": {"viewBackgroundColor": "#ffffff"} +}''' + }, + }, +} + # Combined templates dict for backwards compatibility TEMPLATES = PLANTUML_TEMPLATES @@ -788,6 +1694,9 @@ def get_templates_for_format(format_type: str) -> dict: 'plantuml': PLANTUML_TEMPLATES, 'mermaid': MERMAID_TEMPLATES, 'openscad': OPENSCAD_TEMPLATES, + 'code': CODE_TEMPLATES, + 'svg': SVG_TEMPLATES, + 'excalidraw': EXCALIDRAW_TEMPLATES, } return format_map.get(format_type, PLANTUML_TEMPLATES)