Skip to content

Instantly share code, notes, and snippets.

@SqrtRyan
Created July 1, 2025 22:51
Show Gist options
  • Save SqrtRyan/a875e320d720d9afec231718057d2b95 to your computer and use it in GitHub Desktop.
Save SqrtRyan/a875e320d720d9afec231718057d2b95 to your computer and use it in GitHub Desktop.
# ==========================================================================
# FILE: pimgui_skia.py
#
# DOCTRINES (Unchanged from original):
# 1. Component Architecture: The UI is a tree of components.
# 2. Implicit Bidirectional Binding: Attributes are bound via assignment.
# 3. Declarative Layout: Layouts are defined procedurally.
# 4. IMGUI (Image GUI): The UI operates on a drawing-library-agnostic
# NumPy array as its primary image buffer.
# 5. Event Broadcast: Events are broadcast recursively.
# 6. Parent/Child Hierarchy: Components maintain parent/child relationships.
#
# FRAMEWORK GUIDE FOR AI DEVELOPERS:
# This framework uses several advanced Python features to create a concise
# and declarative UI system. Understanding them is key to avoiding bugs.
#
# 1. State Proxy Architecture:
# - Each component has a `state` attribute that is a StateProxy instance.
# - Declaring state: `self.state.my_var = initial_value` creates a State object.
# - Binding to components: `Child(prop=self.state.my_var)` passes the State object.
# - Reading values: `self.my_var` returns the current value from the bound state.
# - Setting values: `self.my_var = new_value` updates the bound state.
#
# 2. `Component.__setattr__` Magic Method (The `nn.Module` Pattern):
# - This method is customized to handle special cases, a design pattern
# directly inspired by high-level ML frameworks like PyTorch.
# - When you assign a `Component` to an attribute (e.g., `self.title_bar = TitleBar()`),
# the `__setattr__` method intercepts this action. Instead of a simple
# assignment, it AUTOMATICALLY registers the new component as a child
# of the current one, adding it to `self.children`.
# - This is the *exact same mechanic* used by `torch.nn.Module`. When a user
# writes `self.conv1 = nn.Conv2d()`, PyTorch's `__setattr__` automatically
# registers `conv1` as a sub-module. This allows for a clean, declarative
# API while the framework handles the complex tree-building in the background.
# - It also handles state updates: if an attribute is managed by the state proxy,
# `__setattr__` will update the state's value.
#
# 3. The `UIContext` and `context` Property:
# - The `UIContext` is the master object that holds the drawing buffer
# (`image_buffer`) and the display list (`display_list`).
# - It is established as the TOP-LEVEL PARENT of the root component (e.g., DemoApp).
# - Any component can access it via the `self.context` cached property,
# which works by traversing the parent hierarchy to the top.
#
# 4. Key Development Pattern:
# - Use `self.state.my_var = value` to declare reactive state variables.
# - Use `Child(prop=self.state.my_var)` to bind state to child components.
# - Use `self.my_var` to read current values and `self.my_var = value` to update.
# - The framework automatically handles the data flow between components.
# ==========================================================================
import numpy as np
import skia
import weakref
from functools import cached_property
from contextlib import contextmanager
import time
import rp #My personal library: Ryan-Python. Get via "pip install rp"
# Global weak dictionary to map component IDs to component instances
_component_registry = weakref.WeakValueDictionary()
# --------------------------------------------------------------------------
# Backend-Agnostic Keyboard Constants
# --------------------------------------------------------------------------
class Key:
"""Backend-agnostic keyboard constants."""
BACKSPACE = "backspace"
DELETE = "delete"
LEFT = "left"
RIGHT = "right"
HOME = "home"
END = "end"
UP = "up"
DOWN = "down"
ENTER = "enter"
TAB = "tab"
ESCAPE = "escape"
SHIFT = "shift"
CTRL = "ctrl"
ALT = "alt"
COMMAND = "command"
def convert_pygame_key(pygame_key, unicode_char=""):
"""Converts pygame key constants to our backend-agnostic format."""
import pygame
key_map = {
pygame.K_BACKSPACE: Key.BACKSPACE,
pygame.K_DELETE: Key.DELETE,
pygame.K_LEFT: Key.LEFT,
pygame.K_RIGHT: Key.RIGHT,
pygame.K_HOME: Key.HOME,
pygame.K_END: Key.END,
pygame.K_UP: Key.UP,
pygame.K_DOWN: Key.DOWN,
pygame.K_RETURN: Key.ENTER,
pygame.K_TAB: Key.TAB,
pygame.K_ESCAPE: Key.ESCAPE,
pygame.K_LSHIFT: Key.SHIFT,
pygame.K_RSHIFT: Key.SHIFT,
pygame.K_LCTRL: Key.CTRL,
pygame.K_RCTRL: Key.CTRL,
pygame.K_LALT: Key.ALT,
pygame.K_RALT: Key.ALT,
pygame.K_LMETA: Key.COMMAND,
pygame.K_RMETA: Key.COMMAND,
}
# If it's a special key, return the enum (prioritize over unicode)
if pygame_key in key_map:
return key_map[pygame_key]
# For regular characters, return the unicode character itself
# But skip control characters like \r, \n, \t that should be handled as special keys
if unicode_char and unicode_char.isprintable() and ord(unicode_char) >= 32:
return unicode_char
return None
# --------------------------------------------------------------------------
# Event & Drawing Utilities
# --------------------------------------------------------------------------
class Event:
"""A simple class to represent UI events."""
def __init__(self, event_type: str, **kwargs):
self.type = event_type
self.__dict__.update(kwargs)
self.handled_by = None # Flag to stop event propagation and tell who caught it
def draw_rect(buffer, x, y, w, h, color):
"""Draws a solid rectangle onto the RGBA numpy buffer using Skia."""
# This implementation directly wraps the buffer's memory for high performance.
if w <= 0 or h <= 0:
return
height, width, _ = buffer.shape
image_info = skia.ImageInfo.Make(
width, height,
ct=skia.kRGBA_8888_ColorType,
at=skia.kPremul_AlphaType
)
# Create a surface that directly wraps the numpy buffer's memory
surface = skia.Surface.MakeRasterDirect(image_info, buffer.data, buffer.strides[0])
if surface is None:
raise RuntimeError("Failed to create Skia surface from buffer.")
with surface as canvas:
paint = skia.Paint(
# Handle both RGB and RGBA color tuples by adding full alpha if missing
Color=skia.Color(*color) if len(color) == 4 else skia.Color(*color, 255),
AntiAlias=True
)
canvas.drawRect(skia.Rect.MakeXYWH(x, y, w, h), paint)
def draw_matrix_rect(m, i, x, y, h, w):
H, W = m.shape
if w > 0 and h > 0:
x0, y0 = max(0, int(x)), max(0, int(y))
x1, y1 = min(W, int(x + w)), min(H, int(y + h))
if x1 > x0 and y1 > y0:
m[y0:y1, x0:x1] = i
def draw_shadow(buffer, x, y, w, h, blur_radius=8, offset_x=4, offset_y=4, shadow_color=(0, 0, 0, 80)):
"""Draws a soft drop shadow for a rectangle using Skia."""
if w <= 0 or h <= 0:
return
height, width, _ = buffer.shape
image_info = skia.ImageInfo.Make(
width, height,
ct=skia.kRGBA_8888_ColorType,
at=skia.kPremul_AlphaType
)
# Create a surface that directly wraps the numpy buffer's memory
surface = skia.Surface.MakeRasterDirect(image_info, buffer.data, buffer.strides[0])
if surface is None:
raise RuntimeError("Failed to create Skia surface from buffer.")
with surface as canvas:
# Create shadow paint with blur
shadow_paint = skia.Paint(
Color=skia.Color(*shadow_color) if len(shadow_color) == 4 else skia.Color(*shadow_color, 80),
AntiAlias=True,
MaskFilter=skia.MaskFilter.MakeBlur(skia.kNormal_BlurStyle, blur_radius / 2)
)
# Draw shadow rectangle offset from the main rectangle
shadow_rect = skia.Rect.MakeXYWH(x + offset_x, y + offset_y, w, h)
canvas.drawRect(shadow_rect, shadow_paint)
_font_cache = {}
def get_font(font_name: str, size: int) -> skia.Font:
"""Gets a Skia font, caching it for performance."""
if (font_name, size) in _font_cache:
return _font_cache[(font_name, size)]
try:
# Use a specific name for common monospace fonts
if font_name.lower() in ['courier new', 'courier']:
typeface = skia.Typeface('Courier New')
else:
typeface = skia.Typeface(font_name)
except RuntimeError:
typeface = None # Fallback to Skia's default
font = skia.Font(typeface, size)
_font_cache[(font_name, size)] = font
return font
def draw_text(buffer, text, x, y, color, font_name='Arial', font_size=12):
"""Draws text onto the RGBA numpy buffer using Skia."""
height, width, _ = buffer.shape
image_info = skia.ImageInfo.Make(
width, height, ct=skia.kRGBA_8888_ColorType, at=skia.kPremul_AlphaType
)
surface = skia.Surface.MakeRasterDirect(image_info, buffer.data, buffer.strides[0])
if surface is None:
raise RuntimeError("Failed to create Skia surface from buffer.")
with surface as canvas:
font = get_font(font_name, font_size)
paint = skia.Paint(
Color=skia.Color(*color) if len(color) == 4 else skia.Color(*color, 255),
AntiAlias=True
)
# Adjust y for Skia's baseline rendering to match top-left anchor
canvas.drawString(str(text), float(x), float(y) + font_size, font, paint)
def measure_text(text, font_name='Arial', font_size=12) -> float:
"""Measures the width of a string for a given font."""
font = get_font(font_name, font_size)
return font.measureText(str(text))
def draw_circle(buffer, x, y, radius, color):
"""Draws a filled circle using Skia."""
if radius <= 0:
return
height, width, _ = buffer.shape
image_info = skia.ImageInfo.Make(
width, height, ct=skia.kRGBA_8888_ColorType, at=skia.kPremul_AlphaType
)
surface = skia.Surface.MakeRasterDirect(image_info, buffer.data, buffer.strides[0])
if surface is None:
raise RuntimeError("Failed to create Skia surface from buffer.")
with surface as canvas:
paint = skia.Paint(
Color=skia.Color(*color) if len(color) == 4 else skia.Color(*color, 255),
AntiAlias=True
)
canvas.drawCircle(float(x), float(y), float(radius), paint)
def draw_gradient_circle(buffer, x, y, radius, center_color, edge_color):
"""Draws a circle with radial gradient from center to edge."""
if radius <= 0:
return
height, width, _ = buffer.shape
image_info = skia.ImageInfo.Make(
width, height, ct=skia.kRGBA_8888_ColorType, at=skia.kPremul_AlphaType
)
surface = skia.Surface.MakeRasterDirect(image_info, buffer.data, buffer.strides[0])
if surface is None:
raise RuntimeError("Failed to create Skia surface from buffer.")
with surface as canvas:
# Create radial gradient
gradient = skia.GradientShader.MakeRadial(
center=(float(x), float(y)),
radius=float(radius),
colors=[
skia.Color(*center_color) if len(center_color) == 4 else skia.Color(*center_color, 255),
skia.Color(*edge_color) if len(edge_color) == 4 else skia.Color(*edge_color, 0)
],
positions=[0.0, 1.0]
)
paint = skia.Paint(Shader=gradient, AntiAlias=True)
canvas.drawCircle(float(x), float(y), float(radius), paint)
def draw_lens_flare(buffer, x, y, intensity=1.0, size_multiplier=1.0):
"""Draws a lens flare effect with multiple overlapping circles."""
base_radius = 30 * intensity * size_multiplier
# Main bright center
draw_gradient_circle(buffer, x, y, base_radius,
(255, 255, 255, int(200 * intensity)),
(255, 255, 255, 0))
# Colored rings
draw_gradient_circle(buffer, x, y, base_radius * 1.5,
(255, 200, 100, int(100 * intensity)),
(255, 200, 100, 0))
draw_gradient_circle(buffer, x, y, base_radius * 2.0,
(100, 150, 255, int(60 * intensity)),
(100, 150, 255, 0))
def draw_sub_flare(buffer, x, y, size, color=(255, 255, 255, 120)):
"""Draws a smaller sub-flare for the lens flare system."""
draw_gradient_circle(buffer, x, y, size, color, (color[0], color[1], color[2], 0))
def draw_magnified_region(buffer, source_buffer, center_x, center_y, radius, magnification=2.0, distortion=0.1, chromatic_aberration=1.0):
"""Draws a magnified view using OpenCV for distortion and Skia for rendering."""
if radius <= 0:
return
import cv2
import numpy as np
height, width, _ = buffer.shape
# Extract circular region from source
y1, y2 = max(0, int(center_y - radius)), min(height, int(center_y + radius))
x1, x2 = max(0, int(center_x - radius)), min(width, int(center_x + radius))
if y2 <= y1 or x2 <= x1:
return
# Get source region for magnification
src_radius = int(radius / magnification)
src_y1 = max(0, int(center_y - src_radius))
src_y2 = min(height, int(center_y + src_radius))
src_x1 = max(0, int(center_x - src_radius))
src_x2 = min(width, int(center_x + src_radius))
if src_y2 <= src_y1 or src_x2 <= src_x1:
return
# Extract and resize source region
src_region = source_buffer[src_y1:src_y2, src_x1:src_x2].copy()
target_size = int(radius * 2)
# Apply magnification with OpenCV
magnified = cv2.resize(src_region, (target_size, target_size), interpolation=cv2.INTER_CUBIC)
# Apply barrel distortion if requested using OpenCV
if distortion > 0:
h, w = magnified.shape[:2]
# Create camera matrix (identity for simple distortion)
camera_matrix = np.array([[w, 0, w/2],
[0, h, h/2],
[0, 0, 1]], dtype=np.float32)
# Distortion coefficients: [k1, k2, p1, p2, k3]
# k1 = barrel/pincushion distortion
dist_coeffs = np.array([distortion * 2, 0, 0, 0, 0], dtype=np.float32)
# Apply distortion using OpenCV
magnified = cv2.undistort(magnified, camera_matrix, dist_coeffs)
# Apply chromatic aberration if requested
if chromatic_aberration > 1 and len(magnified.shape) == 3 and magnified.shape[2] >= 3:
h, w = magnified.shape[:2]
shift = int(chromatic_aberration)
# Separate color channels (handle both BGR and BGRA)
if magnified.shape[2] == 3:
b, g, r = cv2.split(magnified)
alpha = None
elif magnified.shape[2] == 4:
b, g, r, alpha = cv2.split(magnified)
# Shift red channel slightly
M_r = np.float32([[1, 0, shift], [0, 1, 0]])
r = cv2.warpAffine(r, M_r, (w, h))
# Shift blue channel opposite direction
M_b = np.float32([[1, 0, -shift], [0, 1, 0]])
b = cv2.warpAffine(b, M_b, (w, h))
# Merge channels back
if alpha is not None:
magnified = cv2.merge([b, g, r, alpha])
else:
magnified = cv2.merge([b, g, r])
# Now composite the magnified region onto the buffer with circular mask
draw_image_circular(buffer, magnified, center_x, center_y, radius)
def draw_image_circular(buffer, image, center_x, center_y, radius):
"""Draws an image with circular clipping mask."""
if radius <= 0:
return
import cv2
height, width, _ = buffer.shape
img_h, img_w = image.shape[:2]
# Calculate bounds
y1 = max(0, int(center_y - radius))
y2 = min(height, int(center_y + radius))
x1 = max(0, int(center_x - radius))
x2 = min(width, int(center_x + radius))
if y2 <= y1 or x2 <= x1:
return
# Create circular mask
mask_h, mask_w = y2 - y1, x2 - x1
mask = np.zeros((mask_h, mask_w), dtype=np.uint8)
# Draw filled circle on mask
cv2.circle(mask,
(int(center_x - x1), int(center_y - y1)),
int(radius), 255, -1)
# Resize image to fit the bounding box
if img_h != mask_h or img_w != mask_w:
image = cv2.resize(image, (mask_w, mask_h), interpolation=cv2.INTER_CUBIC)
# Apply mask and composite - match channel count
buffer_region = buffer[y1:y2, x1:x2]
img_channels = image.shape[2] if len(image.shape) == 3 else 1
buf_channels = buffer_region.shape[2] if len(buffer_region.shape) == 3 else 1
# Create mask with matching channels
if img_channels == 4:
mask_nd = cv2.merge([mask, mask, mask, mask]) / 255.0
else:
mask_nd = cv2.merge([mask, mask, mask]) / 255.0
# Ensure image and buffer region have same number of channels
if img_channels != buf_channels:
if img_channels == 4 and buf_channels == 3:
# Convert RGBA image to RGB
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
mask_nd = mask_nd[:,:,:3]
elif img_channels == 3 and buf_channels == 4:
# Convert RGB image to RGBA
image = cv2.cvtColor(image, cv2.COLOR_RGB2RGBA)
# Blend with existing buffer content
blended = image * mask_nd + buffer_region * (1 - mask_nd)
buffer[y1:y2, x1:x2] = blended.astype(np.uint8)
def draw_glass_rim(buffer, center_x, center_y, radius, rim_width=8):
"""Draws the metallic rim of a magnifying glass efficiently."""
if radius <= 0:
return
height, width, _ = buffer.shape
image_info = skia.ImageInfo.Make(
width, height, ct=skia.kRGBA_8888_ColorType, at=skia.kPremul_AlphaType
)
surface = skia.Surface.MakeRasterDirect(image_info, buffer, buffer.strides[0])
if surface is None:
return
with surface as canvas:
# Create a single radial gradient for the entire rim
rim_gradient = skia.GradientShader.MakeRadial(
center=(float(center_x - rim_width * 0.2), float(center_y - rim_width * 0.2)),
radius=float(rim_width * 1.2),
colors=[
skia.Color(160, 160, 170, 255), # Bright highlight
skia.Color(90, 90, 100, 255), # Mid tone
skia.Color(50, 50, 60, 255) # Dark edge
],
positions=[0.0, 0.6, 1.0]
)
# Draw rim with single anti-aliased circle
rim_paint = skia.Paint(Shader=rim_gradient, AntiAlias=True)
canvas.drawCircle(float(center_x), float(center_y), float(radius + rim_width), rim_paint)
# Cut out the center with transparency (for the glass area)
center_paint = skia.Paint(
Color=skia.Color(0, 0, 0, 0), # Transparent
AntiAlias=True,
BlendMode=skia.BlendMode.kClear
)
canvas.drawCircle(float(center_x), float(center_y), float(radius), center_paint)
def draw_glass_reflection(buffer, center_x, center_y, radius):
"""Draws realistic glass reflections and highlights."""
# Main reflection highlight (upper left)
highlight_x = center_x - radius * 0.3
highlight_y = center_y - radius * 0.3
highlight_radius = radius * 0.2
draw_gradient_circle(buffer, highlight_x, highlight_y, highlight_radius,
(255, 255, 255, 120), (255, 255, 255, 0))
# Secondary reflection (lower right, smaller)
highlight2_x = center_x + radius * 0.4
highlight2_y = center_y + radius * 0.4
highlight2_radius = radius * 0.1
draw_gradient_circle(buffer, highlight2_x, highlight2_y, highlight2_radius,
(255, 255, 255, 60), (255, 255, 255, 0))
# Edge reflection (subtle rim light)
edge_highlight_radius = radius * 0.95
draw_gradient_circle(buffer, center_x, center_y, radius,
(0, 0, 0, 0), (255, 255, 255, 20))
def draw_crt_effect(buffer, x, y, w, h, beam_width=1.0, phosphor_glow=0.8, curvature=0.02, noise=0.1):
"""Applies CRT monitor effect with scanlines, curvature, and noise using numba."""
import cv2
import numpy as np
try:
from numba import jit
except ImportError:
# Fallback without numba
jit = lambda func: func
if w <= 0 or h <= 0:
return
# Extract region
buffer_h, buffer_w = buffer.shape[:2]
x1, y1 = max(0, int(x)), max(0, int(y))
x2, y2 = min(buffer_w, int(x + w)), min(buffer_h, int(y + h))
if x2 <= x1 or y2 <= y1:
return
region = buffer[y1:y2, x1:x2].copy()
region_h, region_w = region.shape[:2]
# Convert to float for processing
region_float = region.astype(np.float32) / 255.0
def fast_crt_effect(img, beam_width, phosphor_glow):
"""Fast CRT effect using pure vectorized operations - no fallbacks."""
result = img.copy()
# Scanlines - darken every other row
result[::2, :, :] *= (1.0 - beam_width * 0.2)
# Add glow to dark scanlines (handle odd/even row counts)
if img.shape[0] > 1:
dark_lines = result[1::2, :, :]
bright_lines = result[::2, :, :]
# Only copy up to the minimum size to avoid broadcasting issues
min_rows = min(dark_lines.shape[0], bright_lines.shape[0])
if min_rows > 0:
result[1::2, :, :][:min_rows] = bright_lines[:min_rows] * 0.1
# Horizontal beam glow using OpenCV
kernel_w = max(3, int(beam_width * 3))
if kernel_w % 2 == 0:
kernel_w += 1
# Apply horizontal blur for beam glow effect
for c in range(result.shape[2]):
result[:,:,c] = cv2.GaussianBlur(result[:,:,c], (kernel_w, 1),
sigmaX=beam_width*0.5, sigmaY=0)
# Phosphor glow
result *= phosphor_glow
result = np.clip(result, 0.0, 1.0)
return result
def apply_noise_vectorized(img, noise_level):
"""Add noise with pure NumPy vectorization."""
# Generate noise for entire image at once
noise = (np.random.random(img.shape) - 0.5) * noise_level
result = np.clip(img + noise, 0.0, 1.0)
return result
# Apply barrel distortion using OpenCV (fast)
if curvature > 0:
h, w = region_float.shape[:2]
camera_matrix = np.array([[w*0.8, 0, w/2], [0, h*0.8, h/2], [0, 0, 1]], dtype=np.float32)
dist_coeffs = np.array([curvature * 0.5, 0, 0, 0, 0], dtype=np.float32)
region_float = cv2.undistort(region_float, camera_matrix, dist_coeffs)
# Apply CRT beam simulation
processed = fast_crt_effect(region_float, beam_width, phosphor_glow)
# Apply noise (vectorized - no loops!)
if noise > 0:
processed = apply_noise_vectorized(processed, noise * 0.3)
# Convert back to uint8
result = (processed * 255).astype(np.uint8)
# Apply slight color tint (greenish CRT phosphor)
result[:,:,1] = np.minimum(255, result[:,:,1] * 1.1).astype(np.uint8) # Boost green
result[:,:,0] = (result[:,:,0] * 0.9).astype(np.uint8) # Reduce red slightly
# Put back into buffer
buffer[y1:y2, x1:x2] = result
def draw_magnifying_glass_handle(buffer, glass_x, glass_y, radius, handle_angle=45):
"""Draws a realistic 3D magnifying glass handle extending from the rim."""
if radius <= 0:
return
height, width, _ = buffer.shape
image_info = skia.ImageInfo.Make(
width, height, ct=skia.kRGBA_8888_ColorType, at=skia.kPremul_AlphaType
)
surface = skia.Surface.MakeRasterDirect(image_info, buffer, buffer.strides[0])
if surface is None:
return
import math
angle_rad = math.radians(handle_angle)
# Handle dimensions based on glass size
handle_length = radius * 1.8
handle_width = radius * 0.15
# Calculate handle start and end points
start_x = glass_x + math.cos(angle_rad) * (radius + 5)
start_y = glass_y + math.sin(angle_rad) * (radius + 5)
end_x = glass_x + math.cos(angle_rad) * (radius + handle_length)
end_y = glass_y + math.sin(angle_rad) * (radius + handle_length)
with surface as canvas:
# Calculate perpendicular offset for width
perp_x = -math.sin(angle_rad) * handle_width
perp_y = math.cos(angle_rad) * handle_width
# Draw multiple layers for 3D effect
for layer in range(3):
layer_offset = layer * 1.5
layer_alpha = 255 - layer * 40
# Create handle path for this layer
path = skia.Path()
path.moveTo(start_x + perp_x + layer_offset, start_y + perp_y + layer_offset)
path.lineTo(start_x - perp_x + layer_offset, start_y - perp_y + layer_offset)
path.lineTo(end_x - perp_x + layer_offset, end_y - perp_y + layer_offset)
path.lineTo(end_x + perp_x + layer_offset, end_y + perp_y + layer_offset)
path.close()
# Handle body with 3D shading
if layer == 0: # Main body
handle_paint = skia.Paint(
Color=skia.Color(45, 45, 55, layer_alpha),
AntiAlias=True
)
elif layer == 1: # Mid tone
handle_paint = skia.Paint(
Color=skia.Color(75, 75, 85, layer_alpha),
AntiAlias=True
)
else: # Highlight
handle_paint = skia.Paint(
Color=skia.Color(130, 130, 140, layer_alpha),
AntiAlias=True
)
canvas.drawPath(path, handle_paint)
# Top highlight strip (3D beveled edge)
highlight_path = skia.Path()
highlight_width = handle_width * 0.4
highlight_perp_x = -math.sin(angle_rad) * highlight_width
highlight_perp_y = math.cos(angle_rad) * highlight_width
highlight_path.moveTo(start_x + highlight_perp_x, start_y + highlight_perp_y)
highlight_path.lineTo(start_x - highlight_perp_x * 0.3, start_y - highlight_perp_y * 0.3)
highlight_path.lineTo(end_x - highlight_perp_x * 0.3, end_y - highlight_perp_y * 0.3)
highlight_path.lineTo(end_x + highlight_perp_x, end_y + highlight_perp_y)
highlight_path.close()
# Create gradient for top highlight
gradient = skia.GradientShader.MakeLinear(
points=[(start_x + highlight_perp_x, start_y + highlight_perp_y),
(start_x - highlight_perp_x * 0.3, start_y - highlight_perp_y * 0.3)],
colors=[skia.Color(160, 160, 170, 200), skia.Color(100, 100, 110, 100)],
positions=[0.0, 1.0]
)
highlight_paint = skia.Paint(Shader=gradient, AntiAlias=True)
canvas.drawPath(highlight_path, highlight_paint)
# Handle end cap (3D sphere-like)
for cap_layer in range(3):
cap_radius = handle_width * (1.3 - cap_layer * 0.1)
cap_offset = cap_layer * 0.8
cap_alpha = 255 - cap_layer * 60
cap_paint = skia.Paint(
Color=skia.Color(60 + cap_layer * 30, 60 + cap_layer * 30, 70 + cap_layer * 30, cap_alpha),
AntiAlias=True
)
canvas.drawCircle(float(end_x + cap_offset), float(end_y + cap_offset), float(cap_radius), cap_paint)
# Final highlight on cap
cap_highlight = skia.Paint(
Color=skia.Color(180, 180, 190, 120),
AntiAlias=True
)
canvas.drawCircle(float(end_x - 2), float(end_y - 2), float(handle_width * 0.4), cap_highlight)
# --------------------------------------------------------------------------
# Base Component Class with State Management, Hierarchy, and Event Logic
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
# State Management Classes
# --------------------------------------------------------------------------
class State:
"""A simple, observable state-holding object."""
def __init__(self, initial_value):
"""Initializes the state with a starting value."""
self._value = initial_value
def get(self):
"""Returns the current raw value."""
return self._value
def set(self, new_value):
"""Updates the value."""
self._value = new_value
def __repr__(self):
"""Provides a clear, debug-friendly representation of the object."""
return f"State(value={self._value!r})"
class StateProxy:
"""
A proxy object that manages state declarations and binding access.
It acts as the controller and storage for all State objects
belonging to its parent component.
"""
def __setattr__(self, name: str, value):
"""
Creates and registers a NEW State object. This is triggered by
the initial declaration: `self.state.my_var = initial_value`.
"""
# Create the new State object.
new_state = State(value)
# Use object.__setattr__ to store this new State object as a direct
# attribute of this proxy instance, avoiding recursion.
object.__setattr__(self, name, new_state)
def __getattribute__(self, name: str):
"""
Gets the BINDING OBJECT (the State object itself) associated with 'name'.
This is triggered when binding: `Child(prop=self.state.my_var)`.
"""
# Use object.__getattribute__ to retrieve the attribute, avoiding recursion.
return object.__getattribute__(self, name)
class Component:
"""Base class for all UI components."""
def __init__(self, **kwargs):
# Use object.__setattr__ to avoid triggering our custom logic during setup.
object.__setattr__(self, 'parent', None)
object.__setattr__(self, 'children', [])
object.__setattr__(self, '_w', 0); object.__setattr__(self, '_h', 0)
object.__setattr__(self, '_x', 0); object.__setattr__(self, '_y', 0)
# Every component gets its own state controller.
object.__setattr__(self, 'state', StateProxy())
_component_registry[id(self)] = self
# Handle style defaults if they exist
if hasattr(self, 'Style'):
for key, value in self.Style.items():
setattr(self, key, value)
self.add_children(*kwargs.pop('children', []))
for key, value in kwargs.items():
setattr(self, key, value)
def __getattribute__(self, name):
"""Implements the 'default to value' behavior by delegating to the state proxy."""
# Allow normal access to internal attributes, the state proxy itself, special attributes, and methods.
special_attrs = ['children', 'parent']
if name.startswith('_') or name == 'state' or name in special_attrs or callable(getattr(type(self), name, None)):
return object.__getattribute__(self, name)
# Check if the state proxy is managing a state with this name.
if hasattr(self, 'state'):
state_proxy = object.__getattribute__(self, 'state')
if hasattr(state_proxy, name):
# If so, get the binding from the proxy and return its raw value.
return getattr(state_proxy, name).get()
# Otherwise, fall back to a normal attribute on this component.
return object.__getattribute__(self, name)
@cached_property
def parent_chain(self):
output = []
cursor = self
while cursor:
output+=[cursor]
cursor=cursor.parent
return output
@cached_property
def context(self):
return self.parent_chain[-1].context
def __setattr__(self, name, value):
"""Delegates state updates to the proxy, otherwise sets normal attributes."""
# Allow normal setting of internal attributes and special attributes.
special_attrs = ['children', 'parent', 'x', 'y', 'w', 'h']
if name.startswith('_') or name == 'state' or name in special_attrs:
object.__setattr__(self, name, value)
return
# This try/except is a safeguard for the very first moments of object creation.
state_proxy = object.__getattribute__(self, 'state') if hasattr(self, 'state') else None
# If we're being passed a State object directly (during binding),
# we need to create a binding in our state proxy
if isinstance(value, State):
if state_proxy:
# Store the binding in our state proxy
object.__setattr__(state_proxy, name, value)
return
# If the state proxy exists and is already managing this attribute, update it.
if state_proxy and hasattr(state_proxy, name):
# Get the binding from the proxy and set its inner value.
getattr(state_proxy, name).set(value)
return
# Handle Component parenting (this logic is preserved).
if isinstance(value, Component):
object.__setattr__(self, name, value)
self.add_child(value)
return
# Otherwise, it's just a regular attribute on this component.
object.__setattr__(self, name, value)
def get_w(self, w):
"""The actual width this component will be if we try to set self.w=w"""
if getattr(self, 'min_w', None) is not None: w = max(w, self.min_w)
if getattr(self, 'max_w', None) is not None: w = min(w, self.max_w)
return w
def get_h(self, h):
"""The actual height this component will be if we try to set self.h=h"""
if getattr(self, 'min_h', None) is not None: h = max(h, self.min_h)
if getattr(self, 'max_h', None) is not None: h = min(h, self.max_h)
return h
def get_x(self, x):
"""The actual x position this component will be if we try to set self.x=x"""
return x
def get_y(self, y):
"""The actual y position this component will be if we try to set self.y=y"""
return y
def set_x(self, x): self._x = x
def set_y(self, y): self._y = y
def set_h(self, h): self._h = h
def set_w(self, w): self._w = w
@property
def w(self):
return self.get_w(self._w)
@w.setter
def w(self, value):
self.set_w(value)
@property
def h(self):
return self.get_h(self._h)
@h.setter
def h(self, value):
self.set_h(value)
@property
def x(self):
return self.get_x(self._x)
@x.setter
def x(self, value):
self.set_x(value)
@property
def y(self):
return self.get_y(self._y)
@y.setter
def y(self, value):
self.set_y(value)
def add_child(self, child):
"""Adds a child component and sets its parent."""
if child not in self.children:
self.children.append(child)
assert not child.parent, f"{self}.add_child: Cant add child {child} because it already has parent {child.parent}"
child.parent = self
def add_children(self, *children):
"""Plural of add_child"""
for child in children:
self.add_child(child)
def is_hit(self, event) -> bool:
"""Checks if the given coordinates hit this component's mouse area"""
#Do not override this function
return self.context.get_hit_component(event) is self
def handle_event(self, event):
pass
def render_children(self):
"""Recursively renders all children."""
for child in self.children:
child.render()
def render_hitbox(self):
"""Draw component ID to the ID matrix for hit testing"""
draw_matrix_rect(
m=self.context.id_matrix,
i=id(self),
x=self.x,
y=self.y,
h=self.h,
w=self.w,
)
def render(self):
self.render_hitbox()
self.render_children()
# --------------------------------------------------------------------------
# Layout Component
# --------------------------------------------------------------------------
class FlexContainer(Component):
"""A component that lays out its children using a simplified flexbox model."""
direction = "column"
gap = 5
padding = 0
def __init__(self, children, **kwargs):
super().__init__(children=children, **kwargs)
def render(self):
"""Calculates layout and renders children."""
if not self.children:
return
is_column = self.direction == 'column'
main_size = self.h if is_column else self.w
cross_size = self.w if is_column else self.h
total_gap = self.gap * max(0, len(self.children) - 1)
available_main = main_size - 2 * self.padding - total_gap
# Simplified layout: Distribute available space equally among children.
# A more complete implementation would handle flex-grow, shrink, and basis.
child_main_size = available_main / len(self.children) if self.children else 0
current_pos = self.padding
for child in self.children:
child_cross_size = cross_size - 2 * self.padding
if is_column:
child.x, child.y = self.x + self.padding, self.y + current_pos
child.w, child.h = child_cross_size, child_main_size
else: # row
child.x, child.y = self.x + current_pos, self.y + self.padding
child.w, child.h = child_main_size, child_cross_size
current_pos += child_main_size + self.gap
self.render_children()
def get_h(self, h):
#TODO: We need a more sophisticated algorithm and structure, that lets children
#Request percentages of a height...
return sum(x.get_h(x.h) for x in self.children)
def set_h(self, h):
#TODO: We need a more sophisticated algorithm and structure, that lets children
#Request percentages of a height...
n=len(self.children)
for child in self.children:
child.h = h/n
# --------------------------------------------------------------------------
# Widget Components
# --------------------------------------------------------------------------
class Slider(Component):
"""A slider widget for controlling a float value."""
label = ""
value = 0.0
min_val = 0.0
max_val = 1.0
color = (60, 60, 60)
fill_color = (150, 150, 250)
hot_color = (180, 180, 255)
max_h = 50
min_h = 30
min_w = 50
text_space = 15
text_color = (220, 220, 220)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._is_dragging = False
def render(self):
context=self.context
# Display label with current value
current_val = self.min_val + (self.max_val - self.min_val) * self.value
label_with_value = f"{self.label}: {current_val:.2f}"
draw_text(context.image_buffer, label_with_value, self.x, self.y, self.text_color)
bar_y = self.y + self.text_space
bar_h = self.h - self.text_space
draw_rect(context.image_buffer, self.x, bar_y, self.w, bar_h, self.color)
fill_w = int(self.value * self.w)
draw_rect(context.image_buffer, self.x, bar_y, fill_w, bar_h, self.hot_color if self._is_dragging else self.fill_color)
draw_matrix_rect(
m=self.context.id_matrix,
i=id(self),
x=self.x,
y=bar_y,
h=bar_h,
w=self.w,
)
self.render_children()
def handle_event(self, event):
if event.type == 'mouse_up':
self._is_dragging = False
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_down' and is_hot:
self._is_dragging = True
if event.type in ['mouse_down', 'mouse_move'] and self._is_dragging:
self.value = np.clip((event.x - self.x) / self.w, 0.0, 1.0)
event.handled_by = self
class ResizeHandle(Component):
"""A handle to resize a window."""
size = 16
color = (90, 90, 110)
hot_color = (120, 120, 140)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._is_dragging = False
def render(self):
# Position ourselves at parent's bottom-right corner
if self.parent:
self.x = self.parent.x + self.parent.w - self.size
self.y = self.parent.y + self.parent.h - self.size
self.w = self.h = self.size
# Draw component ID to the ID matrix for hit testing
if hasattr(self.context, 'id_matrix') and self.w > 0 and self.h > 0:
h, w = self.context.id_matrix.shape
x1, y1 = max(0, int(self.x)), max(0, int(self.y))
x2, y2 = min(w, int(self.x + self.w)), min(h, int(self.y + self.h))
if x2 > x1 and y2 > y1:
self.context.id_matrix[y1:y2, x1:x2] = id(self)
color = self.hot_color if self._is_dragging else self.color
context = self.context
draw_rect(context.image_buffer, self.x, self.y, self.w, self.h, color)
self.render_hitbox()
self.render_children()
def handle_event(self, event):
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_up':
self._is_dragging = False
elif event.type == 'mouse_down' and is_hot:
self._is_dragging = True
event.handled_by = self
elif event.type in 'mouse_move' and self._is_dragging:
# Resize parent - let parent's w/h setters handle constraints
self.parent.w = event.x - self.parent.x
self.parent.h = event.y - self.parent.y
event.handled_by = self
class Scrollbar(Component):
"""A vertical scrollbar component."""
track_color = (60, 60, 60)
thumb_color = (120, 120, 120)
hot_thumb_color = (150, 150, 150)
scroll_position = 0.0 # 0.0 to 1.0
thumb_size = 0.2 # 0.0 to 1.0 (fraction of track)
min_thumb_height = 20
w = 16
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._is_dragging = False
self._drag_offset = 0
def render(self):
# Draw track
context = self.context
draw_rect(context.image_buffer, self.x, self.y, self.w, self.h, self.track_color)
# Calculate thumb position and size
thumb_height = max(20, int(self.thumb_size * self.h))
available_travel = self.h - thumb_height
thumb_y = self.y + int(self.scroll_position * available_travel)
# Draw thumb
thumb_color = self.hot_thumb_color if self._is_dragging else self.thumb_color
draw_rect(context.image_buffer, self.x, thumb_y, self.w, thumb_height, thumb_color)
self.render_hitbox()
self.render_children()
def handle_event(self, event):
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_up':
self._is_dragging = False
elif is_hot and event.type == 'mouse_down':
# Calculate thumb position
thumb_height = max(20, int(self.thumb_size * self.h))
available_travel = self.h - thumb_height
thumb_y = self.y + int(self.scroll_position * available_travel)
# Check if clicking on thumb
if thumb_y <= event.y <= thumb_y + thumb_height:
self._is_dragging = True
self._drag_offset = event.y - thumb_y
event.handled_by = self
else:
# Click on track - jump to position
new_position = (event.y - self.y - thumb_height/2) / available_travel
self.scroll_position = np.clip(new_position, 0.0, 1.0)
event.handled_by = self
elif self._is_dragging and event.type == 'mouse_move':
thumb_height = max(self.min_thumb_height, int(self.thumb_size * self.h))
available_travel = self.h - thumb_height
new_thumb_y = event.y - self._drag_offset
new_position = (new_thumb_y - self.y) / available_travel if available_travel else 0
self.scroll_position = np.clip(new_position, 0.0, 1.0)
event.handled_by = self
def handle_scrollwheel(self, event):
assert event.type=='scroll'
scroll_delta = getattr(event, 'scroll_y', 0) * 0.1 # Adjust sensitivity
new_position = self.scroll_position - scroll_delta
self.scroll_position = np.clip(new_position, 0.0, 1.0)
event.handled_by = self
class ScrollContainer(Component):
"""A container that can scroll its content with a scrollbar."""
def __init__(self, content, **kwargs):
super().__init__(**kwargs)
assert isinstance(content, Component)
self.content = content
self.scrollbar = Scrollbar()
def render(self):
with self.context.crop_changes(self.x, self.y, self.h, self.w):
content_width = self.w - self.scrollbar.w
# Position scrollbar on the right
self.scrollbar.x = self.x + content_width
self.scrollbar.y = self.y
self.scrollbar.h = self.h
#Set content height
self.content.x = self.x
self.content.w = content_width
self.content.h = self.h
#The requested content height might not match the actual one
#These are @properties and right now are not cached
content_h = self.content.h
h = self.h
# Calculate total content height needed
scroll_offset = 0
if content_h > h:
max_scroll = content_h - h
scroll_offset = self.scrollbar.scroll_position * max_scroll
self.content.y = self.y - scroll_offset
# Update scrollbar thumb size based on content ratio
if content_h > h:
self.scrollbar.thumb_size = h / content_h
else:
self.scrollbar.thumb_size = 1.0
self.scrollbar.scroll_position = 0.0
# Render content and scrollbar
self.render_hitbox()
if self.content:
self.content.render()
self.scrollbar.render()
def handle_event(self, event):
# Handle scrollbar events first
self.scrollbar.handle_event(event)
hit_component = self.context.get_hit_component(event)
if hit_component is not None:
chain = hit_component.parent_chain
if event.type == 'scroll' and self in chain:
# Forward scroll events to the scrollbar
self.scrollbar.handle_scrollwheel(event)
class Floating(Component):
def to_top(self):
"""Moves this component to the end of its parent's children list to ensure it's rendered last (on top)."""
if self.parent:
parent_children = self.parent.children
if self in parent_children:
parent_children.remove(self)
parent_children.append(self)
# Recursively bring the parent to the top of its context as well
if isinstance(self.parent, Floating):
self.parent.to_top()
class TitleBar(Component):
"""Draggable Title Bar for a Window."""
color = (70, 70, 80)
text_color = (240, 240, 240)
h = 25
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._is_dragging = False
self._drag_offset = (0, 0)
def render(self):
# Draw component ID to the ID matrix for hit testing
if hasattr(self.context, 'id_matrix') and self.w > 0 and self.h > 0:
h, w = self.context.id_matrix.shape
x1, y1 = max(0, int(self.x)), max(0, int(self.y))
x2, y2 = min(w, int(self.x + self.w)), min(h, int(self.y + self.h))
if x2 > x1 and y2 > y1:
self.context.id_matrix[y1:y2, x1:x2] = id(self)
draw_rect(self.context.image_buffer, self.x, self.y, self.w, self.h, self.color)
draw_text(self.context.image_buffer, self.title, self.x + 5, self.y + 5, self.text_color)
self.render_hitbox()
self.render_children()
def handle_event(self, event):
# Let children (like close buttons, if any) handle event first
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_up':
self._is_dragging = False
elif is_hot and event.type == 'mouse_down':
self._is_dragging = True
self._drag_offset = (event.x - self.parent.x, event.y - self.parent.y)
event.handled_by = self
elif self._is_dragging and event.type == 'mouse_move':
# Move the parent (the Window)
self.parent.x = event.x - self._drag_offset[0]
self.parent.y = event.y - self._drag_offset[1]
event.handled_by = self
class Window(Floating):
"""A container component with a title bar and resize handle."""
title = "Window"
color = (50, 50, 60)
min_h = 100
min_w = 100
def __init__(self, content:Component, **kwargs):
super().__init__(**kwargs)
# Add children
assert isinstance(content, Component)
self.content=content
self.title_bar=TitleBar(title=self.title)
self.resize_handle=ResizeHandle()
def render(self):
# Draw component ID to the ID matrix for hit testing
if hasattr(self.context, 'id_matrix') and self.w > 0 and self.h > 0:
h, w = self.context.id_matrix.shape
x1, y1 = max(0, int(self.x)), max(0, int(self.y))
x2, y2 = min(w, int(self.x + self.w)), min(h, int(self.y + self.h))
if x2 > x1 and y2 > y1:
self.context.id_matrix[y1:y2, x1:x2] = id(self)
# Draw soft drop shadow first
context = self.context
draw_shadow(context.image_buffer, self.x, self.y, self.w, self.h)
# Draw window background
draw_rect(context.image_buffer, self.x, self.y, self.w, self.h, self.color)
title_bar = self.title_bar
content = self.content
# Title bar spans full width at top
title_bar.x, title_bar.y = self.x, self.y
title_bar.w = self.w
# Content fills remaining space with padding
content.x, content.y = self.x, self.y + title_bar.h
content.w, content.h = self.w, self.h - title_bar.h
self.render_hitbox()
self.render_children()
def handle_event(self, event):
hit_component = self.context.get_hit_component(event)
if event.type=='mouse_down' and hit_component and self in hit_component.parent_chain:
self.to_top()
class Pad(Component):
pad_t = 5
pad_b = 5
pad_l = 5
pad_r = 5
def __init__(self, content, **kwargs):
super().__init__(**kwargs)
# Add children
assert isinstance(content, Component)
self.content = content
def render(self):
self.content.x = self.x + self.pad_l
self.content.y = self.y + self.pad_t
# self.render_hitbox() #Have emptyness...
self.render_children()
def get_h(self):
return self.content.h+self.pad_t+self.pad_b
def get_w(self):
return self.content.w+self.pad_l+self.pad_r
def set_w(self, w):
self.content.w = w - self.pad_l - self.pad_r
def set_h(self, h):
self.content.h = h - self.pad_t - self.pad_b
class BoxBorder(Component):
"""A component that draws a border around its content, similar to Pad but with visual border."""
border_width = 2
border_color = (255, 255, 255)
show_border = True
def __init__(self, content, **kwargs):
super().__init__(**kwargs)
# Add children
assert isinstance(content, Component)
self.content = content
def render(self):
# Always position content with border space reserved, regardless of border visibility
# This ensures consistent sizing
self.content.x = self.x + self.border_width
self.content.y = self.y + self.border_width
self.content.w = self.w - 2 * self.border_width
self.content.h = self.h - 2 * self.border_width
# Draw border if enabled (inside the allocated space)
if self.show_border:
# Draw 4 border rectangles around the content
# Top border
draw_rect(self.context.image_buffer, self.x, self.y,
self.w, self.border_width, self.border_color)
# Bottom border
draw_rect(self.context.image_buffer, self.x, self.y + self.h - self.border_width,
self.w, self.border_width, self.border_color)
# Left border
draw_rect(self.context.image_buffer, self.x, self.y,
self.border_width, self.h, self.border_color)
# Right border
draw_rect(self.context.image_buffer, self.x + self.w - self.border_width, self.y,
self.border_width, self.h, self.border_color)
self.render_hitbox()
self.render_children()
def get_h(self, h):
# Always include border space in size calculations for consistency
return self.content.get_h(self.content.h) + 2 * self.border_width
def get_w(self, w):
# Always include border space in size calculations for consistency
return self.content.get_w(self.content.w) + 2 * self.border_width
def set_w(self, w):
# Reserve space for border but don't change content size based on border visibility
self._w = w
def set_h(self, h):
# Reserve space for border but don't change content size based on border visibility
self._h = h
class Image(Component):
def __init__(self, image, **kwargs):
super().__init__(image=image, **kwargs)
def render(self):
h, w = self.h, self.w
x, y = self.x, self.y
image = self.image
if image is not None:
image = rp.cv_resize_image(image, (h,w))
image = rp.as_byte_image(image)
image = rp.as_rgba_image(image)
rp.stamp_tensor(
self.context.image_buffer,
image,
offset=[y, x],
mutate=True,
mode='replace',
)
self.render_hitbox()
self.render_children()
class TextEdit(Component):
"""A single-line text input component."""
text = "Hello, pimgui!"
bg_color = (30, 30, 40)
active_bg_color = (40, 40, 55)
text_color = (220, 220, 220)
cursor_color = (240, 240, 240)
selection_color = (70, 90, 120)
font_name = "Courier New" # Monospace font
font_size = 14
padding = 5
multiline = False
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.is_active = False
self.cursor_pos = 0
self.selection_start = 0
self._is_dragging = False
self._cursor_visible = True
self._last_blink_time = 0
self._blink_interval = 500 # milliseconds
self._shift_pressed = False
self._alt_pressed = False
@cached_property
def _char_width(self) -> float:
"""Measures and caches the width of a single character."""
return measure_text('W', self.font_name, self.font_size)
def _x_to_cursor_pos(self, x: int) -> int:
"""Converts an x-coordinate to a character position in the text (single-line only)."""
char_width = self._char_width
if char_width == 0: return 0
relative_x = x - (self.x + self.padding)
pos = int(round(relative_x / char_width))
return np.clip(pos, 0, len(self.text))
def _xy_to_cursor_pos(self, x: int, y: int) -> int:
"""Converts x,y coordinates to cursor position (works for both single and multiline)."""
if not self.multiline:
return self._x_to_cursor_pos(x)
char_width = self._char_width
line_height = self.font_size + 2
# Calculate which line was clicked
relative_y = y - (self.y + self.padding)
clicked_line = int(relative_y / line_height)
# Calculate column within that line
relative_x = x - (self.x + self.padding)
clicked_col = int(round(relative_x / char_width)) if char_width > 0 else 0
# Convert to cursor position
lines = self.text.split('\n')
clicked_line = np.clip(clicked_line, 0, len(lines) - 1)
clicked_col = np.clip(clicked_col, 0, len(lines[clicked_line]) if clicked_line < len(lines) else 0)
return self._line_col_to_pos(clicked_line, clicked_col)
def _find_word_start(self, pos: int) -> int:
"""Find the start of the word at the given position."""
if pos <= 0:
return 0
# If we're in whitespace, move back to the end of the previous word first
while pos > 0 and not self.text[pos - 1].isalnum():
pos -= 1
# Now move backwards until we find the start of the word
while pos > 0 and self.text[pos - 1].isalnum():
pos -= 1
return pos
def _find_word_end(self, pos: int) -> int:
"""Find the end of the word at the given position."""
if pos >= len(self.text):
return len(self.text)
# If we're in whitespace, move forward to the start of the next word first
while pos < len(self.text) and not self.text[pos].isalnum():
pos += 1
# Now move forwards until we find the end of the word
while pos < len(self.text) and self.text[pos].isalnum():
pos += 1
return pos
def _reset_cursor_blink(self):
"""Reset cursor to visible and update timing."""
self._cursor_visible = True
import time
self._last_blink_time = int(time.time() * 1000)
def _pos_to_line_col(self, pos: int) -> tuple:
"""Convert cursor position to (line, column)."""
if not self.multiline:
return (0, pos)
lines = self.text.split('\n')
char_count = 0
for line_num, line in enumerate(lines):
if char_count + len(line) >= pos:
return (line_num, pos - char_count)
char_count += len(line) + 1 # +1 for newline
return (len(lines) - 1, len(lines[-1]) if lines else 0)
def _line_col_to_pos(self, line: int, col: int) -> int:
"""Convert (line, column) to cursor position."""
if not self.multiline:
return col
lines = self.text.split('\n')
if line < 0:
return 0
if line >= len(lines):
return len(self.text)
pos = sum(len(lines[i]) + 1 for i in range(line)) # +1 for newlines
return min(pos + col, pos + len(lines[line]))
def render(self):
# Manage cursor blinking using system time
import time
current_time = int(time.time() * 1000) # Convert to milliseconds
if current_time - self._last_blink_time > self._blink_interval:
self._cursor_visible = not self._cursor_visible
self._last_blink_time = current_time
# Draw background
bg_color = self.active_bg_color if self.is_active else self.bg_color
draw_rect(self.context.image_buffer, self.x, self.y, self.w, self.h, bg_color)
char_width = self._char_width
text_x = self.x + self.padding
text_y = self.y + self.padding
if self.multiline:
# Multiline rendering
lines = self.text.split('\n')
line_height = self.font_size + 2
# Find cursor position
cursor_line, cursor_col = self._pos_to_line_col(self.cursor_pos)
# Draw selection highlighting
if self.selection_start != self.cursor_pos:
start_pos = min(self.selection_start, self.cursor_pos)
end_pos = max(self.selection_start, self.cursor_pos)
start_line, start_col = self._pos_to_line_col(start_pos)
end_line, end_col = self._pos_to_line_col(end_pos)
for line_num in range(start_line, end_line + 1):
line_y = text_y + line_num * line_height
if line_y < self.y + self.h: # Only draw visible lines
if line_num == start_line and line_num == end_line:
# Selection within single line
sel_x = text_x + start_col * char_width
sel_w = (end_col - start_col) * char_width
elif line_num == start_line:
# First line of multi-line selection
sel_x = text_x + start_col * char_width
sel_w = (len(lines[line_num]) - start_col) * char_width
elif line_num == end_line:
# Last line of multi-line selection
sel_x = text_x
sel_w = end_col * char_width
else:
# Middle lines of multi-line selection
sel_x = text_x
sel_w = len(lines[line_num]) * char_width
draw_rect(self.context.image_buffer, sel_x, line_y, sel_w, self.font_size + 2, self.selection_color)
# Draw each line
for i, line in enumerate(lines):
line_y = text_y + i * line_height
if line_y < self.y + self.h: # Only draw visible lines
draw_text(self.context.image_buffer, line, text_x, line_y, self.text_color, self.font_name, self.font_size)
# Draw blinking cursor
if self.is_active and self._cursor_visible:
cursor_x = text_x + cursor_col * char_width
cursor_y = text_y + cursor_line * line_height
draw_rect(self.context.image_buffer, cursor_x, cursor_y, 2, self.font_size + 2, self.cursor_color)
else:
# Single line rendering (original code)
# Draw selection rectangle
if self.selection_start != self.cursor_pos:
start = min(self.selection_start, self.cursor_pos)
end = max(self.selection_start, self.cursor_pos)
sel_x = text_x + start * char_width
sel_w = (end - start) * char_width
draw_rect(self.context.image_buffer, sel_x, text_y, sel_w, self.font_size + 2, self.selection_color)
# Draw text
draw_text(self.context.image_buffer, self.text, text_x, text_y, self.text_color, self.font_name, self.font_size)
# Draw blinking cursor
if self.is_active and self._cursor_visible:
cursor_x = text_x + self.cursor_pos * char_width
draw_rect(self.context.image_buffer, cursor_x, text_y, 2, self.font_size + 2, self.cursor_color)
self.render_hitbox()
def handle_event(self, event):
# Deactivation logic must run regardless of event.handled_by,
# so any click outside this component will de-focus it.
if event.type == 'mouse_down' and not self.is_hit(event):
self.is_active = False
if event.handled_by:
return
is_hot = self.is_hit(event)
if event.type == 'mouse_down' and is_hot:
self.is_active = True
self._is_dragging = True
event.handled_by = self
# Set cursor position and start selection
self.cursor_pos = self._xy_to_cursor_pos(event.x, event.y)
self.selection_start = self.cursor_pos
# Reset cursor blink to be visible on click
self._reset_cursor_blink()
elif event.type == 'mouse_move' and self._is_dragging:
# Update selection end (cursor position) while dragging
self.cursor_pos = self._xy_to_cursor_pos(event.x, event.y)
event.handled_by = self
elif event.type == 'mouse_up':
self._is_dragging = False
elif event.type == 'key_down' and self.is_active:
self.handle_key_press(event)
event.handled_by = self
elif event.type == 'key_up' and self.is_active:
self.handle_key_release(event)
event.handled_by = self
def handle_key_press(self, event):
"""Helper method to process key presses when the component is active."""
# Reset cursor blink on any key press
self._reset_cursor_blink()
key = event.key
print(f"Key pressed: {repr(key)}") # Debug
# Track modifier states
if key == Key.SHIFT:
self._shift_pressed = True
return
elif key == Key.ALT:
self._alt_pressed = True
print(f"Alt pressed, state: {self._alt_pressed}") # Debug
return
# Helper to delete current selection
def delete_selection():
if self.selection_start != self.cursor_pos:
start = min(self.selection_start, self.cursor_pos)
end = max(self.selection_start, self.cursor_pos)
self.text = self.text[:start] + self.text[end:]
self.cursor_pos = start
self.selection_start = start
return True
return False
# Handle special keys first
if key == Key.BACKSPACE:
if not delete_selection():
if self._alt_pressed and self.cursor_pos > 0:
# Delete whole word
word_start = self._find_word_start(self.cursor_pos)
print(f"Alt+Backspace: deleting from {word_start} to {self.cursor_pos}") # Debug
self.text = self.text[:word_start] + self.text[self.cursor_pos:]
self.cursor_pos = word_start
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif self.cursor_pos > 0:
# Delete single character
self.text = self.text[:self.cursor_pos - 1] + self.text[self.cursor_pos:]
self.cursor_pos -= 1
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif key == Key.DELETE:
if not delete_selection() and self.cursor_pos < len(self.text):
self.text = self.text[:self.cursor_pos] + self.text[self.cursor_pos + 1:]
self.selection_start = self.cursor_pos
elif key == Key.LEFT:
if self._alt_pressed:
# Move to start of current/previous word
self.cursor_pos = self._find_word_start(self.cursor_pos)
else:
# Move one character left
self.cursor_pos = max(0, self.cursor_pos - 1)
if not self._shift_pressed: # If shift is not held, clear selection
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif key == Key.RIGHT:
if self._alt_pressed:
# Move to end of current/next word
self.cursor_pos = self._find_word_end(self.cursor_pos)
else:
# Move one character right
self.cursor_pos = min(len(self.text), self.cursor_pos + 1)
if not self._shift_pressed:
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif key == Key.HOME:
self.cursor_pos = 0
if not self._shift_pressed:
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif key == Key.END:
self.cursor_pos = len(self.text)
if not self._shift_pressed:
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif key == Key.UP and self.multiline:
line, col = self._pos_to_line_col(self.cursor_pos)
if line > 0:
self.cursor_pos = self._line_col_to_pos(line - 1, col)
if not self._shift_pressed:
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif key == Key.DOWN and self.multiline:
line, col = self._pos_to_line_col(self.cursor_pos)
lines = self.text.split('\n')
if line < len(lines) - 1:
self.cursor_pos = self._line_col_to_pos(line + 1, col)
if not self._shift_pressed:
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
elif key == Key.ENTER and self.multiline:
print(f"Enter key pressed in multiline mode") # Debug
delete_selection()
self.text = self.text[:self.cursor_pos] + "\n" + self.text[self.cursor_pos:]
self.cursor_pos += 1
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
# Handle regular printable characters (when key is the character itself)
elif key and len(key) == 1 and key.isprintable():
# Special case: if we get \r or \n as a character, treat it as Enter in multiline mode
if key in ['\r', '\n'] and self.multiline:
print(f"Enter via character: {repr(key)}") # Debug
delete_selection()
self.text = self.text[:self.cursor_pos] + "\n" + self.text[self.cursor_pos:]
self.cursor_pos += 1
self.selection_start = self.cursor_pos
self._reset_cursor_blink()
else:
delete_selection()
self.text = self.text[:self.cursor_pos] + key + self.text[self.cursor_pos:]
self.cursor_pos += len(key)
self.selection_start = self.cursor_pos
def handle_key_release(self, event):
"""Helper method to process key releases when the component is active."""
key = event.key
# Track modifier states
if key == Key.SHIFT:
self._shift_pressed = False
elif key == Key.ALT:
self._alt_pressed = False
class KeyStateIndicator(Component):
"""A component that shows the state of modifier keys."""
key_name = "Key"
pressed_color = (0, 255, 0)
unpressed_color = (0, 0, 255)
font_size = 14
font_name = "Arial"
text_color = (255, 255, 255)
def __init__(self, key_name, **kwargs):
super().__init__(key_name=key_name, **kwargs)
self.key_pressed = False
def render(self):
# Choose color based on key state
color = self.pressed_color if self.key_pressed else self.unpressed_color
draw_rect(self.context.image_buffer, self.x, self.y, self.w, self.h, color)
# Draw key name
text = f"{self.key_name}: {'ON' if self.key_pressed else 'OFF'}"
draw_text(self.context.image_buffer, text, self.x + 5, self.y + 5,
self.text_color, self.font_name, self.font_size)
self.render_hitbox()
self.render_children()
def handle_event(self, event):
if event.type == 'key_down' and event.key == getattr(Key, self.key_name.upper()):
self.key_pressed = True
elif event.type == 'key_up' and event.key == getattr(Key, self.key_name.upper()):
self.key_pressed = False
# --------------------------------------------------------------------------
# Split Pane Components
# --------------------------------------------------------------------------
class Splitter(Component):
"""A draggable handle for resizing panes in a SplitPane container."""
color = (80, 80, 90)
hot_color = (120, 120, 140)
size = 8 # Thickness of the splitter bar
# MODIFIED: Accept an index to identify which splitter this is
def __init__(self, orientation='vertical', index=0, **kwargs):
super().__init__(**kwargs)
self.orientation = orientation
self.index = index # Store our index
self._is_dragging = False
def render(self):
color = self.hot_color if self._is_dragging else self.color
draw_rect(self.context.image_buffer, self.x, self.y, self.w, self.h, color)
self.render_hitbox()
def handle_event(self, event):
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_up':
self._is_dragging = False
elif event.type == 'mouse_down' and is_hot:
self._is_dragging = True
event.handled_by = self
elif event.type == 'mouse_move' and self._is_dragging:
# Tell the parent MultiSplitPane to update its split position
if self.parent and isinstance(self.parent, MultiSplitPane):
# MODIFIED: Pass our index to the parent
self.parent.update_split(self.index, event.x, event.y)
event.handled_by = self
class MultiSplitPane(Component):
"""
A container that holds N components, separated by draggable splitters,
allowing one pane to be resized by pushing/pulling adjacent ones.
"""
def __init__(self, panes, orientation='vertical', **kwargs):
super().__init__(**kwargs)
assert len(panes) > 1, "MultiSplitPane requires at least two panes."
for pane in panes:
assert isinstance(pane, Component)
self.panes = list(panes)
self.orientation = orientation
# Initialize ratios to be equal
num_panes = len(panes)
self.split_ratios = [1.0 / num_panes] * num_panes
# Create N-1 splitters
splitters = [
Splitter(orientation=self.orientation, index=i)
for i in range(num_panes - 1)
]
self.splitters=splitters
# Use add_children to establish parent-child relationships
self.add_children(*panes)
self.add_children(*splitters)
def update_split(self, splitter_index, mouse_x, mouse_y):
"""Calculates new split ratios based on a dragged splitter."""
i = splitter_index
# The drag only affects the panes at index i and i+1
pane1 = self.panes[i]
pane2 = self.panes[i+1]
# Define a minimum pane size as a fraction of the total space
min_ratio = 0
if self.orientation == 'vertical':
# Calculate the combined width and x-position of the two affected panes
total_w = pane1.w + pane2.w
start_x = pane1.x
# Calculate the new width of the first pane based on mouse position
new_pane1_w = mouse_x - start_x
# Convert back to a new combined ratio for these two panes
combined_ratio = self.split_ratios[i] + self.split_ratios[i+1]
if total_w > 0:
new_ratio1 = (new_pane1_w / total_w) * combined_ratio
else:
new_ratio1 = self.split_ratios[i]
else: # horizontal
# Calculate the combined height and y-position
total_h = pane1.h + pane2.h
start_y = pane1.y
new_pane1_h = mouse_y - start_y
combined_ratio = self.split_ratios[i] + self.split_ratios[i+1]
if total_h > 0:
new_ratio1 = (new_pane1_h / total_h) * combined_ratio
else:
new_ratio1 = self.split_ratios[i]
# Clamp to minimum size
new_ratio1 = max(min_ratio, new_ratio1)
# The second ratio is what's left of the combined ratio
new_ratio2 = combined_ratio - new_ratio1
# Ensure the second pane also respects the minimum size
if new_ratio2 < min_ratio:
new_ratio2 = min_ratio
new_ratio1 = combined_ratio - new_ratio2
# Update the main ratios list
self.split_ratios[i] = new_ratio1
self.split_ratios[i+1] = new_ratio2
def render(self):
"""Lays out the N panes and N-1 splitters based on the split ratios."""
splitter_size = self.splitters[0].size if self.splitters else 0
num_splitters = len(self.splitters)
if self.orientation == 'vertical':
available_w = self.w - (splitter_size * num_splitters)
current_x = self.x
for i, pane in enumerate(self.panes):
pane_w = available_w * self.split_ratios[i]
pane.x, pane.y = current_x, self.y
pane.w, pane.h = pane_w, self.h
current_x += pane_w
# If there's a splitter after this pane, position it
if i < num_splitters:
splitter = self.splitters[i]
splitter.x, splitter.y = current_x, self.y
splitter.w, splitter.h = splitter_size, self.h
current_x += splitter_size
else: # horizontal
available_h = self.h - (splitter_size * num_splitters)
current_y = self.y
for i, pane in enumerate(self.panes):
pane_h = available_h * self.split_ratios[i]
pane.x, pane.y = self.x, current_y
pane.w, pane.h = self.w, pane_h
current_y += pane_h
# Position the splitter
if i < num_splitters:
splitter = self.splitters[i]
splitter.x, splitter.y = self.x, current_y
splitter.w, splitter.h = self.w, splitter_size
current_y += splitter_size
self.render_hitbox()
self.render_children()
class VSplit(MultiSplitPane):
"""Convenience class for vertical split panes (side by side)."""
def __init__(self, left, right, **kwargs):
super().__init__([left, right], orientation='vertical', **kwargs)
@property
def left(self):
"""Get the left pane."""
return self.panes[0] if self.panes else None
@property
def right(self):
"""Get the right pane."""
return self.panes[1] if len(self.panes) > 1 else None
class HSplit(MultiSplitPane):
"""Convenience class for horizontal split panes (top and bottom)."""
def __init__(self, top, bottom, **kwargs):
super().__init__([top, bottom], orientation='horizontal', **kwargs)
@property
def top(self):
"""Get the top pane."""
return self.panes[0] if self.panes else None
@property
def bottom(self):
"""Get the bottom pane."""
return self.panes[1] if len(self.panes) > 1 else None
class Button(Component):
"""A simple clickable button component."""
label = "Button"
bg_color = (80, 80, 90)
hot_color = (100, 100, 110)
pressed_color = (120, 120, 140)
text_color = (220, 220, 220)
font_name = 'Arial'
font_size = 14
on_click = None
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._is_pressed = False
self._is_hot = False
def render(self):
color = self.bg_color
if self._is_pressed:
color = self.pressed_color
elif self._is_hot:
color = self.hot_color
draw_rect(self.context.image_buffer, self.x, self.y, self.w, self.h, color)
text_width = measure_text(self.label, self.font_name, self.font_size)
# Center the text inside the button
text_x = self.x + (self.w - text_width) / 2
text_y = self.y + (self.h - self.font_size) / 2 # Adjust y for vertical alignment
draw_text(self.context.image_buffer, self.label, text_x, text_y, self.text_color, self.font_name, self.font_size)
self.render_hitbox()
def handle_event(self, event):
# Already handled by a child or other component
if event.handled_by:
self._is_hot = False
return
is_currently_hit = self.is_hit(event)
self._is_hot = is_currently_hit
if event.type == 'mouse_down' and is_currently_hit:
self._is_pressed = True
event.handled_by = self
elif event.type == 'mouse_up':
# Check if the button was pressed and the mouse is released over it
if self._is_pressed and is_currently_hit:
if self.on_click and callable(self.on_click):
self.on_click()
self._is_pressed = False
class LensFlare(Floating):
"""A draggable lens flare effect that mimics Photoshop's lens flare behavior."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Start with default positions, will be updated in render()
self.flare_x = 500 # Main flare position
self.flare_y = 350
self.w = 100 # Drag handle size
self.h = 100
self._is_dragging = False
self._drag_offset = (0, 0)
# Sub-flare positions along the line (as fractions from center to main flare)
self.sub_flare_positions = [0.3, 0.6, 0.8, 1.2, 1.5, 1.8]
self.sub_flare_sizes = [15, 25, 10, 20, 12, 8]
self.sub_flare_colors = [
(255, 200, 100, 100),
(100, 255, 200, 80),
(255, 100, 200, 120),
(200, 200, 255, 90),
(255, 255, 100, 110),
(100, 200, 255, 70)
]
def calculate_sub_flare_positions(self):
"""Calculate positions of sub-flares along the line that goes through the entire screen."""
# Get screen dimensions and calculate dynamic center
screen_w = self.context.image_buffer.shape[1]
screen_h = self.context.image_buffer.shape[0]
center_x = screen_w // 2
center_y = screen_h // 2
# Vector from center to flare
dx = self.flare_x - center_x
dy = self.flare_y - center_y
# Avoid division by zero
if abs(dx) < 0.1 and abs(dy) < 0.1:
dx, dy = 1, 0
# Calculate line that goes through entire screen
# Find where the line intersects screen edges
positions = []
# Calculate sub-flares as fractions of the distance from center to flare
# These fractions determine where along the line each sub-flare appears
fractions = [-0.8, -0.5, -0.2, 0.3, 0.6, 0.9, 1.3] # Mix of negative and positive
for fraction in fractions:
# Calculate position as a fraction of the center-to-flare distance
sub_x = center_x + dx * fraction
sub_y = center_y + dy * fraction
# Only add if it's within screen bounds (with some margin)
if -50 <= sub_x <= screen_w + 50 and -50 <= sub_y <= screen_h + 50:
positions.append((sub_x, sub_y))
return positions
def render(self):
# Update position based on flare position (center drag handle on flare)
self.x = self.flare_x - self.w // 2
self.y = self.flare_y - self.h // 2
# Draw main lens flare
draw_lens_flare(self.context.image_buffer, self.flare_x, self.flare_y, intensity=1.0)
# Draw sub-flares along the line
sub_positions = self.calculate_sub_flare_positions()
for i, (sub_x, sub_y) in enumerate(sub_positions):
if 0 <= i < len(self.sub_flare_sizes) and 0 <= i < len(self.sub_flare_colors):
draw_sub_flare(self.context.image_buffer, sub_x, sub_y,
self.sub_flare_sizes[i], self.sub_flare_colors[i])
# Draw a small handle indicator (subtle circle to show it's draggable)
draw_circle(self.context.image_buffer, self.flare_x, self.flare_y, 5, (255, 255, 255, 50))
self.render_hitbox()
self.render_children()
def handle_event(self, event):
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_up':
self._is_dragging = False
elif event.type == 'mouse_down' and is_hot:
self._is_dragging = True
self._drag_offset = (event.x - self.flare_x, event.y - self.flare_y)
self.to_top() # Bring to front when clicked
event.handled_by = self
elif event.type == 'mouse_move' and self._is_dragging:
# Update flare position
self.flare_x = event.x - self._drag_offset[0]
self.flare_y = event.y - self._drag_offset[1]
event.handled_by = self
class MagnifyingGlass(Floating):
"""A realistic magnifying glass that magnifies whatever is underneath it."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.glass_x = 400 # Center of magnifying glass
self.glass_y = 300
self.radius = 60 # Glass radius
self.magnification = 2.5
self.distortion = 0.1 # Barrel distortion amount
self.chromatic_aberration = 1.0 # Chromatic aberration intensity
self.w = self.radius * 2 + 20 # Drag handle size
self.h = self.radius * 2 + 20
self._is_dragging = False
self._is_resizing = False
self._drag_offset = (0, 0)
self._background_buffer = None
self.handle_angle = 45 # Handle position in degrees
import math
# Calculate handle end position for resize detection
self.handle_end_x = 0
self.handle_end_y = 0
self._update_handle_position()
def _update_handle_position(self):
"""Update handle end position for resize detection."""
import math
angle_rad = math.radians(self.handle_angle)
handle_length = self.radius * 1.8
self.handle_end_x = self.glass_x + math.cos(angle_rad) * (self.radius + handle_length)
self.handle_end_y = self.glass_y + math.sin(angle_rad) * (self.radius + handle_length)
def capture_background(self):
"""Capture the current frame buffer as background for magnification."""
# Make a copy of the current buffer before we draw the magnifying glass
self._background_buffer = self.context.image_buffer.copy()
def render(self):
# Update component position and handle position
self.w = self.radius * 2 + 20
self.h = self.radius * 2 + 20
self.x = self.glass_x - self.w // 2
self.y = self.glass_y - self.h // 2
self._update_handle_position()
# Capture background before drawing the glass
self.capture_background()
# Draw the magnifying glass handle first
draw_magnifying_glass_handle(self.context.image_buffer, self.glass_x, self.glass_y, self.radius, self.handle_angle)
# Draw the magnifying glass rim
draw_glass_rim(self.context.image_buffer, self.glass_x, self.glass_y, self.radius)
# Draw the magnified view using the background buffer with current settings
if self._background_buffer is not None:
draw_magnified_region(
self.context.image_buffer,
self._background_buffer,
self.glass_x,
self.glass_y,
self.radius - 3, # Slightly smaller than rim
self.magnification,
self.distortion,
self.chromatic_aberration
)
# Draw glass reflections and highlights on top
draw_glass_reflection(self.context.image_buffer, self.glass_x, self.glass_y, self.radius)
# Draw subtle center indicator
draw_circle(self.context.image_buffer, self.glass_x, self.glass_y, 3, (255, 255, 255, 80))
self.render_hitbox() # Use default square hitbox method
self.render_children()
def handle_event(self, event):
if event.handled_by: return
# Only handle mouse events
if not hasattr(event, 'x') or not hasattr(event, 'y'):
return
is_hot = self.is_hit(event)
# Check if clicking near handle end (for resizing)
handle_distance = ((event.x - self.handle_end_x)**2 + (event.y - self.handle_end_y)**2)**0.5
is_near_handle = handle_distance < 15
if event.type == 'mouse_up':
self._is_dragging = False
self._is_resizing = False
elif event.type == 'mouse_down' and (is_hot or is_near_handle):
if is_near_handle:
self._is_resizing = True
else:
self._is_dragging = True
self._drag_offset = (event.x - self.glass_x, event.y - self.glass_y)
self.to_top() # Bring to front when clicked
event.handled_by = self
elif event.type == 'mouse_move':
if self._is_dragging:
# Update glass position
self.glass_x = event.x - self._drag_offset[0]
self.glass_y = event.y - self._drag_offset[1]
event.handled_by = self
elif self._is_resizing:
# Update radius based on distance from center
distance = ((event.x - self.glass_x)**2 + (event.y - self.glass_y)**2)**0.5
self.radius = max(30, min(120, int(distance * 0.6))) # Clamp between 30-120
event.handled_by = self
class CRTWindow(Window):
"""A resizable floating window that applies CRT monitor effects to whatever is behind it."""
def __init__(self, **kwargs):
# Provide empty content if not specified
if 'content' not in kwargs:
kwargs['content'] = Component()
super().__init__(**kwargs)
# Default CRT parameters (will be controlled by external sliders)
self.beam_width = 1.0
self.phosphor_glow = 0.8
self.curvature = 0.03
self.noise = 0.08
self._background_buffer = None
def capture_background(self):
"""Capture the current frame buffer as background for CRT filtering."""
self._background_buffer = self.context.image_buffer.copy()
def render(self):
# Capture background before applying CRT effect
self.capture_background()
# Draw window frame first (but not content)
super().render()
# Apply CRT effect to the content area (inside the window frame)
if self._background_buffer is not None:
content_x = self.x + 5 # Account for frame thickness
content_y = self.y + 25 # Account for title bar
content_w = self.w - 10 # Account for left/right frame
content_h = self.h - 30 # Account for title bar and bottom frame
if content_w > 0 and content_h > 0:
# First, copy the background to the content area
bg_region = self._background_buffer[
max(0, content_y):min(self._background_buffer.shape[0], content_y + content_h),
max(0, content_x):min(self._background_buffer.shape[1], content_x + content_w)
]
if bg_region.size > 0:
# Copy background to current buffer
dest_y1 = max(0, content_y)
dest_y2 = min(self.context.image_buffer.shape[0], content_y + content_h)
dest_x1 = max(0, content_x)
dest_x2 = min(self.context.image_buffer.shape[1], content_x + content_w)
if dest_y2 > dest_y1 and dest_x2 > dest_x1:
bg_h, bg_w = bg_region.shape[:2]
dest_h, dest_w = dest_y2 - dest_y1, dest_x2 - dest_x1
if bg_h == dest_h and bg_w == dest_w:
self.context.image_buffer[dest_y1:dest_y2, dest_x1:dest_x2] = bg_region
# Now apply CRT effect to the content area
draw_crt_effect(
self.context.image_buffer,
content_x, content_y, content_w, content_h,
self.beam_width, self.phosphor_glow, self.curvature, self.noise
)
# Render children on top of CRT effect
self.render_children()
class ColorSquare(Component):
"""A single color square that can be wrapped in a BoxBorder."""
def __init__(self, color, palette=None, **kwargs):
super().__init__(**kwargs)
self.color = color
# Use object.__setattr__ to avoid triggering the Component's __setattr__ logic
object.__setattr__(self, '_palette', palette)
def render(self):
# Draw the color square
draw_rect(self.context.image_buffer, self.x, self.y,
self.w, self.h, self.color)
self.render_hitbox()
self.render_children()
def handle_event(self, event):
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_down' and is_hot and self._palette:
self._palette.selected_color = self.color
event.handled_by = self
class ColorPalette(Component):
"""A color palette component for selecting paint colors."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.colors = [
(255, 0, 0), # Red
(0, 255, 0), # Green
(0, 0, 255), # Blue
(255, 255, 0), # Yellow
(255, 0, 255), # Magenta
(0, 255, 255), # Cyan
(255, 255, 255), # White
(0, 0, 0), # Black
]
self.selected_color = self.colors[0]
self.color_size = 25
self.padding = 5
# Create color squares with borders
self.color_squares = []
for color in self.colors:
# Create a border around each color square
border_color = (255, 255, 255) if sum(color) < 400 else (0, 0, 0)
color_square = ColorSquare(color=color, palette=self) # Pass palette reference
color_square.w = self.color_size
color_square.h = self.color_size
# Wrap in BoxBorder
bordered_square = BoxBorder(
content=color_square,
border_color=border_color,
show_border=False # Initially not selected
)
self.color_squares.append(bordered_square)
# Add all bordered squares as children
self.add_children(*self.color_squares)
def render(self):
cols = 4
rows = (len(self.colors) + cols - 1) // cols
for i, (color, bordered_square) in enumerate(zip(self.colors, self.color_squares)):
row = i // cols
col = i % cols
color_x = self.x + col * (self.color_size + self.padding)
color_y = self.y + row * (self.color_size + self.padding)
# Position the bordered square
bordered_square.x = color_x
bordered_square.y = color_y
bordered_square.w = self.color_size
bordered_square.h = self.color_size
# Update border visibility based on selection
bordered_square.show_border = (color == self.selected_color)
self.render_hitbox()
self.render_children()
def handle_event(self, event):
# ColorSquare components handle their own events now
pass
class PaintableImage(Component):
"""An image component that can be painted on."""
def __init__(self, image=None, **kwargs):
super().__init__(**kwargs)
self.original_image = image
self.painted_image = image.copy() if image is not None else None
self.is_painting = False
self.last_paint_pos = None
self.brush_color = (255, 0, 0)
self.brush_size = 5
def paint_at(self, x, y):
"""Paint a circle at the given coordinates."""
if self.painted_image is None:
return
# Convert screen coordinates to image coordinates
img_x = int((x - self.x) * self.painted_image.shape[1] / self.w)
img_y = int((y - self.y) * self.painted_image.shape[0] / self.h)
# Ensure coordinates are within image bounds
if 0 <= img_x < self.painted_image.shape[1] and 0 <= img_y < self.painted_image.shape[0]:
# Paint a circle
h, w = self.painted_image.shape[:2]
for dy in range(-self.brush_size, self.brush_size + 1):
for dx in range(-self.brush_size, self.brush_size + 1):
if dx*dx + dy*dy <= self.brush_size*self.brush_size:
paint_x = img_x + dx
paint_y = img_y + dy
if 0 <= paint_x < w and 0 <= paint_y < h:
self.painted_image[paint_y, paint_x, :3] = self.brush_color
def paint_line(self, x1, y1, x2, y2):
"""Paint a line between two points."""
steps = max(abs(x2 - x1), abs(y2 - y1))
if steps == 0:
self.paint_at(x1, y1)
return
for i in range(steps + 1):
t = i / steps
x = int(x1 + t * (x2 - x1))
y = int(y1 + t * (y2 - y1))
self.paint_at(x, y)
def render(self):
if self.painted_image is not None:
# Resize and display the painted image
image = rp.cv_resize_image(self.painted_image, (self.h, self.w))
image = rp.as_byte_image(image)
image = rp.as_rgba_image(image)
rp.stamp_tensor(
self.context.image_buffer,
image,
offset=[self.y, self.x],
mutate=True,
mode='replace',
)
self.render_hitbox()
self.render_children()
def handle_event(self, event):
if event.handled_by: return
is_hot = self.is_hit(event)
if event.type == 'mouse_down' and is_hot:
self.is_painting = True
self.last_paint_pos = (event.x, event.y)
self.paint_at(event.x, event.y)
event.handled_by = self
elif event.type == 'mouse_move' and self.is_painting:
if self.last_paint_pos:
self.paint_line(self.last_paint_pos[0], self.last_paint_pos[1],
event.x, event.y)
else:
self.paint_at(event.x, event.y)
self.last_paint_pos = (event.x, event.y)
event.handled_by = self
elif event.type == 'mouse_up':
self.is_painting = False
self.last_paint_pos = None
def set_image(self, image):
"""Set a new image to paint on."""
self.original_image = image
self.painted_image = image.copy() if image is not None else None
def reset_paint(self):
"""Reset painting to original image."""
if self.original_image is not None:
self.painted_image = self.original_image.copy()
class VideoPlayer(Component):
"""
A widget to play video files with a scrubber and play/pause controls.
"""
fps = 60 # Default playback speed
def __init__(self, video_path, **kwargs):
super().__init__(**kwargs)
video_frames_rgb = rp.load_video(video_path)
frames = [rp.as_rgba_image(frame) for frame in video_frames_rgb]
# --- Load Video ---
# This can take time for large videos. For a real app,
# this should be done asynchronously.
# rp.load_video returns a THWC byte RGB array
# Convert all frames to RGBA for our renderer
total_frames = len(frames)
print(f"Video '{video_path}' loaded successfully ({total_frames} frames).")
initial_image = frames[0] if frames else None
# --- Child Components ---
video_image = PaintableImage(image=initial_image) # Use PaintableImage instead of Image
play_pause_button = Button(label="Pause", on_click=self.toggle_play_pause)
scrubber = Slider(value=0.0, label="Frame") # Using the label for frame number
brush_size_slider = Slider(value=0.1, label="Brush Size")
color_palette = ColorPalette()
reset_button = Button(label="Reset", on_click=self.reset_paint)
# Add children directly to establish parent-child relationship
self.add_children(video_image, play_pause_button, scrubber,
brush_size_slider, color_palette, reset_button)
# --- Playback State ---
self.is_playing = True
self.frame_number = 0
self.last_frame_time = 0
self.frames = frames
self.total_frames = total_frames
self.video_image = video_image
self.play_pause_button=play_pause_button
self.scrubber=scrubber
self.brush_size_slider=brush_size_slider
self.color_palette=color_palette
self.reset_button=reset_button
def toggle_play_pause(self):
"""Callback for the play/pause button."""
if self.total_frames <= 1: return # Don't play if there's no video
self.is_playing = not self.is_playing
self.play_pause_button.label = "Pause" if self.is_playing else "Play"
# Reset time to prevent a large frame jump after resuming
if self.is_playing:
self.last_frame_time = time.time()
def reset_paint(self):
"""Callback for the reset button."""
self.video_image.reset_paint()
def render(self):
# --- Logic & State Update ---
current_time = time.time()
# 1. Update frame number from timed playback
if self.is_playing and self.total_frames > 1:
time_per_frame = 1.0 / self.fps
if current_time - self.last_frame_time >= time_per_frame:
self.frame_number = (self.frame_number + 1) % self.total_frames
self.last_frame_time = current_time
# 2. Handle scrubber interaction (bi-directional binding)
if self.total_frames > 1:
# If the user is dragging the slider, it controls the frame number
if self.scrubber._is_dragging:
if self.is_playing:
self.is_playing = False # Pause playback while scrubbing
self.play_pause_button.label = "Play"
self.frame_number = int(self.scrubber.value * (self.total_frames - 1))
else:
# Otherwise, the current frame number controls the slider's position
self.scrubber.value = self.frame_number / (self.total_frames - 1)
# 3. Update the image displayed
# Ensure frame_number is within bounds before indexing
safe_frame_num = np.clip(self.frame_number, 0, self.total_frames - 1)
current_frame = self.frames[safe_frame_num]
# Update the paintable image with the current frame if it has changed
if self.video_image.original_image is not current_frame:
self.video_image.set_image(current_frame)
# 4. Update scrubber label to show frame number
self.scrubber.label = f"Frame: {safe_frame_num + 1} / {self.total_frames}"
# 5. Update paint settings from controls
# Update brush size (convert from 0-1 to 1-20 pixels)
self.video_image.brush_size = int(self.brush_size_slider.value * 19) + 1
self.brush_size_slider.label = f"Brush: {self.video_image.brush_size}px"
# Update brush color from palette
self.video_image.brush_color = self.color_palette.selected_color
# --- Layout ---
# Define the layout of the child components within the VideoPlayer's bounds
controls_height = 120 # Increased height for paint controls
button_width = 80
padding = 5
# Video image takes up the top part
self.video_image.x = self.x
self.video_image.y = self.y
self.video_image.w = self.w
self.video_image.h = max(0, self.h - controls_height)
# Controls container takes up the bottom part
controls_y = self.y + self.h - controls_height
# First row: Play/Pause button and scrubber
self.play_pause_button.x = self.x + padding
self.play_pause_button.y = controls_y + padding
self.play_pause_button.w = button_width
self.play_pause_button.h = 30
self.scrubber.x = self.x + button_width + padding * 2
self.scrubber.y = controls_y + padding
self.scrubber.w = max(0, self.w - button_width - padding * 3)
self.scrubber.h = 30
# Second row: Paint controls
paint_controls_y = controls_y + 40
# Color palette on the left
self.color_palette.x = self.x + padding
self.color_palette.y = paint_controls_y
self.color_palette.w = 120 # 4 colors * (25 + 5) padding
self.color_palette.h = 60 # 2 rows * (25 + 5) padding
# Brush size slider in the middle
brush_slider_x = self.x + 140
self.brush_size_slider.x = brush_slider_x
self.brush_size_slider.y = paint_controls_y
self.brush_size_slider.w = max(0, self.w - 300) # Leave space for reset button
self.brush_size_slider.h = 30
# Reset button on the right
self.reset_button.x = self.x + self.w - button_width - padding
self.reset_button.y = paint_controls_y
self.reset_button.w = button_width
self.reset_button.h = 30
# --- Render ---
self.render_hitbox()
self.render_children() # Recursively render children (Image, Button, Slider)
# --------------------------------------------------------------------------
# UI Context & Pygame Runner
# --------------------------------------------------------------------------
class UIContext:
"""Manages the main image buffer and event dispatch."""
def __init__(self, w, h, root_comp):
self.resize(w, h)
self.root_comp=root_comp
self.root_comp.context=self
def resize(self, w, h):
self.image_buffer = np.zeros((h, w, 4), np.uint8)
self.id_matrix = np.zeros((h, w), dtype=np.int64)
def get_hit_component(self, event):
if 'mouse' in event.type:
x,y=event.x,event.y
h,w=self.id_matrix.shape
if 0<=x<w and 0<=y<h:
component_id=self.id_matrix[y,x]
return _component_registry.get(component_id, None)
else:
return None
def begin_frame(self):
# This will fill the buffer with (40, 40, 40, 40).
# The alpha channel will be opaque in the final blit.
self.image_buffer.fill(40)
# Clear the ID matrix
self.id_matrix.fill(0)
def render(self):
"""Renders a full frame."""
self.begin_frame()
h, w, _ = self.image_buffer.shape
# Set root component bounds to full screen
self.root_comp.x, self.root_comp.y = 0, 0
self.root_comp.w, self.root_comp.h = w, h
# Start the recursive render and event handling pass
self.root_comp.render()
return self.image_buffer
@contextmanager
def crop_changes(self, x, y, h, w):
old_id_matrix = self.id_matrix + 0
old_image_buffer = self.image_buffer + 0
yield
new_id_matrix = self.id_matrix
new_image_buffer = self.image_buffer
y0, y1 = sorted([int(y), int(y + h)])
x0, x1 = sorted([int(x), int(x + w)])
y0 = max(y0, 0)
x0 = max(x0, 0)
if x1>0 and y1>0:
old_id_matrix [y0:y1, x0:x1] = new_id_matrix [y0:y1, x0:x1]
old_image_buffer[y0:y1, x0:x1] = new_image_buffer[y0:y1, x0:x1]
self.id_matrix = old_id_matrix
self.image_buffer = old_image_buffer
def handle_event(self, event, root_comp):
"""Dispatches an event to the entire component tree."""
#The ordering here is important!
#Topmost components should receive events first
def helper(component):
for child in component.children[::-1]:
helper(child)
component.handle_event(event)
helper(root_comp)
def run_pygame(app):
"""Initializes Pygame and runs the main application loop."""
import pygame
pygame.init()
screen_size = (800, 600)
screen = pygame.display.set_mode(screen_size, pygame.RESIZABLE)
pygame.display.set_caption("pimgui Refactored Demo")
context = UIContext(*screen_size,app)
running = True
clock = pygame.time.Clock()
while running:
# Handle Pygame events
for pg_event in pygame.event.get():
if pg_event.type == pygame.QUIT:
running = False
elif pg_event.type == pygame.VIDEORESIZE:
screen = pygame.display.set_mode((pg_event.w, pg_event.h), pygame.RESIZABLE)
context.resize(pg_event.w, pg_event.h)
elif pg_event.type in [pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION]:
# Skip scroll wheel button events (buttons 4 and 5) to avoid conflicts with MOUSEWHEEL
if pg_event.type in [pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP] and pg_event.button in [4, 5]:
continue
event_type_map = {
pygame.MOUSEBUTTONDOWN: 'mouse_down',
pygame.MOUSEBUTTONUP: 'mouse_up',
pygame.MOUSEMOTION: 'mouse_move'
}
# Create our custom event and dispatch it
evt = Event(event_type_map[pg_event.type], x=pg_event.pos[0], y=pg_event.pos[1])
context.handle_event(evt, app)
elif pg_event.type == pygame.MOUSEWHEEL:
# Handle scroll wheel events
mouse_pos = pygame.mouse.get_pos()
evt = Event('scroll', x=mouse_pos[0], y=mouse_pos[1], scroll_y=pg_event.y)
context.handle_event(evt, app)
elif pg_event.type == pygame.KEYDOWN:
# Convert pygame keys to backend-agnostic format
key = convert_pygame_key(pg_event.key, pg_event.unicode)
if key: # Only handle keys we recognize
evt = Event('key_down', key=key, timestamp=pygame.time.get_ticks())
context.handle_event(evt, app)
elif pg_event.type == pygame.KEYUP:
# Convert pygame keys to backend-agnostic format
key = convert_pygame_key(pg_event.key, "") # No unicode for key up
if key: # Only handle keys we recognize
evt = Event('key_up', key=key, timestamp=pygame.time.get_ticks())
context.handle_event(evt, app)
# Render the UI
frame = context.render()
# Display the rendered frame
# Pygame's blit_array handles the RGBA to screen conversion.
# We only need to remove the alpha channel for the view.
pygame.surfarray.blit_array(screen, np.swapaxes(frame[:,:,:3], 0, 1))
pygame.display.flip()
clock.tick(120)
pygame.quit()
# --------------------------------------------------------------------------
# Application (Root Component)
# --------------------------------------------------------------------------
class DemoApp(Component):
"""The main application class, which is also the root component."""
def __init__(self):
super().__init__()
# Application state
self.state.red_value = 0.8
self.state.green_value = 0.4
self.state.blue_value = 0.2
self.state.image = None # This will be updated by the webcam thread
self.state.editable_text = "HELO WORLD"
self.state.preview_color = (int(self.red_value * 255), int(self.green_value * 255), int(self.blue_value * 255))
# Effects state for lens flare and magnifying glass
self.state.lens_flare_intensity = 0.8
self.state.lens_flare_size = 0.6
self.state.magnification = 0.3 # Will map to dramatic range
self.state.distortion = 0.2 # Will map to dramatic range
self.state.chromatic_aberration = 0.3 # Will map to dramatic range
# CRT effects state
self.state.crt_beam_width = 0.5
self.state.crt_phosphor_glow = 0.8
self.state.crt_curvature = 0.03
self.state.crt_noise = 0.08
# Create effect components
self.lens_flare = LensFlare()
self.magnifying_glass = MagnifyingGlass()
self.crt_window = CRTWindow(x=600, y=100, w=300, h=200, title="📺 CRT Monitor Filter")
# Create sliders with references for dramatic ranges (use object.__setattr__ to avoid auto-parenting)
object.__setattr__(self, 'intensity_slider', Slider(label="🌟 Lens Flare Intensity", value=self.state.lens_flare_intensity, min_val=0.0, max_val=5.0))
object.__setattr__(self, 'size_slider', Slider(label="📏 Lens Flare Size", value=self.state.lens_flare_size, min_val=0.1, max_val=3.0))
object.__setattr__(self, 'magnification_slider', Slider(label="🔍 Magnification Power", value=self.state.magnification, min_val=1.0, max_val=15.0))
object.__setattr__(self, 'distortion_slider', Slider(label="🌊 Lens Barrel Distortion", value=self.state.distortion, min_val=0.0, max_val=2.0))
object.__setattr__(self, 'chromatic_slider', Slider(label="🌈 Chromatic Aberration", value=self.state.chromatic_aberration, min_val=0.0, max_val=10.0))
# CRT sliders
object.__setattr__(self, 'crt_beam_slider', Slider(label="📺 CRT Beam Width", value=self.state.crt_beam_width, min_val=0.1, max_val=3.0))
object.__setattr__(self, 'crt_glow_slider', Slider(label="✨ CRT Phosphor Glow", value=self.state.crt_phosphor_glow, min_val=0.1, max_val=2.0))
object.__setattr__(self, 'crt_curve_slider', Slider(label="🌊 CRT Screen Curve", value=self.state.crt_curvature, min_val=0.0, max_val=0.2))
object.__setattr__(self, 'crt_noise_slider', Slider(label="📡 CRT Analog Noise", value=self.state.crt_noise, min_val=0.0, max_val=0.3))
# Create components
self.add_children(
Window(
content=Pad(
ScrollContainer(
FlexContainer(
[
Slider(label="Red", value=self.state.red_value),
Slider(label="Green", value=self.state.green_value),
Slider(label="Blue", value=self.state.blue_value),
self.intensity_slider,
self.size_slider,
self.magnification_slider,
self.distortion_slider,
self.chromatic_slider,
self.crt_beam_slider,
self.crt_glow_slider,
self.crt_curve_slider,
self.crt_noise_slider,
]
)
)
),
x=50, y=50, w=250, h=350, title="Effects Controls",
),
Window(
content=Pad(
# NEW: SplitPane demonstrating the draggable panes
MultiSplitPane(
panes=[
Image(image=self.state.image),
Image(image=self.state.image),],
orientation='vertical',
split_position=0.5
)
),
x=350, y=50, w=600, h=400, title="Squishy Squishy Webcam Panes",
),
Window(
content=Component(), # FIX: Provide a default empty component
color=self.state.preview_color,
x=350, y=500, w=200, h=200, title="Color Preview"
),
Window(
content=Pad(
TextEdit(text=self.state.editable_text) # Bind to app state
),
x=350, y=500, w=450, h=70, title="Single-line Text Editor"
),
Window(
content=Pad(
TextEdit(text="Try typing here!\nUse Alt+arrows for word nav\nAlt+backspace for word delete", multiline=True)
),
x=350, y=600, w=450, h=150, title="Multi-line Text Editor"
),
Window(
content=Pad(
# NEW: Use MultiSplitPane with THREE panes
MultiSplitPane(
panes=[
# Image(image=self.image), # Pane 1
# Image(image=self.image), # Pane 2
FlexContainer( # Pane 3
[
Slider(label="Effect 1"),
Slider(label="Effect 2"),
],
padding=4
),
FlexContainer( # Pane 3
[
Slider(label="Effect 1"),
Slider(label="Effect 2"),
],
padding=4
),],
orientation='vertical'
)
),
x=350, y=50, w=600, h=400, title="Multi-pane Webcam View",
),
Window(
content=Pad(
FlexContainer([
KeyStateIndicator("Shift"),
KeyStateIndicator("Ctrl"),
KeyStateIndicator("Alt"),
KeyStateIndicator("Command"),
])
),
x=600, y=500, w=200, h=200, title="Modifier Keys Demo"
),
)
# Create components
self.add_children(
Window(
content=Pad(
# NEW: Add the VideoPlayer widget
VideoPlayer(video_path="/Users/burgert/Downloads/Borgi.mp4")
),
x=50, y=50, w=480, h=400, title="Video Player Demo",
),
# Add the effect components
self.lens_flare,
self.magnifying_glass,
self.crt_window,
# You can add other windows from the original demo here as well
# For this example, we'll just show the video player.
)
def render(self):
# Update effect parameters from sliders
# Magnifying glass effects (map slider 0-1 values to dramatic ranges)
self.magnifying_glass.magnification = 1.0 + self.magnification_slider.value * 14.0 # 1.0 to 15.0
self.magnifying_glass.distortion = self.distortion_slider.value * 2.0 # 0.0 to 2.0
self.magnifying_glass.chromatic_aberration = self.chromatic_slider.value * 10.0 # 0.0 to 10.0
# CRT effects (map slider 0-1 values to ranges)
self.crt_window.beam_width = 0.1 + self.crt_beam_slider.value * 2.9 # 0.1 to 3.0
self.crt_window.phosphor_glow = 0.1 + self.crt_glow_slider.value * 1.9 # 0.1 to 2.0
self.crt_window.curvature = self.crt_curve_slider.value * 0.2 # 0.0 to 0.2
self.crt_window.noise = self.crt_noise_slider.value * 0.3 # 0.0 to 0.3
# Store old lens flare render method
original_lens_flare_render = self.lens_flare.render
# Override lens flare render to use app state
def custom_lens_flare_render():
# Update position based on flare position (center drag handle on flare)
self.lens_flare.x = self.lens_flare.flare_x - self.lens_flare.w // 2
self.lens_flare.y = self.lens_flare.flare_y - self.lens_flare.h // 2
# Draw main lens flare with dramatic slider values
intensity = self.intensity_slider.value * 5.0 # 0.0 to 5.0
size_mult = 0.1 + self.size_slider.value * 2.9 # 0.1 to 3.0
draw_lens_flare(self.lens_flare.context.image_buffer,
self.lens_flare.flare_x, self.lens_flare.flare_y,
intensity=intensity,
size_multiplier=size_mult)
# Draw sub-flares along the line with dramatic scaling
sub_positions = self.lens_flare.calculate_sub_flare_positions()
for i, (sub_x, sub_y) in enumerate(sub_positions):
if 0 <= i < len(self.lens_flare.sub_flare_sizes) and 0 <= i < len(self.lens_flare.sub_flare_colors):
draw_sub_flare(self.lens_flare.context.image_buffer, sub_x, sub_y,
self.lens_flare.sub_flare_sizes[i] * size_mult,
self.lens_flare.sub_flare_colors[i])
# Draw a small handle indicator
draw_circle(self.lens_flare.context.image_buffer,
self.lens_flare.flare_x, self.lens_flare.flare_y, 5, (255, 255, 255, 50))
self.lens_flare.render_hitbox()
self.lens_flare.render_children()
# Temporarily override the render method
self.lens_flare.render = custom_lens_flare_render
# The root component's render simply tells its children to render.
# The UIContext handles the initial setup.
self.render_children()
# Restore original render method
self.lens_flare.render = original_lens_flare_render
if __name__ == "__main__":
app = DemoApp()
# For fast performance, read from the webcam on a separate thread
@rp.run_as_new_thread
def webcam_image_updater():
while True:
app.image = rp.load_image_from_webcam(height=360,width=480)
run_pygame(app)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment