Created
July 1, 2025 22:51
-
-
Save SqrtRyan/a875e320d720d9afec231718057d2b95 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# ========================================================================== | |
# 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