"""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