Source code for simvx.editor.panels.shader_editor

"""Shader Editor Panel -- GLSL code editor with live preview.

Provides a split-view shader editing experience: a CodeTextEdit with
GLSL syntax highlighting on the left, and a material preview + error
output on the right.  Auto-recompiles the shader on text change
(debounced 500ms after last edit) and displays compilation errors
inline via TextMarker squiggles.

Layout:
    +----------------------------------------------------+
    | Shader Editor  [Compile] [Sphere|Cube|Quad] [Reset]|
    +---------------------------+------------------------+
    |                           |  Preview               |
    |  GLSL Code Editor         |  [rendered mesh]       |
    |  (syntax highlighted)     |                        |
    |                           +------------------------+
    |                           |  Uniforms              |
    |                           |  time [====|---] 0.0   |
    |                           |  colour [####]          |
    +---------------------------+------------------------+
    | Errors                                             |
    | shader.frag:12: error: undeclared identifier 'foo' |
    +----------------------------------------------------+
"""


from __future__ import annotations

import re
import time
from pathlib import Path
from typing import Any

from simvx.core import (
    Button,
    CheckBox,
    ColourPicker,
    Control,
    DropDown,
    HBoxContainer,
    Label,
    Panel,
    Signal,
    Slider,
    SplitContainer,
    VBoxContainer,
    Vec2,
)
from simvx.core.ui.code_edit import CodeTextEdit
from simvx.core.ui.theme import em, get_theme

__all__ = ["ShaderEditorPanel"]


# ============================================================================
# Constants
# ============================================================================

_PANEL_BG = (0.13, 0.13, 0.13, 1.0)
_TOOLBAR_BG = (0.16, 0.16, 0.16, 1.0)
_ERROR_BG = (0.11, 0.08, 0.08, 1.0)
_PREVIEW_BG = (0.10, 0.10, 0.12, 1.0)
_UNIFORM_BG = (0.12, 0.12, 0.14, 1.0)
_SEPARATOR_COLOUR = (0.25, 0.25, 0.25, 1.0)
_LABEL_COLOUR = (0.7, 0.7, 0.7, 1.0)
_ACCENT_COLOUR = (0.4, 0.7, 1.0, 1.0)
_ERROR_COLOUR = (1.0, 0.3, 0.3, 1.0)
_WARNING_COLOUR = (1.0, 0.8, 0.2, 1.0)
_MUTED_COLOUR = (0.5, 0.5, 0.5, 1.0)
_SUCCESS_COLOUR = (0.3, 0.9, 0.4, 1.0)

_TOOLBAR_HEIGHT = 32.0
_ERROR_PANEL_HEIGHT = 80.0


def _font_size() -> float:
    return get_theme().font_size


def _padding() -> float:
    return em(0.55)


def _row_h() -> float:
    return em(2.18)

_DEBOUNCE_SECONDS = 0.5

_DEFAULT_VERTEX_SHADER = """\
#version 450

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_uv;

layout(set = 0, binding = 0) uniform UBO {
    mat4 model;
    mat4 view;
    mat4 projection;
    float time;
};

layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec2 v_uv;
layout(location = 2) out vec3 v_world_pos;

void main() {
    vec4 world = model * vec4(a_position, 1.0);
    v_world_pos = world.xyz;
    v_normal = mat3(model) * a_normal;
    v_uv = a_uv;
    gl_Position = projection * view * world;
}
"""

_DEFAULT_FRAGMENT_SHADER = """\
#version 450

layout(location = 0) in vec3 v_normal;
layout(location = 1) in vec2 v_uv;
layout(location = 2) in vec3 v_world_pos;

layout(set = 0, binding = 0) uniform UBO {
    mat4 model;
    mat4 view;
    mat4 projection;
    float time;
};

layout(location = 0) out vec4 frag_colour;

void main() {
    vec3 N = normalize(v_normal);
    vec3 light_dir = normalize(vec3(1.0, 1.0, 0.5));
    float diff = max(dot(N, light_dir), 0.0);
    float ambient = 0.15;
    vec3 colour = vec3(0.4, 0.6, 1.0) * (ambient + diff);
    frag_colour = vec4(colour, 1.0);
}
"""

# ============================================================================
# GLSL syntax token sets
# ============================================================================

_GLSL_KEYWORDS = frozenset(
    {
        "uniform",
        "varying",
        "in",
        "out",
        "inout",
        "layout",
        "void",
        "return",
        "if",
        "else",
        "for",
        "while",
        "do",
        "switch",
        "case",
        "default",
        "break",
        "continue",
        "discard",
        "struct",
        "const",
        "flat",
        "smooth",
        "noperspective",
        "centroid",
        "invariant",
        "precise",
        "coherent",
        "volatile",
        "restrict",
        "readonly",
        "writeonly",
        "buffer",
        "shared",
        "subroutine",
        "patch",
        "sample",
        "location",
        "binding",
        "set",
        "push_constant",
    }
)

_GLSL_TYPES = frozenset(
    {
        "float",
        "double",
        "int",
        "uint",
        "bool",
        "vec2",
        "vec3",
        "vec4",
        "dvec2",
        "dvec3",
        "dvec4",
        "ivec2",
        "ivec3",
        "ivec4",
        "uvec2",
        "uvec3",
        "uvec4",
        "bvec2",
        "bvec3",
        "bvec4",
        "mat2",
        "mat3",
        "mat4",
        "mat2x2",
        "mat2x3",
        "mat2x4",
        "mat3x2",
        "mat3x3",
        "mat3x4",
        "mat4x2",
        "mat4x3",
        "mat4x4",
        "sampler1D",
        "sampler2D",
        "sampler3D",
        "samplerCube",
        "sampler2DArray",
        "samplerCubeArray",
        "sampler2DShadow",
        "sampler2DMS",
        "image2D",
        "image3D",
    }
)

_GLSL_BUILTINS = frozenset(
    {
        "gl_Position",
        "gl_FragCoord",
        "gl_FragColour",
        "gl_FragDepth",
        "gl_VertexID",
        "gl_InstanceID",
        "gl_VertexIndex",
        "gl_InstanceIndex",
        "gl_FrontFacing",
        "gl_PointSize",
        "gl_PointCoord",
        "gl_GlobalInvocationID",
        "gl_LocalInvocationID",
        "gl_WorkGroupID",
        "gl_NumWorkGroups",
        "gl_WorkGroupSize",
        "texture",
        "textureLod",
        "textureGrad",
        "texelFetch",
        "textureSize",
        "normalize",
        "dot",
        "cross",
        "length",
        "distance",
        "reflect",
        "refract",
        "mix",
        "clamp",
        "min",
        "max",
        "abs",
        "sign",
        "floor",
        "ceil",
        "fract",
        "mod",
        "pow",
        "sqrt",
        "inversesqrt",
        "exp",
        "exp2",
        "log",
        "log2",
        "step",
        "smoothstep",
        "sin",
        "cos",
        "tan",
        "asin",
        "acos",
        "atan",
        "radians",
        "degrees",
        "sinh",
        "cosh",
        "tanh",
        "transpose",
        "inverse",
        "determinant",
        "outerProduct",
        "lessThan",
        "greaterThan",
        "equal",
        "notEqual",
        "any",
        "all",
        "not",
        "dFdx",
        "dFdy",
        "fwidth",
        "imageLoad",
        "imageStore",
        "barrier",
        "memoryBarrier",
        "atomicAdd",
        "atomicMin",
        "atomicMax",
        "atomicExchange",
        "atomicCompSwap",
    }
)

_GLSL_SYNTAX_COLOURS = {
    "keyword": (0.56, 0.47, 0.86, 1.0),  # purple
    "type": (0.30, 0.76, 0.86, 1.0),  # cyan
    "builtin": (0.86, 0.76, 0.30, 1.0),  # gold
    "string": (0.60, 0.86, 0.46, 1.0),  # green
    "comment": (0.45, 0.50, 0.45, 1.0),  # grey-green
    "number": (0.90, 0.58, 0.30, 1.0),  # orange
    "preprocessor": (0.70, 0.45, 0.65, 1.0),  # magenta
    "normal": (0.88, 0.88, 0.88, 1.0),  # light grey
    "swizzle": (0.60, 0.80, 1.00, 1.0),  # light blue
}

_GLSL_NUMBER_RE = re.compile(
    r"(?<![a-zA-Z_])"
    r"(?:"
    r"0[xX][0-9a-fA-F]+"  # hex
    r"|\d+\.\d*(?:[eE][+-]?\d+)?"  # float with dot
    r"|\.\d+(?:[eE][+-]?\d+)?"  # float starting with dot
    r"|\d+[eE][+-]?\d+"  # float scientific
    r"|\d+"  # integer
    r")"
    r"[uUfF]?"  # type suffix
)

_PREPROCESSOR_RE = re.compile(r"^\s*#\s*\w+")

# Uniform type discovery regex: layout(...) uniform <type> <name>;
# or simpler: uniform <type> <name>;
_UNIFORM_RE = re.compile(
    r"^\s*(?:layout\s*\([^)]*\)\s*)?uniform\s+"
    r"(?!(?:UBO|PushConstants)\b)"  # skip UBO blocks
    r"(\w+)\s+(\w+)\s*;"
)


# ============================================================================
# GLSLHighlighter -- tokenizer for GLSL source
# ============================================================================


[docs] class GLSLHighlighter: """Tokenizer that converts GLSL source lines into coloured token spans. Handles keywords, types, built-in functions/variables, comments (both // and /* */), preprocessor directives, numbers, and strings. The tokenizer is stateful for multi-line /* */ comments. """ __slots__ = ("_in_block_comment",) def __init__(self): self._in_block_comment = False
[docs] def reset(self): """Reset multi-line comment state.""" self._in_block_comment = False
[docs] def tokenize_line(self, line: str) -> list[tuple[str, str]]: """Tokenize a single GLSL source line. Returns: List of (text, token_type) pairs covering the entire line. """ tokens: list[tuple[str, str]] = [] i = 0 n = len(line) while i < n: ch = line[i] # --- Inside block comment continuation --- if self._in_block_comment: end = line.find("*/", i) if end == -1: tokens.append((line[i:], "comment")) return tokens end += 2 tokens.append((line[i:end], "comment")) i = end self._in_block_comment = False continue # --- Preprocessor directive (must be first non-whitespace) --- if ch == "#" and line[:i].strip() == "": tokens.append((line[i:], "preprocessor")) return tokens # --- Line comment --- if ch == "/" and i + 1 < n and line[i + 1] == "/": tokens.append((line[i:], "comment")) return tokens # --- Block comment start --- if ch == "/" and i + 1 < n and line[i + 1] == "*": end = line.find("*/", i + 2) if end == -1: tokens.append((line[i:], "comment")) self._in_block_comment = True return tokens end += 2 tokens.append((line[i:end], "comment")) i = end continue # --- Whitespace --- if ch in (" ", "\t"): j = i + 1 while j < n and line[j] in (" ", "\t"): j += 1 tokens.append((line[i:j], "normal")) i = j continue # --- Numbers --- m = _GLSL_NUMBER_RE.match(line, i) if m and (i == 0 or not line[i - 1].isalnum()): tokens.append((m.group(), "number")) i = m.end() continue # --- Identifiers / keywords --- if ch.isalpha() or ch == "_": j = i + 1 while j < n and (line[j].isalnum() or line[j] == "_"): j += 1 word = line[i:j] # Check for swizzle after dot if tokens and tokens[-1][0] == ".": if all(c in "xyzwrgbastpq" for c in word) and len(word) <= 4: tokens.append((word, "swizzle")) i = j continue if word in _GLSL_KEYWORDS: tokens.append((word, "keyword")) elif word in _GLSL_TYPES: tokens.append((word, "type")) elif word in _GLSL_BUILTINS: tokens.append((word, "builtin")) else: tokens.append((word, "normal")) i = j continue # --- String literal (rare in GLSL, but handle for completeness) --- if ch == '"': j = i + 1 while j < n and line[j] != '"': if line[j] == "\\": j += 1 j += 1 if j < n: j += 1 tokens.append((line[i:j], "string")) i = j continue # --- Single character (operators, punctuation) --- tokens.append((ch, "normal")) i += 1 return tokens
# ============================================================================ # _UniformInfo -- discovered uniform metadata # ============================================================================ class _UniformInfo: """Metadata for a discovered uniform variable.""" __slots__ = ("name", "glsl_type", "value", "widget") def __init__(self, name: str, glsl_type: str): self.name = name self.glsl_type = glsl_type self.value: Any = _default_for_type(glsl_type) self.widget: Control | None = None def _default_for_type(glsl_type: str) -> Any: """Return a sensible default value for a GLSL type.""" if glsl_type == "float": return 0.0 if glsl_type in ("vec2", "dvec2"): return (0.0, 0.0) if glsl_type in ("vec3", "dvec3"): return (0.5, 0.5, 0.5) if glsl_type in ("vec4", "dvec4"): return (1.0, 1.0, 1.0, 1.0) if glsl_type in ("int", "uint"): return 0 if glsl_type == "bool": return False if glsl_type in ("sampler2D", "samplerCube"): return 0 return 0.0 # ============================================================================ # _ErrorEntry -- parsed compilation error # ============================================================================ class _ErrorEntry: """A single parsed shader compilation error or warning.""" __slots__ = ("line", "col", "severity", "message") def __init__(self, line: int, col: int, severity: str, message: str): self.line = line self.col = col self.severity = severity # "error" or "warning" self.message = message _ERROR_LINE_RE = re.compile(r"(?:.*?:)?(\d+):\s*(error|warning):\s*(.*)", re.IGNORECASE) def _parse_errors(compiler_output: str) -> list[_ErrorEntry]: """Parse glslc-style error output into structured entries.""" errors: list[_ErrorEntry] = [] for raw_line in compiler_output.strip().splitlines(): m = _ERROR_LINE_RE.match(raw_line.strip()) if m: errors.append( _ErrorEntry( line=int(m.group(1)), col=0, severity=m.group(2).lower(), message=m.group(3).strip(), ) ) return errors # ============================================================================ # ShaderPreview -- renders a simple preview shape # ============================================================================
[docs] class ShaderPreview(Control): """Preview widget that displays a shaded sphere/cube/quad representation. Since full Vulkan pipeline compilation requires the graphics backend, this preview uses a software-approximated sphere rendering with simulated lighting based on the shader's apparent output. """ def __init__(self, **kwargs): super().__init__(**kwargs) self.size = Vec2(200, 200) self.bg_colour = _PREVIEW_BG self.preview_shape = "sphere" # "sphere", "cube", "quad" self.preview_colour = (0.4, 0.6, 1.0, 1.0) self.compile_ok = True self.compile_time_ms = 0.0 self._preview_angle = 0.0
[docs] def process(self, dt: float): self._preview_angle += dt * 30.0
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() # Background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) renderer.draw_filled_rect(x, y, w, 1, _SEPARATOR_COLOUR) # Draw label scale = _font_size() / 14.0 label = f"Preview ({self.preview_shape})" renderer.draw_text_coloured(label, x + _padding(), y + 4, scale * 0.85, _MUTED_COLOUR) # Draw the preview shape cx = x + w / 2 cy = y + h / 2 radius = min(w, h) * 0.3 r, g, b, a = self.preview_colour if not self.compile_ok: # Show error indicator renderer.draw_text_coloured("Compile Error", x + _padding(), y + h / 2 - 6, scale, _ERROR_COLOUR) return if self.preview_shape == "sphere": self._draw_sphere(renderer, cx, cy, radius, r, g, b, a) elif self.preview_shape == "cube": self._draw_cube(renderer, cx, cy, radius, r, g, b, a) else: # quad self._draw_quad(renderer, cx, cy, radius, r, g, b, a) # Status line at bottom status = f"OK ({self.compile_time_ms:.0f}ms)" if self.compile_ok else "Error" status_colour = _SUCCESS_COLOUR if self.compile_ok else _ERROR_COLOUR renderer.draw_text_coloured(status, x + _padding(), y + h - _font_size() - 4, scale * 0.85, status_colour)
def _draw_sphere(self, renderer, cx, cy, radius, r, g, b, a): """Draw a sphere approximation with shading rings.""" steps = 14 for i in range(steps, 0, -1): t = i / steps ring_r = radius * t shade = 0.3 + 0.7 * (1.0 - t * t) ring_colour = ( min(1.0, r * shade), min(1.0, g * shade), min(1.0, b * shade), a, ) half = ring_r renderer.draw_filled_rect(cx - half, cy - half, half * 2, half * 2, ring_colour) # Specular highlight hl_x = cx - radius * 0.25 hl_y = cy - radius * 0.25 hl_sz = radius * 0.25 renderer.draw_filled_rect(hl_x, hl_y, hl_sz, hl_sz, (1.0, 1.0, 1.0, 0.35)) def _draw_cube(self, renderer, cx, cy, radius, r, g, b, a): """Draw a simple cube representation.""" s = radius * 0.8 # Front face renderer.draw_filled_rect(cx - s, cy - s, s * 2, s * 2, (r * 0.8, g * 0.8, b * 0.8, a)) # Top face renderer.draw_filled_rect(cx - s, cy - s - s * 0.3, s * 2, s * 0.3, (r, g, b, a)) # Right face renderer.draw_filled_rect(cx + s, cy - s, s * 0.3, s * 2, (r * 0.6, g * 0.6, b * 0.6, a)) # Outline renderer.draw_rect_coloured(cx - s, cy - s, s * 2, s * 2, _SEPARATOR_COLOUR) def _draw_quad(self, renderer, cx, cy, radius, r, g, b, a): """Draw a flat quad representation.""" s = radius * 1.2 renderer.draw_filled_rect(cx - s, cy - s * 0.6, s * 2, s * 1.2, (r, g, b, a)) renderer.draw_rect_coloured(cx - s, cy - s * 0.6, s * 2, s * 1.2, _SEPARATOR_COLOUR)
# ============================================================================ # ShaderEditorPanel -- Main panel # ============================================================================
[docs] class ShaderEditorPanel(Control): """GLSL shader editor panel with syntax highlighting and live preview. Provides: - Split view: CodeTextEdit (80%) on left, preview + uniforms (20%) on right - GLSL syntax highlighting (keywords, types, built-ins, comments, preprocessor) - Live preview with sphere/cube/quad shape selector - Error display with inline markers and error panel - Auto-recompile with 500ms debounce after text changes - Uniform auto-discovery with appropriate editor widgets Args: editor_state: The central EditorState instance. """ def __init__(self, editor_state, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = _PANEL_BG self.size = Vec2(900, 600) # Signals self.shader_compiled = Signal() self.shader_error = Signal() # Highlighter self._highlighter = GLSLHighlighter() # State self._errors: list[_ErrorEntry] = [] self._uniforms: list[_UniformInfo] = [] self._last_edit_time: float = 0.0 self._needs_compile: bool = False self._compile_ok: bool = True self._compile_output: str = "" self._shader_type: str = "fragment" # "vertex" or "fragment" self._file_path: str | None = None # Widgets (created in _build_ui) self._toolbar: Control | None = None self._code_edit: CodeTextEdit | None = None self._preview: ShaderPreview | None = None self._error_panel: Control | None = None self._error_label: Label | None = None self._uniform_panel: Control | None = None self._shape_dropdown: DropDown | None = None self._type_dropdown: DropDown | None = None self._split: SplitContainer | None = None self._vsplit: SplitContainer | None = None self._build_ui() # ==================================================================== # UI construction # ==================================================================== def _build_ui(self): """Build the complete panel layout.""" # --- Toolbar --- self._toolbar = self._build_toolbar() self.add_child(self._toolbar) # --- Main content: horizontal split [code | preview+uniforms] --- self._split = SplitContainer(vertical=True, split_ratio=0.78, name="ShaderSplit") self._split.size = Vec2(self.size.x, self.size.y - _TOOLBAR_HEIGHT - _ERROR_PANEL_HEIGHT) # Left: Code editor self._code_edit = CodeTextEdit(text=_DEFAULT_FRAGMENT_SHADER, name="GLSLEditor") self._code_edit.show_line_numbers = True self._code_edit.bg_colour = (0.10, 0.10, 0.10, 1.0) self._code_edit.font_size = 13.0 self._code_edit.text_changed.connect(self._on_text_changed) self._split.add_child(self._code_edit) # Right: vertical split [preview | uniforms] right_panel = VBoxContainer(name="RightPanel") right_panel.separation = 0 self._preview = ShaderPreview(name="ShaderPreview") right_panel.add_child(self._preview) self._uniform_panel = Panel(name="UniformsPanel") self._uniform_panel.bg_colour = _UNIFORM_BG self._uniform_panel.size = Vec2(200, 200) right_panel.add_child(self._uniform_panel) self._split.add_child(right_panel) self.add_child(self._split) # --- Error panel at bottom --- self._error_panel = Panel(name="ErrorPanel") self._error_panel.bg_colour = _ERROR_BG self._error_panel.size = Vec2(self.size.x, _ERROR_PANEL_HEIGHT) error_title = Label("Errors", name="ErrorTitle") error_title.text_colour = _LABEL_COLOUR error_title.font_size = 11.0 error_title.position = Vec2(_padding(), 2) self._error_panel.add_child(error_title) self._error_label = Label("No errors", name="ErrorMessages") self._error_label.text_colour = _MUTED_COLOUR self._error_label.font_size = 11.0 self._error_label.position = Vec2(_padding(), 18) self._error_panel.add_child(self._error_label) self.add_child(self._error_panel) # Initial compile self._discover_uniforms() def _build_toolbar(self) -> Control: """Build the toolbar with compile, shape, and reset controls.""" toolbar = HBoxContainer(name="ShaderToolbar") toolbar.size = Vec2(self.size.x, _TOOLBAR_HEIGHT) toolbar.separation = 6.0 # Title label title = Label("Shader Editor") title.text_colour = _ACCENT_COLOUR title.font_size = 13.0 title.size = Vec2(100, _TOOLBAR_HEIGHT) toolbar.add_child(title) # Shader type dropdown self._type_dropdown = DropDown(items=["Fragment", "Vertex"], selected=0) self._type_dropdown.font_size = 11.0 self._type_dropdown.size = Vec2(80, 22) self._type_dropdown.item_selected.connect(self._on_type_changed) toolbar.add_child(self._type_dropdown) # Compile button compile_btn = Button("Compile") compile_btn.font_size = 11.0 compile_btn.size = Vec2(64, 22) compile_btn.pressed.connect(self._do_compile) toolbar.add_child(compile_btn) # Preview shape selector self._shape_dropdown = DropDown(items=["Sphere", "Cube", "Quad"], selected=0) self._shape_dropdown.font_size = 11.0 self._shape_dropdown.size = Vec2(70, 22) self._shape_dropdown.item_selected.connect(self._on_shape_changed) toolbar.add_child(self._shape_dropdown) # Reset button reset_btn = Button("Reset") reset_btn.font_size = 11.0 reset_btn.size = Vec2(52, 22) reset_btn.pressed.connect(self._on_reset) toolbar.add_child(reset_btn) return toolbar # ==================================================================== # Public API # ====================================================================
[docs] def load_shader(self, path: str): """Load a GLSL shader file into the editor. Args: path: Path to a .vert or .frag GLSL file. """ try: text = Path(path).read_text(encoding="utf-8") except OSError: return self._file_path = path # Detect shader type from extension if path.endswith(".vert"): self._shader_type = "vertex" if self._type_dropdown: self._type_dropdown.selected = 1 else: self._shader_type = "fragment" if self._type_dropdown: self._type_dropdown.selected = 0 if self._code_edit: self._code_edit.text = text self._do_compile()
[docs] def save_shader(self, path: str | None = None): """Save the current shader source to a file. Args: path: Output path. If None, saves to the originally loaded path. """ save_path = path or self._file_path if save_path is None or self._code_edit is None: return try: Path(save_path).write_text(self._code_edit.text, encoding="utf-8") self._file_path = save_path except OSError: pass
@property def shader_source(self) -> str: """Return the current shader source text.""" return self._code_edit.text if self._code_edit else "" @shader_source.setter def shader_source(self, text: str): """Set the shader source text.""" if self._code_edit: self._code_edit.text = text # ==================================================================== # Text change and debounced compilation # ==================================================================== def _on_text_changed(self, _text: str): """Handle text changes -- schedule a debounced recompile.""" self._last_edit_time = time.monotonic() self._needs_compile = True def _on_type_changed(self, index: int): """Handle shader type dropdown change.""" self._shader_type = "fragment" if index == 0 else "vertex" if self._code_edit: default = _DEFAULT_FRAGMENT_SHADER if index == 0 else _DEFAULT_VERTEX_SHADER if not self._code_edit.text.strip(): self._code_edit.text = default self._do_compile() def _on_shape_changed(self, index: int): """Handle preview shape selector change.""" shapes = ["sphere", "cube", "quad"] if self._preview and index < len(shapes): self._preview.preview_shape = shapes[index] def _on_reset(self): """Reset to the default shader template.""" if self._code_edit: if self._shader_type == "vertex": self._code_edit.text = _DEFAULT_VERTEX_SHADER else: self._code_edit.text = _DEFAULT_FRAGMENT_SHADER self._do_compile() # ==================================================================== # Compilation # ==================================================================== def _do_compile(self): """Attempt to compile the current shader source. Uses the shader_compiler module if available, otherwise falls back to basic syntax validation. """ if self._code_edit is None: return source = self._code_edit.text self._needs_compile = False compile_start = time.monotonic() # Try real compilation via glslc try: import tempfile from simvx.graphics.materials.shader_compiler import compile_shader ext = ".vert" if self._shader_type == "vertex" else ".frag" with tempfile.NamedTemporaryFile(mode="w", suffix=ext, delete=False, encoding="utf-8") as f: f.write(source) tmp_path = Path(f.name) try: compile_shader(tmp_path) self._compile_ok = True self._compile_output = "" self._errors = [] except RuntimeError as e: self._compile_ok = False self._compile_output = str(e) self._errors = _parse_errors(self._compile_output) finally: # Clean up temp files tmp_path.unlink(missing_ok=True) spv = tmp_path.parent / (tmp_path.name + ".spv") spv.unlink(missing_ok=True) except ImportError: # No graphics package -- do basic syntax check self._compile_ok, self._errors = self._basic_validate(source) self._compile_output = "\n".join(f"{e.line}: {e.severity}: {e.message}" for e in self._errors) compile_ms = (time.monotonic() - compile_start) * 1000.0 # Update preview if self._preview: self._preview.compile_ok = self._compile_ok self._preview.compile_time_ms = compile_ms # Update error panel self._update_error_display() # Update inline markers on the code editor self._update_markers() # Discover uniforms from source self._discover_uniforms() # Try to extract a preview colour from the fragment shader self._update_preview_colour(source) # Emit signals if self._compile_ok: self.shader_compiled.emit() else: self.shader_error.emit(self._compile_output) def _basic_validate(self, source: str) -> tuple[bool, list[_ErrorEntry]]: """Basic GLSL syntax validation without glslc. Checks for common issues: mismatched braces, missing semicolons after statements, and unrecognized preprocessor directives. """ errors: list[_ErrorEntry] = [] brace_depth = 0 in_block_comment = False for i, line in enumerate(source.splitlines(), start=1): stripped = line.strip() # Track block comments if in_block_comment: if "*/" in stripped: in_block_comment = False continue if "/*" in stripped: if "*/" not in stripped[stripped.index("/*") + 2 :]: in_block_comment = True continue # Skip empty lines and comments if not stripped or stripped.startswith("//"): continue # Track braces brace_depth += stripped.count("{") - stripped.count("}") # Check for preprocessor if stripped.startswith("#"): directive = stripped.split()[0] if stripped.split() else "" valid = { "#version", "#define", "#undef", "#if", "#ifdef", "#ifndef", "#else", "#elif", "#endif", "#include", "#pragma", "#extension", "#line", "#error", } if directive not in valid: errors.append(_ErrorEntry(i, 0, "warning", f"Unknown directive: {directive}")) if brace_depth != 0: errors.append( _ErrorEntry( len(source.splitlines()), 0, "error", f"Mismatched braces (depth={brace_depth})", ) ) if in_block_comment: errors.append( _ErrorEntry( len(source.splitlines()), 0, "error", "Unterminated block comment", ) ) return (not errors or all(e.severity == "warning" for e in errors), errors) # ==================================================================== # Error display # ==================================================================== def _update_error_display(self): """Update the error panel text.""" if self._error_label is None: return if not self._errors: self._error_label.text = "No errors" self._error_label.text_colour = _SUCCESS_COLOUR return # Build error display text (first 5 errors) lines: list[str] = [] for err in self._errors[:5]: prefix = "ERR" if err.severity == "error" else "WRN" lines.append(f"[{prefix}] Line {err.line}: {err.message}") if len(self._errors) > 5: lines.append(f" ... and {len(self._errors) - 5} more") self._error_label.text = "\n".join(lines) has_errors = any(e.severity == "error" for e in self._errors) self._error_label.text_colour = _ERROR_COLOUR if has_errors else _WARNING_COLOUR def _update_markers(self): """Place TextMarker squiggles on error lines in the code editor.""" if self._code_edit is None: return self._code_edit.clear_markers() for err in self._errors: colour = (1.0, 0.2, 0.2, 0.6) if err.severity == "error" else (1.0, 0.8, 0.0, 0.6) self._code_edit.add_marker( line=max(0, err.line - 1), # 0-indexed col_start=0, col_end=200, # full line type=err.severity, colour=colour, tooltip=err.message, ) # ==================================================================== # Uniform discovery and widget creation # ==================================================================== def _discover_uniforms(self): """Parse the shader source to find standalone uniform declarations.""" if self._code_edit is None or self._uniform_panel is None: return source = self._code_edit.text new_uniforms: list[_UniformInfo] = [] for line in source.splitlines(): m = _UNIFORM_RE.match(line) if m: glsl_type = m.group(1) name = m.group(2) # Skip types that are not simple scalars/vectors if glsl_type in _GLSL_TYPES or glsl_type in ("float", "int", "uint", "bool"): info = _UniformInfo(name, glsl_type) # Preserve existing value if uniform was already known for existing in self._uniforms: if existing.name == name and existing.glsl_type == glsl_type: info.value = existing.value break new_uniforms.append(info) self._uniforms = new_uniforms self._rebuild_uniform_widgets() def _rebuild_uniform_widgets(self): """Rebuild the uniform control widgets in the uniforms panel.""" if self._uniform_panel is None: return # Clear existing children for child in list(self._uniform_panel.children): self._uniform_panel.remove_child(child) # Title title = Label("Uniforms") title.text_colour = _LABEL_COLOUR title.font_size = 11.0 title.position = Vec2(_padding(), 4) title.size = Vec2(100, 16) self._uniform_panel.add_child(title) if not self._uniforms: hint = Label("No standalone uniforms found") hint.text_colour = _MUTED_COLOUR hint.font_size = 10.0 hint.position = Vec2(_padding(), 22) hint.size = Vec2(180, 14) self._uniform_panel.add_child(hint) return # Create a widget for each uniform y_offset = 24.0 for info in self._uniforms: row = self._create_uniform_row(info) if row: row.position = Vec2(_padding(), y_offset) row.size = Vec2( self._uniform_panel.size.x - _padding() * 2, _row_h(), ) self._uniform_panel.add_child(row) y_offset += _row_h() + 2 def _create_uniform_row(self, info: _UniformInfo) -> Control | None: """Create an appropriate editor widget for a uniform type.""" row = HBoxContainer(name=f"u_{info.name}") row.separation = 4.0 row.size = Vec2(180, _row_h()) # Label label = Label(info.name) label.text_colour = _LABEL_COLOUR label.font_size = 10.0 label.size = Vec2(60, _row_h()) row.add_child(label) # Widget based on type if info.glsl_type == "float": slider = Slider(min_val=-10.0, max_val=10.0, value=float(info.value)) slider.step = 0.01 slider.size = Vec2(100, 18) slider.value_changed.connect(lambda v, i=info: setattr(i, "value", v)) row.add_child(slider) info.widget = slider elif info.glsl_type in ("int", "uint"): slider = Slider(min_val=0.0, max_val=100.0, value=float(info.value)) slider.step = 1.0 slider.size = Vec2(100, 18) slider.value_changed.connect(lambda v, i=info: setattr(i, "value", int(v))) row.add_child(slider) info.widget = slider elif info.glsl_type == "bool": cb = CheckBox("", checked=bool(info.value)) cb.size = Vec2(20, 20) cb.toggled.connect(lambda v, i=info: setattr(i, "value", v)) row.add_child(cb) info.widget = cb elif info.glsl_type in ("vec3", "dvec3"): # Use a colour picker for vec3 (common for colours) picker = ColourPicker() picker.size = Vec2(100, 80) val = info.value picker.colour = (val[0], val[1], val[2], 1.0) if len(val) >= 3 else (0.5, 0.5, 0.5, 1.0) picker.colour_changed.connect(lambda c, i=info: setattr(i, "value", (c[0], c[1], c[2]))) row.add_child(picker) info.widget = picker elif info.glsl_type in ("vec4", "dvec4"): picker = ColourPicker() picker.size = Vec2(100, 80) val = info.value picker.colour = tuple(val[:4]) picker.colour_changed.connect(lambda c, i=info: setattr(i, "value", c)) row.add_child(picker) info.widget = picker else: # Type label for unsupported types type_label = Label(info.glsl_type) type_label.text_colour = _MUTED_COLOUR type_label.font_size = 10.0 type_label.size = Vec2(80, _row_h()) row.add_child(type_label) return row # ==================================================================== # Preview colour extraction # ==================================================================== def _update_preview_colour(self, source: str): """Attempt to extract a preview colour from the fragment shader. Parses simple vec3/vec4 colour assignments to provide visual feedback in the preview widget. """ if self._preview is None or self._shader_type != "fragment": return # Look for common patterns: vec3(r, g, b) or vec4(r, g, b, a) # in the final output assignment colour_re = re.compile(r"vec[34]\s*\(\s*" r"([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)" r"(?:\s*,\s*([\d.]+))?\s*\)") matches = list(colour_re.finditer(source)) if matches: m = matches[-1] # Use the last match (likely the output) try: r = float(m.group(1)) g = float(m.group(2)) b = float(m.group(3)) a = float(m.group(4)) if m.group(4) else 1.0 self._preview.preview_colour = (min(1.0, r), min(1.0, g), min(1.0, b), min(1.0, a)) except (ValueError, TypeError): pass # ==================================================================== # Layout and drawing # ====================================================================
[docs] def process(self, dt: float): """Per-frame update: handle debounced compilation and layout.""" # Debounced compilation if self._needs_compile: elapsed = time.monotonic() - self._last_edit_time if elapsed >= _DEBOUNCE_SECONDS: self._do_compile() # Layout self._update_layout()
def _update_layout(self): """Position all child widgets to fill the panel.""" _, _, w, h = self.get_rect() if w < 1 or h < 1: return # Toolbar at top if self._toolbar: self._toolbar.position = Vec2(0, 0) self._toolbar.size = Vec2(w, _TOOLBAR_HEIGHT) # Error panel at bottom error_h = _ERROR_PANEL_HEIGHT if self._error_panel: self._error_panel.position = Vec2(0, h - error_h) self._error_panel.size = Vec2(w, error_h) # Main split fills the middle if self._split: split_y = _TOOLBAR_HEIGHT split_h = h - _TOOLBAR_HEIGHT - error_h self._split.position = Vec2(0, split_y) self._split.size = Vec2(w, max(100, split_h)) # Right panel children layout if self._preview and self._uniform_panel and self._split: # Get available width for the right panel right_w = w * (1.0 - self._split.split_ratio) - self._split.divider_width right_h = max(100, h - _TOOLBAR_HEIGHT - error_h) preview_h = right_h * 0.5 uniform_h = right_h - preview_h self._preview.size = Vec2(max(100, right_w), max(50, preview_h)) self._uniform_panel.size = Vec2(max(100, right_w), max(50, uniform_h))
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() # Panel background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) # Toolbar background renderer.draw_filled_rect(x, y, w, _TOOLBAR_HEIGHT, _TOOLBAR_BG) renderer.draw_filled_rect(x, y + _TOOLBAR_HEIGHT - 1, w, 1, _SEPARATOR_COLOUR) # Left accent border renderer.draw_filled_rect(x, y, 2, h, _ACCENT_COLOUR)
def _draw_recursive(self, renderer): """Draw with clipping.""" if not self.visible: return self.draw(renderer) x, y, w, h = self.get_global_rect() renderer.push_clip(x, y, w, h) # Draw the GLSL tokens with custom colours self._apply_glsl_highlighting() for child in self.children: if isinstance(child, Control) and child.visible: child._draw_recursive(renderer) renderer.pop_clip() # ==================================================================== # GLSL syntax highlighting integration # ==================================================================== def _apply_glsl_highlighting(self): """Override the CodeTextEdit's tokenizer output with GLSL tokens. Injects GLSL-aware tokens into the code editor's token cache so the standard drawing path renders them with the correct colours. """ if self._code_edit is None: return # Reset highlighter state for clean pass self._highlighter.reset() lines = self._code_edit._lines for i, line in enumerate(lines): tokens = self._highlighter.tokenize_line(line) # Map our token types to the _SYNTAX_COLOURS keys that CodeTextEdit uses mapped: list[tuple[str, str]] = [] for text, ttype in tokens: mapped.append((text, ttype)) # Inject into the cache with correct line hash line_hash = hash(line) self._code_edit._token_cache[i] = (line_hash, mapped) # Also inject our colour table into the module-level dict # that CodeTextEdit.draw() uses for colour lookups import simvx.core.ui.code_edit as ce_mod for key, colour in _GLSL_SYNTAX_COLOURS.items(): ce_mod._SYNTAX_COLOURS[key] = colour