diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aa72662 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.analysis.exclude": [ + "**/*.pyx", + "**/*.pxd" + ], + "python.analysis.stubPath": "." +} \ No newline at end of file diff --git a/README.md b/README.md index 7289141..f5e78c4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Key features: * **Declarative Markup**: Build UIs with HTML-like `.aui` files and CSS-like `.acss` stylesheets. * **Theme Support**: CSS variables with light/dark theme variants. * **Drag & Drop**: Built-in drag and drop system. -* **Animations**: Tweening system with easing functions. +* **Animations**: Sequenced animator and timers with easing functions. * **High Performance**: Cython-accelerated parsing and optimized rendering. ## Installation @@ -49,7 +49,9 @@ Key features: pip install arepy-ui ``` -With markup system acceleration: +The compiled acceleration modules are included in the default installation when a wheel is available or when building from source. + +For a local source workflow where you want the build dependencies available explicitly: ```bash pip install arepy-ui[markup] @@ -60,17 +62,16 @@ pip install arepy-ui[markup] ### Python API ```python -from arepy import ArepyEngine, SystemPipeline +from arepy import ArepyEngine from arepy_ui import UIManager, Node, Text, Button, Style, Color, Unit, JustifyContent, AlignItems -ui_manager: UIManager = None +if __name__ == "__main__": + game = ArepyEngine(title="My Game", width=800, height=600) + world = game.create_world("main") -def setup(game: ArepyEngine): - global ui_manager - ui_manager = UIManager.from_engine(game) - - ui_manager.set_root( - Node( + UIManager.install( + world, + root=Node( style=Style( width=Unit.percent(100), height=Unit.percent(100), @@ -79,24 +80,12 @@ def setup(game: ArepyEngine): gap=20, ), children=[ - Text("Hello, arepy-ui!", font_size=32, color=Color(255, 255, 255)), + Text("Hello, arepy-ui!", size=32, color=Color(255, 255, 255)), Button("Click me", on_click=lambda: print("Clicked!")), ], - ) + ), ) -def update(game: ArepyEngine): - ui_manager.update(game.get_delta_time()) - -def render(game: ArepyEngine): - ui_manager.render() - -if __name__ == "__main__": - game = ArepyEngine(title="My Game", width=800, height=600) - world = game.create_world("main") - world.add_startup_system(setup) - world.add_system(SystemPipeline.UPDATE, update) - world.add_system(SystemPipeline.RENDER, render) game.set_current_world("main") game.run() ``` @@ -147,7 +136,12 @@ from arepy_ui.markup import load_aui handlers = {"greet": lambda: print("Hello!")} result = load_aui("ui.aui", handlers=handlers) -ui_manager.set_root(result.root) + +if result.success and result.root is not None: + ui_manager.set_root(result.root) +else: + for error in result.errors: + print(error) ``` ## Components @@ -287,18 +281,19 @@ uv run examples/markup_demo/main.py ## Performance -The markup parser is **Cython-accelerated** for maximum performance: +arepy-ui ships with compiled acceleration for the markup parsers, layout code, and selected hot paths. + +You do not need a separate runtime step to enable it after installation. When installing from source, setuptools builds the extensions as part of the package build. + +For local development environments that rebuild extensions from source, ensure the markup build dependency is installed: ```bash pip install arepy-ui[markup] -python -m arepy_ui.markup.build_ext ``` -Falls back to pure Python automatically if Cython isn't available. - ## Requirements -* Python 3.10+ +* Python 3.11+ * [Arepy](https://github.com/Scr44gr/arepy) game engine * numpy (for ColorPicker gradients) diff --git a/arepy_ui/__init__.py b/arepy_ui/__init__.py index 4eca717..b00f750 100644 --- a/arepy_ui/__init__.py +++ b/arepy_ui/__init__.py @@ -15,17 +15,22 @@ from .core.animation import Animation, Animator, Easing from .core.fonts import ( FontManager, + FontLoadRequest, TextMetrics, draw_text, draw_text_centered, get_font, get_font_manager, load_font, + load_fonts, measure_text, measure_text_ex, + unload_font, + unload_fonts, ) from .core.node import Node from .core.style import Spacing, Style +from .core.timers import Timer, Timers from .core.transitions import ( CircleReveal, FadeTransition, @@ -74,6 +79,8 @@ "Animation", "Easing", "Animator", + "Timer", + "Timers", # Transitions & Motion Graphics "Timeline", "KeyFrame", @@ -84,9 +91,13 @@ "TransitionState", # Fonts "FontManager", + "FontLoadRequest", "TextMetrics", "get_font_manager", "load_font", + "load_fonts", + "unload_font", + "unload_fonts", "get_font", "draw_text", "draw_text_centered", diff --git a/arepy_ui/components/button.py b/arepy_ui/components/button.py index c3efb21..74921ce 100644 --- a/arepy_ui/components/button.py +++ b/arepy_ui/components/button.py @@ -4,12 +4,15 @@ from arepy.math import check_collision_point_rec from ..core.node import Node -from ..core.style import Spacing, Style +from ..core.style import Spacing, Style, merge_style_fields from ..core.types import AlignItems, Color, CursorType, JustifyContent, Unit from ..runtime import MOUSE_BUTTON_LEFT, get_runtime from .text import Text +_BUTTON_MERGE_FIELDS = ("margin", "position", "top", "left") + + class Button(Node): def __init__( self, @@ -25,6 +28,7 @@ def __init__( pressed_color: Optional[Color] = None, pressed_scale: float = 0.98, style: Optional[Style] = None, + font_name: Optional[str] = None, **kwargs, ): default_style = Style( @@ -38,23 +42,23 @@ def __init__( cursor=CursorType.POINTING_HAND, ) - # Merge provided style if any - if style: - # Simple merge logic (could be improved) - default_style.margin = style.margin - default_style.position = style.position - default_style.top = style.top - default_style.left = style.left - # ... copy other props + if style is not None: + merge_style_fields(default_style, style, _BUTTON_MERGE_FIELDS) super().__init__(style=default_style, **kwargs) self.on_click = on_click # Add Text Child with specified font size - self.text_node = Text(text, size=font_size, color=text_color) + self.text_node = Text( + text, + size=font_size, + color=text_color, + font_name=font_name, + ) self.text_node.pickable = False # Text should not block button click - self.add_child(self.text_node) + self.text_node.parent = self + self.children.append(self.text_node) # Button states self.base_color = bg_color diff --git a/arepy_ui/components/drag.py b/arepy_ui/components/drag.py index 1303c0c..2a9a118 100644 --- a/arepy_ui/components/drag.py +++ b/arepy_ui/components/drag.py @@ -4,7 +4,7 @@ from arepy.engine.renderer import Rect from arepy.math import check_collision_point_rec -from ..core.animation import Animation, Easing +from ..core.animation import Easing from ..core.node import Node from ..core.style import Style from ..core.types import Color, CursorType, FlexDirection, Unit, Vector2 @@ -237,28 +237,21 @@ def _animate_return(self): start_x = self.computed_x start_y = self.computed_y - self._manager.animator.add( - Animation( - target=self, - property_name="computed_x", - start_value=start_x, - end_value=self._original_x, - duration=0.2, - easing=Easing.EASE_OUT_CUBIC, - ) - ) - - self._manager.animator.add( - Animation( - target=self, - property_name="computed_y", - start_value=start_y, - end_value=self._original_y, - duration=0.2, - easing=Easing.EASE_OUT_CUBIC, - on_complete=self.mark_dirty, - ) - ) + self._manager.animator.create().to( + self, + "computed_x", + self._original_x, + 0.2, + Easing.EASE_OUT_CUBIC, + ).start() + + self._manager.animator.create().to( + self, + "computed_y", + self._original_y, + 0.2, + Easing.EASE_OUT_CUBIC, + ).call(self.mark_dirty).start() def render(self): """Override render to avoid double rendering during drag.""" diff --git a/arepy_ui/components/image.py b/arepy_ui/components/image.py index a1ccb3d..7ad8038 100644 --- a/arepy_ui/components/image.py +++ b/arepy_ui/components/image.py @@ -5,7 +5,7 @@ from arepy.engine.renderer import Rect from ..core.node import Node -from ..core.style import Style +from ..core.style import Style, merge_style_fields from ..core.types import Color, Unit from ..logging import logger from ..runtime import get_runtime @@ -43,14 +43,13 @@ def __init__( border_radius=border_radius, ) - if style: - # Merge styles - default_style.width = style.width or default_style.width - default_style.height = style.height or default_style.height - default_style.margin = style.margin - default_style.padding = style.padding - if style.border_radius > 0: - default_style.border_radius = style.border_radius + merge_style_fields( + default_style, + style, + ("width", "height", "margin", "padding"), + ) + if style and style.border_radius > 0: + default_style.border_radius = style.border_radius super().__init__(style=default_style, **kwargs) diff --git a/arepy_ui/components/input.py b/arepy_ui/components/input.py index 503db01..deb8ee2 100644 --- a/arepy_ui/components/input.py +++ b/arepy_ui/components/input.py @@ -1,3 +1,4 @@ +from bisect import bisect_left from typing import Callable, Optional from arepy.engine.input import Key @@ -6,7 +7,7 @@ from ..core.fonts import draw_text, measure_text from ..core.node import Node -from ..core.style import Spacing, Style +from ..core.style import Spacing, Style, merge_non_default_style_fields from ..core.types import AlignItems, Color, CursorType, Unit from ..runtime import MouseButton, get_runtime @@ -52,9 +53,36 @@ def __init__( cursor=CursorType.IBEAM, ) - super().__init__(style=style or default_style, **kwargs) + merge_non_default_style_fields( + default_style, + style, + ( + "width", + "height", + "min_width", + "max_width", + "min_height", + "max_height", + "margin", + "padding", + "align_items", + "position", + "top", + "left", + "right", + "bottom", + "visible", + "opacity", + "background_color", + "border_color", + "border_width", + "border_radius", + "cursor", + ), + ) + + super().__init__(style=default_style, **kwargs) - self.value = value self.placeholder = placeholder self.on_change = on_change self.on_submit = on_submit @@ -66,6 +94,12 @@ def __init__( self.focused_border_color = Color(0, 121, 241, 255) self.default_border_color = Color(130, 130, 130, 255) + self._value = "" + self._cached_metrics_value: Optional[str] = None + self._cached_metrics_font_size: Optional[float] = None + self._prefix_widths = [0.0] + self._midpoint_widths = [] + # Cursor position (0 = before first char, len(value) = after last char) self.cursor_pos = 0 @@ -86,6 +120,45 @@ def __init__( # Key repeat state self._key_states = {} + self.value = value + + @property + def value(self) -> str: + return self._value + + @value.setter + def value(self, text: str): + self._value = text + self._invalidate_text_metrics() + + def _invalidate_text_metrics(self): + self._cached_metrics_value = None + self._cached_metrics_font_size = None + + def _ensure_text_metrics(self): + if ( + self._cached_metrics_value == self._value + and self._cached_metrics_font_size == self.font_size + ): + return + + prefix_widths = [0.0] + midpoint_widths = [] + for index in range(len(self._value)): + next_width = measure_text(self._value[: index + 1], self.font_size) + prefix_widths.append(next_width) + midpoint_widths.append((prefix_widths[index] + next_width) / 2) + + self._prefix_widths = prefix_widths + self._midpoint_widths = midpoint_widths + self._cached_metrics_value = self._value + self._cached_metrics_font_size = self.font_size + + def _get_prefix_width(self, position: int) -> float: + self._ensure_text_metrics() + clamped = max(0, min(position, len(self._value))) + return self._prefix_widths[clamped] + def _clear_selection(self): """Clear text selection.""" self.has_selection = False @@ -310,17 +383,8 @@ def _get_char_position_at_x(self, x: float, runtime) -> int: if relative_x <= 0: return 0 - # Find the character position - for i in range(len(self.value) + 1): - text_width = measure_text(self.value[:i], self.font_size) - if i < len(self.value): - char_width = measure_text(self.value[i], self.font_size) - if relative_x < text_width + char_width / 2: - return i - else: - if relative_x <= text_width: - return i - return len(self.value) + self._ensure_text_metrics() + return min(bisect_left(self._midpoint_widths, relative_x), len(self._value)) def handle_input(self, mouse_pos, is_click, wheel_scroll: float = 0.0) -> bool: if not self.style.visible: @@ -413,10 +477,9 @@ def _get_content_area(self): def _ensure_cursor_visible(self): """Scroll to keep cursor visible.""" - runtime = get_runtime() _, _, content_w, _ = self._get_content_area() - cursor_x = measure_text(self.value[: self.cursor_pos], int(self.font_size)) + cursor_x = self._get_prefix_width(self.cursor_pos) # Scroll left if cursor is before visible area if cursor_x < self.text_offset_x: @@ -498,8 +561,8 @@ def render(self): sel = self._get_selection_range() if sel and self.value: start, end = sel - sel_start_x = measure_text(self.value[:start], int(self.font_size)) - sel_end_x = measure_text(self.value[:end], int(self.font_size)) + sel_start_x = self._get_prefix_width(start) + sel_end_x = self._get_prefix_width(end) runtime.renderer.draw_rectangle( Rect( @@ -529,9 +592,7 @@ def render(self): self.show_cursor = not self.show_cursor if self.show_cursor: - cursor_x = measure_text( - self.value[: self.cursor_pos], int(self.font_size) - ) + cursor_x = self._get_prefix_width(self.cursor_pos) runtime.renderer.draw_rectangle( Rect( int(content_x + cursor_x - self.text_offset_x), diff --git a/arepy_ui/components/listview.py b/arepy_ui/components/listview.py index a585062..43a53a2 100644 --- a/arepy_ui/components/listview.py +++ b/arepy_ui/components/listview.py @@ -409,21 +409,17 @@ def render(self): content_w = self.computed_width - padding_x * 2 - self.scrollbar_width - 4 content_h = self.computed_height - padding_y * 2 - # Scissor for content - runtime.renderer.begin_scissor_mode( - int(content_x), - int(content_y), - int(content_w + self.scrollbar_width + 4), - int(content_h), - ) - # Draw visible items (virtualization) first_visible, last_visible = self._get_visible_range() + content_bottom = content_y + content_h for i in range(first_visible, last_visible): item = self.items[i] item_y = content_y + i * self.item_height - self.scroll_y + if item_y + self.item_height <= content_y or item_y >= content_bottom: + continue + # Item background is_selected = i in self._selected_indices is_hovered = i == self._hovered_index @@ -491,8 +487,6 @@ def render(self): self.scrollbar_thumb_color, ) - runtime.renderer.end_scissor_mode() - # Render children for child in self.children: child.render() diff --git a/arepy_ui/components/tabs.py b/arepy_ui/components/tabs.py index 8671890..cd531af 100644 --- a/arepy_ui/components/tabs.py +++ b/arepy_ui/components/tabs.py @@ -1,8 +1,8 @@ from typing import List, Optional, Tuple from ..core.node import Node -from ..core.style import Spacing, Style -from ..core.types import Color, FlexDirection, JustifyContent, Unit +from ..core.style import Spacing, Style, merge_non_default_style_fields +from ..core.types import Color, FlexDirection, Unit from .button import Button @@ -18,10 +18,38 @@ def __init__( default_style = Style( width=width, height=height, flex_direction=FlexDirection.COLUMN ) - super().__init__(style=style or default_style, **kwargs) + merge_non_default_style_fields( + default_style, + style, + ( + "width", + "height", + "margin", + "padding", + "flex_direction", + "justify_content", + "align_items", + "gap", + "position", + "top", + "left", + "right", + "bottom", + "visible", + "opacity", + "background_color", + "border_color", + "border_width", + "border_radius", + "cursor", + ), + ) + super().__init__(style=default_style, **kwargs) self.tabs_data = tabs self.active_index = 0 + self._tab_buttons: list[Button] = [] + self._tab_contents: list[Node] = [] # Tab Bar # Use a ScrollView for the tab bar if it overflows? @@ -40,73 +68,71 @@ def __init__( self.add_child(self.tab_bar) # Content Container - # Use a ScrollView for the content to prevent overflow - from .scroll import ScrollView - self.content_wrapper = Node( style=Style( width=Unit.percent(100), - height=Unit.percent(90), # Remaining height + height=Unit.auto(), padding=Spacing.all(10), ) ) - # We don't add content_wrapper directly, we add a ScrollView that contains it? - # No, the content itself changes. - # We should make the content_container a ScrollView? - # But ScrollView takes a 'content' node in init. - - # Let's make a container for the active tab content. self.content_container = Node( - style=Style(width=Unit.percent(100), height=Unit.percent(100)) + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + flex_direction=FlexDirection.COLUMN, + ) ) - - # We wrap this container in a ScrollView? - # Or we wrap each tab content in a ScrollView when we add it? - # The user's Inventory tab already has a ScrollView. - # The Settings tab does not. - - # Let's just add the content_container to self. - self.add_child(self.content_container) + self.content_wrapper.add_child(self.content_container) + self.add_child(self.content_wrapper) self._build_ui() def _build_ui(self): - # Optimization: Don't destroy buttons if they exist, just update style - if not self.tab_bar.children: - # First build - create buttons and add content - for i, (title, content) in enumerate(self.tabs_data): - btn = Button( - text=title, - on_click=lambda idx=i: self.set_tab(idx), - width=Unit.auto(), - bg_color=Color(30, 30, 40, 255), - border_radius=0.0, - ) - btn.style.padding = Spacing.symmetric(8, 16) - self.tab_bar.add_child(btn) - - # Add content directly - don't wrap if already ScrollView - from .scroll import ScrollView - - content.style.visible = False - self.content_container.add_child(content) - - # Update button styles - for i, child in enumerate(self.tab_bar.children): - is_active = i == self.active_index - if isinstance(child, Button): - child.style.background_color = ( - Color(50, 50, 60, 255) if is_active else Color(30, 30, 40, 255) - ) - child.style.border_radius = 4.0 if is_active else 0.0 - - # Update content visibility - for i, child in enumerate(self.content_container.children): - child.style.visible = i == self.active_index - - self.mark_dirty() + if self._tab_buttons: + return + + for index, (title, content) in enumerate(self.tabs_data): + button = Button( + text=title, + on_click=lambda idx=index: self.set_tab(idx), + width=Unit.auto(), + bg_color=Color(30, 30, 40, 255), + border_radius=0.0, + ) + button.style.padding = Spacing.symmetric(8, 16) + self.tab_bar.add_child(button) + self._tab_buttons.append(button) + + content.style.visible = False + self.content_container.add_child(content) + self._tab_contents.append(content) + + self._set_tab_active_state(self.active_index, True) + + def _set_tab_active_state(self, index: int, is_active: bool): + if not 0 <= index < len(self._tab_buttons): + return + + button = self._tab_buttons[index] + content = self._tab_contents[index] + button.style.background_color = ( + Color(50, 50, 60, 255) if is_active else Color(30, 30, 40, 255) + ) + button.style.border_radius = 4.0 if is_active else 0.0 + content.style.visible = is_active def set_tab(self, index: int): - if 0 <= index < len(self.tabs_data): - self.active_index = index - self._build_ui() + if not 0 <= index < len(self.tabs_data): + return + if index == self.active_index: + return + + previous_index = self.active_index + self.active_index = index + self._set_tab_active_state(previous_index, False) + self._set_tab_active_state(index, True) + + if self._manager is not None: + self._manager.mark_dirty() + else: + self.mark_dirty() diff --git a/arepy_ui/components/text.py b/arepy_ui/components/text.py index c07c573..5ac0615 100644 --- a/arepy_ui/components/text.py +++ b/arepy_ui/components/text.py @@ -2,10 +2,33 @@ from ..core.fonts import TextMetrics, draw_text, measure_text_ex from ..core.node import Node -from ..core.style import Style +from ..core.style import Style, merge_non_default_style_fields from ..core.types import Color, Unit +_TEXT_STYLE_FIELDS = ( + "margin", + "padding", + "position", + "top", + "left", + "right", + "bottom", + "visible", + "opacity", + "background_color", + "border_color", + "border_width", + "border_radius", + "cursor", + "text_color", + "font_size", +) + + +_TEXT_DEFAULT_STYLE = Style() + + class Text(Node): """Text node with cached measurement and custom font support.""" @@ -18,12 +41,25 @@ def __init__( font_name: Optional[str] = None, **kwargs, ): - super().__init__(style=style or Style(), **kwargs) + if style is None: + merged_style = Style() + else: + merged_style = merge_non_default_style_fields( + Style(), + style, + _TEXT_STYLE_FIELDS, + default_style=_TEXT_DEFAULT_STYLE, + ) + super().__init__(style=merged_style, **kwargs) self._text = text self.font_size = size self.color = color self.font_name = font_name # None uses default font self._cached_metrics: Optional[TextMetrics] = None + self._lines: tuple[str, ...] = () + self._is_multiline = False + self._line_height = 0.0 + self._set_text_cache(text) self._update_size() @property @@ -35,45 +71,58 @@ def text(self, value: str): if value != self._text: self._text = value self._cached_metrics = None + self._set_text_cache(value) self._update_size() self.mark_dirty() + def _set_text_cache(self, text: str): + if not text: + self._lines = ("",) + self._is_multiline = False + return + + newline_index = text.find("\n") + if newline_index == -1: + self._lines = (text,) + self._is_multiline = False + return + + self._lines = tuple(text.split("\n")) + self._is_multiline = True + def _update_size(self): """Update node size based on text content using measure_text_ex.""" - lines = self._text.split("\n") - if len(lines) > 1: - self._is_multiline = True - # Measure first line to get line_height - # We assume all lines have similar height characteristics - metrics = measure_text_ex(lines[0], self.font_size, self.font_name) - self._line_height = metrics.line_height - - max_width = 0 - for line in lines: - m = measure_text_ex(line, self.font_size, self.font_name) - max_width = max(max_width, m.width) - - self.style.width = Unit.px(max_width) - self.style.height = Unit.px(len(lines) * self._line_height) + if self._is_multiline: + first_line_metrics = measure_text_ex( + self._lines[0], self.font_size, self.font_name + ) + self._line_height = first_line_metrics.line_height + + max_width = first_line_metrics.width + for line in self._lines[1:]: + max_width = max( + max_width, + measure_text_ex(line, self.font_size, self.font_name).width, + ) + + self.style.set_measured_size( + max_width, + len(self._lines) * self._line_height, + ) else: - self._is_multiline = False if self._cached_metrics is None: self._cached_metrics = measure_text_ex( self._text, self.font_size, self.font_name ) - self.style.width = Unit.px(self._cached_metrics.width) - # Use font_size as height for better vertical centering - # measure_text_ex may return height with extra padding - self.style.height = Unit.px(self.font_size) + self.style.set_measured_size( + self._cached_metrics.width, + self.font_size, + ) def render(self): if not self.style.visible or self.style.opacity <= 0: return - # Ensure size is updated - if not hasattr(self, "_is_multiline"): - self._update_size() - final_color = self.color if self.style.opacity < 1.0: final_color = Color( @@ -84,9 +133,8 @@ def render(self): ) if self._is_multiline: - lines = self._text.split("\n") y = self.computed_y - for line in lines: + for line in self._lines: draw_text( line, self.computed_x, diff --git a/arepy_ui/components/textarea.py b/arepy_ui/components/textarea.py index 005f173..fdc7494 100644 --- a/arepy_ui/components/textarea.py +++ b/arepy_ui/components/textarea.py @@ -1,5 +1,6 @@ """TextArea component - Multi-line text input.""" +from bisect import bisect_left from typing import Callable, List, Optional from arepy import CursorType @@ -96,6 +97,9 @@ def __init__( # Mouse drag state self._is_dragging = False + self._line_metrics_cache = {} + self._line_number_width_cache = {} + # Colors self.text_color = Color(220, 220, 230, 255) self.placeholder_color = Color(100, 100, 110, 255) @@ -130,9 +134,61 @@ def _get_line_number_width(self) -> float: """Get width reserved for line numbers.""" if not self.show_line_numbers: return 0 + + cache_key = (len(self._lines), self.font_size) + cached_width = self._line_number_width_cache.get(cache_key) + if cached_width is not None: + return cached_width + runtime = get_runtime() max_num = str(len(self._lines)) - return runtime.renderer.measure_text(max_num, self.font_size) + 20 + width = runtime.renderer.measure_text(max_num, self.font_size) + 20 + if len(self._line_number_width_cache) >= 32: + self._line_number_width_cache.clear() + self._line_number_width_cache[cache_key] = width + return width + + def _get_line_metrics(self, line: str, runtime): + cache_key = (line, self.font_size) + cached_metrics = self._line_metrics_cache.get(cache_key) + if cached_metrics is not None: + return cached_metrics + + prefix_widths = [0.0] + midpoint_widths = [] + for index in range(len(line)): + next_width = runtime.renderer.measure_text( + line[: index + 1], self.font_size + ) + prefix_widths.append(next_width) + midpoint_widths.append((prefix_widths[index] + next_width) / 2) + + metrics = (prefix_widths, midpoint_widths, prefix_widths[-1]) + if len(self._line_metrics_cache) >= 512: + self._line_metrics_cache.clear() + self._line_metrics_cache[cache_key] = metrics + return metrics + + def _get_prefix_width(self, line_index: int, column: int, runtime) -> float: + line = self._lines[line_index] + prefix_widths, _, _ = self._get_line_metrics(line, runtime) + clamped = max(0, min(column, len(line))) + return prefix_widths[clamped] + + def _get_line_width(self, line_index: int, runtime) -> float: + _, _, width = self._get_line_metrics(self._lines[line_index], runtime) + return width + + def _get_column_at_x( + self, line_index: int, x: float, content_x: float, runtime + ) -> int: + relative_x = x - content_x + self.scroll_x + if relative_x <= 0: + return 0 + + line = self._lines[line_index] + _, midpoint_widths, _ = self._get_line_metrics(line, runtime) + return min(bisect_left(midpoint_widths, relative_x), len(line)) def _get_content_area(self): """Get the content area for text (excluding line numbers).""" @@ -536,16 +592,9 @@ def handle_input(self, mouse_pos, is_click, wheel_scroll: float = 0.0) -> bool: clicked_line = max(0, min(clicked_line, len(self._lines) - 1)) # Calculate clicked column - line_text = self._lines[clicked_line] - clicked_col = 0 - x_offset = content_x - self.scroll_x - - for i, char in enumerate(line_text): - char_width = runtime.renderer.measure_text(char, self.font_size) - if mouse_pos.x < x_offset + char_width / 2: - break - x_offset += char_width - clicked_col = i + 1 + clicked_col = self._get_column_at_x( + clicked_line, mouse_pos.x, content_x, runtime + ) shift = runtime.input.is_key_down( Key.LEFT_SHIFT @@ -570,16 +619,7 @@ def handle_input(self, mouse_pos, is_click, wheel_scroll: float = 0.0) -> bool: drag_line = int((mouse_pos.y - content_y + self.scroll_y) / line_height) drag_line = max(0, min(drag_line, len(self._lines) - 1)) - line_text = self._lines[drag_line] - drag_col = 0 - x_offset = content_x - self.scroll_x - - for i, char in enumerate(line_text): - char_width = runtime.renderer.measure_text(char, self.font_size) - if mouse_pos.x < x_offset + char_width / 2: - break - x_offset += char_width - drag_col = i + 1 + drag_col = self._get_column_at_x(drag_line, mouse_pos.x, content_x, runtime) # Extend selection while dragging if drag_line != self.cursor_line or drag_col != self.cursor_col: @@ -653,9 +693,7 @@ def _ensure_cursor_visible(self): # Horizontal scroll runtime = get_runtime() - cursor_x = runtime.renderer.measure_text( - self._lines[self.cursor_line][: self.cursor_col], self.font_size - ) + cursor_x = self._get_prefix_width(self.cursor_line, self.cursor_col, runtime) if cursor_x < self.scroll_x: self.scroll_x = cursor_x - 10 elif cursor_x > self.scroll_x + content_w - 10: @@ -751,44 +789,34 @@ def render(self): # Selection on single line x1 = ( content_x - + runtime.renderer.measure_text( - line[:start_col], self.font_size - ) + + self._get_prefix_width(i, start_col, runtime) - self.scroll_x ) x2 = ( content_x - + runtime.renderer.measure_text(line[:end_col], self.font_size) + + self._get_prefix_width(i, end_col, runtime) - self.scroll_x ) elif i == start_line: x1 = ( content_x - + runtime.renderer.measure_text( - line[:start_col], self.font_size - ) + + self._get_prefix_width(i, start_col, runtime) - self.scroll_x ) x2 = ( - content_x - + runtime.renderer.measure_text(line, self.font_size) - - self.scroll_x - + 5 + content_x + self._get_line_width(i, runtime) - self.scroll_x + 5 ) elif i == end_line: x1 = content_x - self.scroll_x x2 = ( content_x - + runtime.renderer.measure_text(line[:end_col], self.font_size) + + self._get_prefix_width(i, end_col, runtime) - self.scroll_x ) else: x1 = content_x - self.scroll_x x2 = ( - content_x - + runtime.renderer.measure_text(line, self.font_size) - - self.scroll_x - + 5 + content_x + self._get_line_width(i, runtime) - self.scroll_x + 5 ) runtime.renderer.draw_rectangle( @@ -835,9 +863,7 @@ def render(self): if self.show_cursor: cursor_x = ( content_x - + runtime.renderer.measure_text( - self._lines[self.cursor_line][: self.cursor_col], self.font_size - ) + + self._get_prefix_width(self.cursor_line, self.cursor_col, runtime) - self.scroll_x ) cursor_y = content_y + self.cursor_line * line_height - self.scroll_y diff --git a/arepy_ui/components/video.py b/arepy_ui/components/video.py index 18f6110..71da9d9 100644 --- a/arepy_ui/components/video.py +++ b/arepy_ui/components/video.py @@ -8,7 +8,12 @@ from typing import Any, Callable, Iterator, Optional, Union from ..core.node import Node -from ..core.style import Spacing, Style +from ..core.style import ( + Spacing, + Style, + merge_non_default_style_fields, + merge_style_fields, +) from ..core.types import ( AlignItems, Color, @@ -19,7 +24,7 @@ Unit, ) from ..logging import logger -from ..runtime import get_runtime +from ..runtime import MOUSE_BUTTON_LEFT, get_runtime from .button import Button from .slider import Slider from .text import Text @@ -65,8 +70,8 @@ class ControlsConfig: # Visual style for controls bar style: Style = field( default_factory=lambda: Style( - background_color=Color(0, 0, 0, 180), - height=Unit.px(44), + background_color=Color(10, 10, 14, 210), + height=Unit.px(52), ) ) @@ -83,7 +88,7 @@ class ControlsConfig: show_volume: bool = True # Auto-hide controls after inactivity - auto_hide: bool = False + auto_hide: bool = True hide_delay: float = 3.0 @@ -160,6 +165,8 @@ def __init__( self._has_audio = False self._audio_memory_data = None self._deps_available = HAS_VIDEO_DEPS + self._post_seek_sync_remaining = 0.0 + self._post_seek_sync_duration = 0.25 # Setup style default_style = Style( @@ -169,13 +176,11 @@ def __init__( cursor=CursorType.POINTING_HAND, ) - if style: - default_style.width = style.width or default_style.width - default_style.height = style.height or default_style.height - if style.cursor: - default_style.cursor = style.cursor - if style.background_color: - default_style.background_color = style.background_color + merge_style_fields(default_style, style, ("width", "height")) + if style and style.cursor: + default_style.cursor = style.cursor + if style and style.background_color: + default_style.background_color = style.background_color super().__init__(style=default_style, **kwargs) @@ -202,14 +207,17 @@ def __init__( # Controls state self._controls_visible = True self._controls_hide_timer = 0.0 + self._last_mouse_position: tuple[float, float] | None = None # Control nodes (created in _build_controls) self._controls_bar: Optional[Node] = None self._play_button: Optional[Button] = None self._progress_slider: Optional[Slider] = None self._time_label: Optional[Text] = None + self._volume_btn: Optional[Button] = None self._volume_slider: Optional[Slider] = None self._is_seeking = False + self._video_display_bounds: tuple[float, float, float, float] | None = None # If dependencies not available, show placeholder instead if not self._deps_available: @@ -272,39 +280,20 @@ def _build_controls(self): if not cfg: return - # Get height from config - controls_height = 44 - if cfg.style.height and hasattr(cfg.style.height, "value"): - controls_height = int(cfg.style.height.value) + controls_height = self._get_controls_height() + bar_style = self._build_controls_bar_style(cfg, controls_height) # Controls bar container - positioned at bottom - self._controls_bar = Node( - style=Style( - position=PositionType.ABSOLUTE, - bottom=Unit.px(0), - left=Unit.px(0), - right=Unit.px(0), - height=Unit.px(controls_height), - width=Unit.percent(100), - background_color=cfg.style.background_color or Color(0, 0, 0, 180), - flex_direction=FlexDirection.ROW, - align_items=AlignItems.CENTER, - padding=Spacing.symmetric(8, 8), - gap=8, - ) - ) + self._controls_bar = Node(style=bar_style) # Play/Pause button if cfg.show_play: - self._play_button = Button( - text=">", + self._play_button = self._build_controls_button( + text=self._get_play_button_text(), on_click=self._on_play_click, - width=Unit.px(36), - height=Unit.px(36), - bg_color=Color(0, 0, 0, 0), - text_color=cfg.foreground_color, - font_size=20, - border_radius=18.0, + width=Unit.px(80), + height=Unit.px(34), + font_size=13, ) self._controls_bar.add_child(self._play_button) @@ -314,10 +303,12 @@ def _build_controls(self): min_value=0.0, max_value=100.0, value=0.0, - track_color=Color(100, 100, 100, 255), + width=Unit.px(180), + height=Unit.px(24), + track_color=Color(255, 255, 255, 55), fill_color=cfg.accent_color, thumb_color=cfg.foreground_color, - thumb_size=12, + thumb_size=10, track_height=4, on_change=self._on_progress_change, ) @@ -327,21 +318,18 @@ def _build_controls(self): if cfg.show_time: self._time_label = Text( "00:00 / 00:00", - size=12, + size=13, color=cfg.foreground_color, ) self._controls_bar.add_child(self._time_label) # Volume slider if cfg.show_volume: - # Volume icon/button - self._volume_btn = Button( - text="[+]", + self._volume_btn = self._build_controls_button( + text=self._get_volume_button_text(), on_click=self._on_mute_click, - width=Unit.px(30), - height=Unit.px(30), - bg_color=Color(0, 0, 0, 0), - text_color=cfg.foreground_color, + width=Unit.px(64), + height=Unit.px(34), font_size=12, ) self._controls_bar.add_child(self._volume_btn) @@ -350,9 +338,9 @@ def _build_controls(self): min_value=0.0, max_value=100.0, value=self._volume * 100, - width=Unit.px(60), - height=Unit.px(20), - track_color=Color(100, 100, 100, 255), + width=Unit.px(76), + height=Unit.px(24), + track_color=Color(255, 255, 255, 55), fill_color=cfg.accent_color, thumb_color=cfg.foreground_color, thumb_size=10, @@ -362,6 +350,212 @@ def _build_controls(self): self._controls_bar.add_child(self._volume_slider) self.add_child(self._controls_bar) + self._set_controls_visible(True) + + def _get_controls_height(self) -> int: + cfg = self._controls_config + controls_height = 52 + if cfg and cfg.style.height and hasattr(cfg.style.height, "value"): + controls_height = int(cfg.style.height.value) + return controls_height + + def _get_controls_button_colors(self) -> tuple[Color, Color, Color]: + base = Color(255, 255, 255, 26) + hover = Color(255, 255, 255, 40) + pressed = Color(255, 255, 255, 58) + return base, hover, pressed + + def _build_controls_button( + self, + text: str, + on_click: Callable[[], None], + width: Unit, + height: Unit, + font_size: float, + ) -> Button: + cfg = self._controls_config + assert cfg is not None + + base_color, hover_color, pressed_color = self._get_controls_button_colors() + return Button( + text=text, + on_click=on_click, + width=width, + height=height, + bg_color=base_color, + text_color=cfg.foreground_color, + border_radius=17.0, + font_size=font_size, + hover_color=hover_color, + pressed_color=pressed_color, + ) + + def _get_play_button_text(self) -> str: + return "PAUSE" if self.is_playing else "PLAY" + + def _get_volume_button_text(self) -> str: + return "MUTE" if self.muted or self._volume <= 0 else "VOL" + + def _build_controls_bar_style( + self, cfg: ControlsConfig, controls_height: int + ) -> Style: + base_style = Style( + position=PositionType.ABSOLUTE, + bottom=Unit.px(0), + left=Unit.px(0), + right=Unit.px(0), + height=Unit.px(controls_height), + width=Unit.percent(100), + background_color=cfg.style.background_color or Color(10, 10, 14, 210), + flex_direction=FlexDirection.ROW, + align_items=AlignItems.CENTER, + padding=Spacing.symmetric(0, 18), + gap=14, + border_radius=0.0, + ) + merge_non_default_style_fields( + base_style, + cfg.style, + ( + "background_color", + "border_color", + "border_width", + "border_radius", + "opacity", + "padding", + "gap", + ), + ) + base_style.position = PositionType.ABSOLUTE + base_style.bottom = Unit.px(0) + base_style.left = Unit.px(0) + base_style.right = Unit.px(0) + base_style.height = Unit.px(controls_height) + base_style.width = Unit.percent(100) + base_style.flex_direction = FlexDirection.ROW + base_style.align_items = AlignItems.CENTER + return base_style + + def _set_controls_visible(self, visible: bool): + self._controls_visible = visible + + def _reveal_controls(self): + self._controls_hide_timer = 0.0 + self._set_controls_visible(True) + + def _normalize_mouse_position(self, mouse_pos: Any) -> tuple[float, float]: + if isinstance(mouse_pos, tuple): + return float(mouse_pos[0]), float(mouse_pos[1]) + return float(mouse_pos.x), float(mouse_pos.y) + + def _is_mouse_over_video(self, mouse_x: float, mouse_y: float) -> bool: + rect = self._video_display_bounds or ( + self.computed_x, + self.computed_y, + self.computed_width, + self.computed_height, + ) + rect_x, rect_y, rect_width, rect_height = rect + return ( + rect_x <= mouse_x < rect_x + rect_width + and rect_y <= mouse_y < rect_y + rect_height + ) + + def _update_controls_visibility(self, runtime): + if not self._controls_config or not self._controls_bar: + return + + if not self._controls_config.auto_hide or not self.is_playing: + self._controls_hide_timer = 0.0 + self._set_controls_visible(True) + mouse_pos = runtime.input.get_mouse_position() + self._last_mouse_position = self._normalize_mouse_position(mouse_pos) + return + + delta_time = runtime.renderer.get_delta_time() + mouse_x, mouse_y = self._normalize_mouse_position( + runtime.input.get_mouse_position() + ) + mouse_down = runtime.input.is_mouse_button_down(MOUSE_BUTTON_LEFT) + mouse_moved = ( + self._last_mouse_position is None + or self._last_mouse_position + != ( + mouse_x, + mouse_y, + ) + ) + + if self._is_mouse_over_video(mouse_x, mouse_y) and (mouse_moved or mouse_down): + self._reveal_controls() + else: + self._controls_hide_timer += delta_time + if self._controls_hide_timer >= self._controls_config.hide_delay: + self._set_controls_visible(False) + + self._last_mouse_position = (mouse_x, mouse_y) + + def _get_reserved_controls_width(self) -> float: + used_width = 24.0 + if self._play_button: + used_width += self._play_button.style.width.value + 10 + if self._time_label: + used_width += max(self._time_label.computed_width, 104) + 10 + if self._volume_btn: + used_width += self._volume_btn.style.width.value + 10 + if self._volume_slider: + used_width += self._volume_slider.style.width.value + 10 + return used_width + + def _get_video_display_rect(self) -> tuple[float, float, float, float]: + if self.computed_width <= 0 or self.computed_height <= 0: + return (self.computed_x, self.computed_y, 0.0, 0.0) + + if self._video_width <= 0 or self._video_height <= 0: + return ( + self.computed_x, + self.computed_y, + self.computed_width, + self.computed_height, + ) + + video_aspect = self._video_width / self._video_height + container_aspect = self.computed_width / self.computed_height + + if video_aspect > container_aspect: + dst_width = self.computed_width + dst_height = self.computed_width / video_aspect + else: + dst_height = self.computed_height + dst_width = self.computed_height * video_aspect + + dst_x = self.computed_x + (self.computed_width - dst_width) / 2 + dst_y = self.computed_y + (self.computed_height - dst_height) / 2 + return (dst_x, dst_y, dst_width, dst_height) + + def _layout_controls_overlay(self): + if not self._controls_bar: + return + + display_x, display_y, display_width, display_height = ( + self._get_video_display_rect() + ) + self._video_display_bounds = ( + display_x, + display_y, + display_width, + display_height, + ) + + if display_width <= 0 or display_height <= 0: + return + + self._controls_bar.calculate_layout( + display_x, + display_y, + display_width, + display_height, + ) def _on_video_click(self): """Handle click on video area - toggle play/pause.""" @@ -371,34 +565,44 @@ def _on_video_click(self): # Check if click is in controls area if self._controls_bar and self._controls_config: - controls_height = 44 - if self._controls_config.style.height and hasattr( - self._controls_config.style.height, "value" - ): - controls_height = int(self._controls_config.style.height.value) - - controls_top = self.computed_y + self.computed_height - controls_height - if mouse_y >= controls_top: + if not self._controls_visible: + self._reveal_controls() + + controls_right = ( + self._controls_bar.computed_x + self._controls_bar.computed_width + ) + controls_bottom = ( + self._controls_bar.computed_y + self._controls_bar.computed_height + ) + in_controls = ( + self._controls_bar.computed_x <= mouse_x < controls_right + and self._controls_bar.computed_y <= mouse_y < controls_bottom + ) + if in_controls: return # Click is on controls, don't toggle self.toggle_play() def _on_play_click(self): """Handle play button click.""" + self._reveal_controls() self.toggle_play() def _on_progress_change(self, value: float): """Handle progress slider change.""" + self._reveal_controls() if self._duration > 0: seek_time = (value / 100.0) * self._duration self.seek(seek_time) def _on_volume_change(self, value: float): """Handle volume slider change.""" + self._reveal_controls() self.volume = value / 100.0 def _on_mute_click(self): """Toggle mute.""" + self._reveal_controls() self.muted = not self.muted if self.muted: self.volume = 0.0 @@ -412,22 +616,14 @@ def _update_controls(self): # Update play button icon if self._play_button: - self._play_button.text_node.text = "||" if self.is_playing else ">" + self._play_button.text_node.text = self._get_play_button_text() # Update progress slider width dynamically if self._progress_slider and self._controls_bar: - # Calculate available width for progress bar - used_width = 16 # padding - if self._play_button: - used_width += 36 + 8 # button width + gap - if self._time_label: - used_width += 90 + 8 # approx time label width + gap - if hasattr(self, "_volume_btn") and self._volume_btn: - used_width += 30 + 8 # volume button + gap - if self._volume_slider: - used_width += 60 + 8 # volume slider + gap - - available_width = max(100, self.computed_width - used_width) + controls_width = self._controls_bar.computed_width or self.computed_width + available_width = max( + 100, controls_width - self._get_reserved_controls_width() + ) self._progress_slider.style.width = Unit.px(available_width) # Update progress slider value @@ -441,10 +637,19 @@ def _update_controls(self): total = self._format_time(self._duration) self._time_label.text = f"{current} / {total}" + if self._volume_btn: + self._volume_btn.text_node.text = self._get_volume_button_text() + # Update volume slider if self._volume_slider: self._volume_slider._value = self._volume * 100 + self._layout_controls_overlay() + + def calculate_layout(self, parent_x, parent_y, parent_width, parent_height): + super().calculate_layout(parent_x, parent_y, parent_width, parent_height) + self._layout_controls_overlay() + @property def controls(self) -> Optional[ControlsConfig]: """Get controls configuration.""" @@ -460,6 +665,7 @@ def controls(self, value: Optional[Union[ControlsConfig, bool]]): self._play_button = None self._progress_slider = None self._time_label = None + self._volume_btn = None self._volume_slider = None if value is True: @@ -509,6 +715,7 @@ def play(self): if self._state != VideoState.PLAYING: self._state = VideoState.PLAYING + self._reveal_controls() if self._has_audio and self._audio_music and self._audio_device: self._audio_device.resume_music(self._audio_music) if self.on_play_callback: @@ -521,6 +728,7 @@ def pause(self): if self._state == VideoState.PLAYING: self._state = VideoState.PAUSED + self._reveal_controls() if self._has_audio and self._audio_music and self._audio_device: self._audio_device.pause_music(self._audio_music) if self.on_pause_callback: @@ -547,15 +755,28 @@ def seek(self, time: float): time = max(0.0, min(time, self._duration)) try: - stream = self._video_stream - if stream.time_base: - pts = int(time / float(stream.time_base)) - self._video_container.seek(pts, stream=stream) - self._frame_generator = self._video_container.decode(video=0) + runtime = get_runtime() + was_playing = self.is_playing + + if self._has_audio and self._audio_music and self._audio_device: + try: + self._audio_device.pause_music(self._audio_music) + except Exception: + pass + + primed = self._prime_video_at_time(time, runtime) + if not primed: self._current_time = time if self._has_audio and self._audio_music and self._audio_device: - self._audio_device.seek_music_stream(self._audio_music, time) + self._audio_device.seek_music_stream( + self._audio_music, self._current_time + ) + self._audio_device.update_music_stream(self._audio_music) + if was_playing: + self._audio_device.resume_music(self._audio_music) + + self._post_seek_sync_remaining = self._post_seek_sync_duration except Exception as e: logger.error(f"Seek error: {e}") @@ -729,14 +950,7 @@ def _get_next_frame(self) -> Optional[bytes]: if self._frame_generator is None: return None frame = next(self._frame_generator) - - if frame.pts and self._video_stream and self._video_stream.time_base: - self._current_time = float(frame.pts * self._video_stream.time_base) - if self.on_time_update: - self.on_time_update(self._current_time) - - rgba_frame = frame.to_ndarray(format="rgba") - return rgba_frame.tobytes() + return self._frame_to_pixels(frame) except StopIteration: if self.loop: @@ -751,8 +965,7 @@ def _get_next_frame(self) -> Optional[bytes]: if self._frame_generator is None: return None frame = next(self._frame_generator) - rgba_frame = frame.to_ndarray(format="rgba") - return rgba_frame.tobytes() + return self._frame_to_pixels(frame) except: return None else: @@ -771,6 +984,114 @@ def _format_time(self, seconds: float) -> str: secs = int(seconds % 60) return f"{minutes:02d}:{secs:02d}" + def _update_current_time_from_frame(self, frame: Any) -> Optional[float]: + if ( + frame.pts is not None + and self._video_stream + and self._video_stream.time_base + ): + self._current_time = float(frame.pts * self._video_stream.time_base) + if self.on_time_update: + self.on_time_update(self._current_time) + return self._current_time + return None + + def _frame_to_pixels(self, frame: Any) -> bytes: + self._update_current_time_from_frame(frame) + rgba_frame = frame.to_ndarray(format="rgba") + return rgba_frame.tobytes() + + def _update_streaming_texture(self, runtime, pixels: Optional[bytes]) -> None: + if pixels and self._streaming_texture: + runtime.renderer.update_streaming_texture(self._streaming_texture, pixels) + + def _prime_video_at_time(self, target_time: float, runtime) -> bool: + if not self._video_container or not self._video_stream: + return False + + stream = self._video_stream + time_base = getattr(stream, "time_base", None) + if not time_base: + return False + + pts = int(target_time / float(time_base)) + self._video_container.seek(pts, stream=stream, backward=True) + self._frame_generator = self._video_container.decode(video=0) + + max_scan_frames = max(int(self._video_fps * 2), 1) + frame_duration = 1.0 / self._video_fps if self._video_fps > 0 else 0.0 + selected_frame = None + last_frame = None + + for _ in range(max_scan_frames): + if self._frame_generator is None: + break + + try: + frame = next(self._frame_generator) + except StopIteration: + break + + last_frame = frame + frame_time = self._update_current_time_from_frame(frame) + if frame_time is None: + selected_frame = frame + break + + if frame_time + (frame_duration * 0.5) >= target_time: + selected_frame = frame + break + + if selected_frame is None: + selected_frame = last_frame + + if selected_frame is None: + return False + + pixels = self._frame_to_pixels(selected_frame) + self._update_streaming_texture(runtime, pixels) + self._frame_time_acc = 0.0 + return True + + def _sync_video_to_audio(self, runtime, audio_time: float) -> None: + time_diff = audio_time - self._current_time + + if self._post_seek_sync_remaining > 0.0: + self._post_seek_sync_remaining = max( + 0.0, + self._post_seek_sync_remaining - runtime.renderer.get_delta_time(), + ) + + if abs(time_diff) > 0.2: + self._prime_video_at_time(audio_time, runtime) + return + + if time_diff > 0: + self._update_streaming_texture(runtime, self._get_next_frame()) + return + + if time_diff > 0.1: + max_skip_frames = 30 + skipped = 0 + latest_pixels = None + while self._current_time < audio_time - 0.05 and skipped < max_skip_frames: + latest_pixels = self._get_next_frame() + skipped += 1 + if not latest_pixels: + break + self._update_streaming_texture(runtime, latest_pixels) + elif time_diff > 0: + self._update_streaming_texture(runtime, self._get_next_frame()) + + def _advance_video_without_audio(self, runtime) -> None: + delta_time = runtime.renderer.get_delta_time() + self._frame_time_acc += delta_time + + frame_duration = 1.0 / self._video_fps + if self._frame_time_acc >= frame_duration: + self._frame_time_acc -= frame_duration + self._update_streaming_texture(runtime, self._get_next_frame()) + def render(self): """Render the video player.""" if not self.style.visible: @@ -829,63 +1150,18 @@ def render(self): # Detect if audio has looped (audio time reset to near 0 while video is still far ahead) if audio_time < 1.0 and self._current_time > self._duration - 1.0: # Audio has looped, reset video to sync - self._video_container.seek(0) - self._frame_generator = self._video_container.decode(video=0) + self._prime_video_at_time(0.0, runtime) self._current_time = 0.0 - # If video is behind audio, skip frames to catch up - # If video is ahead, wait - time_diff = audio_time - self._current_time - - if time_diff > 0.1: # Video is more than 100ms behind - # Skip frames to catch up (limit to avoid infinite loop) - max_skip_frames = 30 # Limit how many frames we skip at once - skipped = 0 - while ( - self._current_time < audio_time - 0.05 - and skipped < max_skip_frames - ): - pixels = self._get_next_frame() - skipped += 1 - if not pixels: - break - elif time_diff > 0: # Video is slightly behind, advance normally - pixels = self._get_next_frame() - if pixels: - runtime.renderer.update_streaming_texture( - self._streaming_texture, pixels - ) - # else: video is ahead, don't advance (wait for audio) + self._sync_video_to_audio(runtime, audio_time) else: - # No audio - use frame timing - delta_time = runtime.renderer.get_delta_time() - self._frame_time_acc += delta_time - - frame_duration = 1.0 / self._video_fps - if self._frame_time_acc >= frame_duration: - self._frame_time_acc -= frame_duration - - pixels = self._get_next_frame() - if pixels: - runtime.renderer.update_streaming_texture( - self._streaming_texture, pixels - ) + self._advance_video_without_audio(runtime) texture = runtime.renderer.get_streaming_texture(self._streaming_texture) if texture: - video_aspect = self._video_width / self._video_height - container_aspect = self.computed_width / self.computed_height - - if video_aspect > container_aspect: - dst_width = self.computed_width - dst_height = self.computed_width / video_aspect - else: - dst_height = self.computed_height - dst_width = self.computed_height * video_aspect - - dst_x = self.computed_x + (self.computed_width - dst_width) / 2 - dst_y = self.computed_y + (self.computed_height - dst_height) / 2 + dst_x, dst_y, dst_width, dst_height = self._get_video_display_rect() + self._video_display_bounds = (dst_x, dst_y, dst_width, dst_height) src_rect = Rect(0, 0, self._video_width, self._video_height) dst_rect = Rect(int(dst_x), int(dst_y), int(dst_width), int(dst_height)) @@ -901,6 +1177,7 @@ def render(self): # Update controls state self._update_controls() + self._update_controls_visibility(runtime) # Draw big play button if not playing and no controls if not self._initialized or ( @@ -910,8 +1187,51 @@ def render(self): # Render children (controls) ON TOP of video for child in self.children: + if child is self._controls_bar and not self._controls_visible: + continue child.render() + def handle_input(self, mouse_pos, is_click, wheel_scroll: float = 0.0) -> bool: + from arepy.engine.renderer import Rect + from arepy.math import check_collision_point_rec + + if not self.style.visible: + return False + + for child in reversed(self.children): + if child is self._controls_bar and not self._controls_visible: + continue + if child.handle_input(mouse_pos, is_click, wheel_scroll): + return True + + if not self.pickable: + return False + + rect = Rect( + self.computed_x, + self.computed_y, + int(self.computed_width), + int(self.computed_height), + ) + is_over = check_collision_point_rec((mouse_pos.x, mouse_pos.y), rect) + + if is_over: + if not self.is_hovered: + self.is_hovered = True + if self.on_hover_enter: + self.on_hover_enter() + + if is_click and self.on_click: + self.on_click() + return True + else: + if self.is_hovered: + self.is_hovered = False + if self.on_hover_exit: + self.on_hover_exit() + + return False + def _render_big_play_button(self, runtime): """Render large centered play button.""" from arepy import Color as ArepyColor diff --git a/arepy_ui/config.py b/arepy_ui/config.py index 85df5dc..c37c6ba 100644 --- a/arepy_ui/config.py +++ b/arepy_ui/config.py @@ -3,6 +3,7 @@ from typing import Callable, Optional, Tuple, List from arepy import TextureFilter +from arepy.engine.input import Key class ResizeMode(Enum): @@ -65,6 +66,13 @@ class UIConfig: # Debounce layout recalculation (ms) - helps with rapid resize layout_debounce_ms: float = 0.0 + # === Debug Overlay === + debug_enabled: bool = False + debug_toggle_key: Optional[Key] = Key.F3 + debug_bounds_key: Optional[Key] = Key.F4 + debug_padding_key: Optional[Key] = Key.F5 + debug_tree_key: Optional[Key] = Key.F6 + # === Callbacks === # Called when window is resized: (new_width, new_height) on_resize: Optional[Callable[[int, int], None]] = None @@ -116,18 +124,25 @@ def inverse_point(self, screen_x: float, screen_y: float) -> Tuple[float, float] # === Font texture filter change notification helpers === _font_texture_filter_listeners: List[Callable[[TextureFilter], None]] = [] -def register_font_texture_filter_listener(listener: Callable[[TextureFilter], None]) -> None: + +def register_font_texture_filter_listener( + listener: Callable[[TextureFilter], None], +) -> None: """Register a listener to be called when UIConfig.font_texture_filter changes.""" if listener not in _font_texture_filter_listeners: _font_texture_filter_listeners.append(listener) -def unregister_font_texture_filter_listener(listener: Callable[[TextureFilter], None]) -> None: + +def unregister_font_texture_filter_listener( + listener: Callable[[TextureFilter], None], +) -> None: """Unregister a previously registered listener.""" try: _font_texture_filter_listeners.remove(listener) except ValueError: pass + def notify_font_texture_filter_change(filter: TextureFilter) -> None: """Notify listeners and attempt to apply the filter to loaded fonts. @@ -152,8 +167,14 @@ def notify_font_texture_filter_change(filter: TextureFilter) -> None: runtime = get_runtime() for font_info in fm._fonts.values(): try: - tmp_tex = ArepyTexture(-1, size=(font_info.base_size, font_info.base_size)) - tmp_tex._ref_texture = getattr(getattr(font_info.font, "_ref_font", font_info.font), "texture", None) + tmp_tex = ArepyTexture( + -1, size=(font_info.base_size, font_info.base_size) + ) + tmp_tex._ref_texture = getattr( + getattr(font_info.font, "_ref_font", font_info.font), + "texture", + None, + ) if tmp_tex._ref_texture is not None: runtime.renderer.set_texture_filter(tmp_tex, filter) except Exception: diff --git a/arepy_ui/core/__init__.py b/arepy_ui/core/__init__.py index 59b93b0..24bbc87 100644 --- a/arepy_ui/core/__init__.py +++ b/arepy_ui/core/__init__.py @@ -9,6 +9,7 @@ from .animation import Animation, Animator, Easing from .node import Node from .style import Spacing, Style +from .timers import Timer, Timers from .types import ( AlignItems, Color, @@ -33,6 +34,8 @@ "Animation", "Easing", "Animator", + "Timer", + "Timers", "Button", "Text", "TextInput", diff --git a/arepy_ui/core/animation.py b/arepy_ui/core/animation.py index ed576a4..2c7dfb4 100644 --- a/arepy_ui/core/animation.py +++ b/arepy_ui/core/animation.py @@ -1,76 +1,356 @@ -from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Optional +from __future__ import annotations -if TYPE_CHECKING: - from arepy_ui import Node, Style +from dataclasses import dataclass, field +from numbers import Real +from typing import Any, Callable, Protocol, TypeAlias from .easing import Easing, apply_easing from .types import Unit +EasingFunction: TypeAlias = Callable[[float], float] +EasingLike: TypeAlias = int | EasingFunction + + +def _resolve_property(target: Any, property_name: str) -> tuple[Any, str]: + obj = target + parts = property_name.split(".") + for part in parts[:-1]: + obj = getattr(obj, part) + return obj, parts[-1] + + +def _read_property(target: Any, property_name: str) -> Any: + obj, attr = _resolve_property(target, property_name) + return getattr(obj, attr) + + +def _write_property(target: Any, property_name: str, value: Any) -> None: + obj, attr = _resolve_property(target, property_name) + setattr(obj, attr, value) + + +def _is_number(value: Any) -> bool: + return isinstance(value, Real) and not isinstance(value, bool) + + +def _is_vector_like(value: Any) -> bool: + return hasattr(value, "x") and hasattr(value, "y") + + +def _is_color_like(value: Any) -> bool: + return hasattr(value, "r") and hasattr(value, "g") and hasattr(value, "b") + + +def _copy_value(value: Any) -> Any: + if isinstance(value, Unit): + return Unit(value.value, value.type) + if _is_vector_like(value): + return type(value)(float(value.x), float(value.y)) + if _is_color_like(value): + return type(value)( + int(value.r), + int(value.g), + int(value.b), + int(getattr(value, "a", 255)), + ) + return value + + +def _clamp_channel(value: float) -> int: + return max(0, min(255, round(value))) + + +def _interpolate_value(start: Any, end: Any, progress: float) -> Any: + if isinstance(start, Unit): + if isinstance(end, Unit): + if start.type != end.type: + raise ValueError("Unit animations require matching unit types.") + if progress <= 0.0: + return Unit(start.value, start.type) + if progress >= 1.0: + return Unit(end.value, end.type) + value = start.value + (end.value - start.value) * progress + return Unit(value, start.type) + + if not _is_number(end): + raise TypeError("Unit animations require a numeric or Unit target value.") + + if progress <= 0.0: + return Unit(start.value, start.type) + if progress >= 1.0: + return Unit(float(end), start.type) + value = start.value + (float(end) - start.value) * progress + return Unit(value, start.type) + + if _is_number(start) and _is_number(end): + if progress <= 0.0: + return start + if progress >= 1.0: + return end + return float(start) + (float(end) - float(start)) * progress + + if _is_vector_like(start) and _is_vector_like(end): + if progress <= 0.0: + return _copy_value(start) + if progress >= 1.0: + return _copy_value(end) + return type(start)( + float(start.x) + (float(end.x) - float(start.x)) * progress, + float(start.y) + (float(end.y) - float(start.y)) * progress, + ) + + if _is_color_like(start) and _is_color_like(end): + start_a = int(getattr(start, "a", 255)) + end_a = int(getattr(end, "a", 255)) + if progress <= 0.0: + return _copy_value(start) + if progress >= 1.0: + return _copy_value(end) + return type(start)( + _clamp_channel(float(start.r) + (float(end.r) - float(start.r)) * progress), + _clamp_channel(float(start.g) + (float(end.g) - float(start.g)) * progress), + _clamp_channel(float(start.b) + (float(end.b) - float(start.b)) * progress), + _clamp_channel(float(start_a) + (float(end_a) - float(start_a)) * progress), + ) + + raise TypeError( + f"Unsupported animation values: {type(start).__name__} -> {type(end).__name__}." + ) + + +def _apply_easing(progress: float, easing: EasingLike) -> float: + if isinstance(easing, int): + return apply_easing(progress, easing) + return easing(progress) + + +class _AnimationStep(Protocol): + def reset(self) -> None: ... + + def advance(self, dt: float) -> tuple[float, bool]: ... + @dataclass -class Animation: - target: "Node | Style" # The object to animate (usually a Node or Style) - property_name: str - start_value: float - end_value: float +class _WaitStep: duration: float - easing: int # Easing constant (e.g., Easing.EASE_OUT_QUAD) + elapsed: float = 0.0 + + def reset(self) -> None: + self.elapsed = 0.0 + + def advance(self, dt: float) -> tuple[float, bool]: + if self.duration <= 0.0: + return 0.0, True + remaining = self.duration - self.elapsed + consumed = min(dt, remaining) + self.elapsed += consumed + return consumed, self.elapsed >= self.duration + +@dataclass +class _CallStep: + callback: Callable[[], None] + called: bool = False + + def reset(self) -> None: + self.called = False + + def advance(self, dt: float) -> tuple[float, bool]: + if not self.called: + self.called = True + self.callback() + return 0.0, True + + +@dataclass +class _TweenStep: + target: Any + property_name: str + end_value: Any + duration: float + easing: EasingLike = Easing.LINEAR elapsed: float = 0.0 - is_finished: bool = False - on_complete: Optional[Callable[[], None]] = None + started: bool = False + start_value: Any = field(default=None, init=False) - def update(self, dt: float): - if self.is_finished: + def reset(self) -> None: + self.elapsed = 0.0 + self.started = False + self.start_value = None + + def _ensure_started(self) -> None: + if self.started: return + self.started = True + self.start_value = _copy_value(_read_property(self.target, self.property_name)) + + def advance(self, dt: float) -> tuple[float, bool]: + self._ensure_started() - self.elapsed += dt - t: int | float = min(self.elapsed / self.duration, 1.0) - eased_t: int | float = apply_easing(t, self.easing) + if self.duration <= 0.0: + _write_property( + self.target, + self.property_name, + _interpolate_value(self.start_value, self.end_value, 1.0), + ) + return 0.0, True - current_value: int | float = ( - self.start_value + (self.end_value - self.start_value) * eased_t + remaining = self.duration - self.elapsed + consumed = min(dt, remaining) + self.elapsed += consumed + progress = min(self.elapsed / self.duration, 1.0) + eased_progress = _apply_easing(progress, self.easing) + _write_property( + self.target, + self.property_name, + _interpolate_value(self.start_value, self.end_value, eased_progress), ) + return consumed, self.elapsed >= self.duration + + +class Animation: + """A sequenced animation built from waits, tweens, and callbacks.""" + + def __init__(self) -> None: + self._animator: Animator | None = None + self._steps: list[_AnimationStep] = [] + self._current_step_index = 0 + self._started = False + self._finished = False + self._cancelled = False + + def bind(self, animator: "Animator") -> "Animation": + self._animator = animator + return self + + def _ensure_editable(self) -> None: + if self._started: + raise RuntimeError("Cannot modify an animation after start().") + + def wait(self, duration: float) -> "Animation": + self._ensure_editable() + if duration < 0.0: + raise ValueError("Animation wait duration must be >= 0.") + self._steps.append(_WaitStep(duration=duration)) + return self + + def to( + self, + target: Any, + property_name: str, + end_value: Any, + duration: float, + easing: EasingLike = Easing.LINEAR, + ) -> "Animation": + self._ensure_editable() + if duration < 0.0: + raise ValueError("Animation duration must be >= 0.") + self._steps.append( + _TweenStep( + target=target, + property_name=property_name, + end_value=_copy_value(end_value), + duration=duration, + easing=easing, + ) + ) + return self + + def call(self, callback: Callable[[], None]) -> "Animation": + self._ensure_editable() + self._steps.append(_CallStep(callback=callback)) + return self - # Use setattr to update the property - # Handle nested properties if needed (e.g. style.opacity) - if "." in self.property_name: - obj = self.target - parts = self.property_name.split(".") - for part in parts[:-1]: - obj = getattr(obj, part) - final_attr = parts[-1] - else: - obj = self.target - final_attr = self.property_name - - # Check if the property is a Unit and handle accordingly - current_attr = getattr(obj, final_attr) - if isinstance(current_attr, Unit): - # Preserve the Unit type and only change the value - new_unit = Unit(current_value, current_attr.type) - setattr(obj, final_attr, new_unit) - else: - setattr(obj, final_attr, current_value) - - if t >= 1.0: - self.is_finished = True - if self.on_complete: - self.on_complete() + def start(self) -> "Animation": + if self._animator is None: + raise RuntimeError( + "Animation must be created by Animator.create() before start()." + ) + if self._started: + raise RuntimeError("Animation has already been started.") + + self._started = True + self._finished = False + self._cancelled = False + self._current_step_index = 0 + for step in self._steps: + step.reset() + self._animator._activate(self) + return self + + def cancel(self) -> None: + self._cancelled = True + + def update(self, dt: float) -> bool: + if self._cancelled or self._finished: + return False + + remaining = max(0.0, dt) + while self._current_step_index < len(self._steps): + step = self._steps[self._current_step_index] + consumed, completed = step.advance(remaining) + remaining = max(0.0, remaining - consumed) + + if not completed: + return True + + self._current_step_index += 1 + + self._finished = True + return False + + @property + def is_finished(self) -> bool: + return self._finished class Animator: - def __init__(self): - self.animations: list[Animation] = [] - - def add(self, animation: Animation): - self.animations.append(animation) - - def update(self, dt: float): - active_animations: list[Animation] = [] - for anim in self.animations: - anim.update(dt) - if not anim.is_finished: - active_animations.append(anim) - self.animations: list[Animation] = active_animations + """Runs active animations built with the fluent Animation API.""" + + def __init__(self) -> None: + self._animations: list[Animation] = [] + self._queued_animations: list[Animation] = [] + self._is_updating = False + + @property + def animations(self) -> tuple[Animation, ...]: + return tuple(self._animations + self._queued_animations) + + def __len__(self) -> int: + return len(self._animations) + + def create(self) -> Animation: + return Animation().bind(self) + + def clear(self) -> None: + for animation in self._animations: + animation.cancel() + for animation in self._queued_animations: + animation.cancel() + self._animations.clear() + self._queued_animations.clear() + + def update(self, dt: float) -> None: + if dt < 0.0: + raise ValueError("Animator delta time must be >= 0.") + + active: list[Animation] = [] + self._is_updating = True + try: + for animation in self._animations: + if animation.update(dt): + active.append(animation) + finally: + self._is_updating = False + + if self._queued_animations: + active.extend(self._queued_animations) + self._queued_animations.clear() + + self._animations = active + + def _activate(self, animation: Animation) -> None: + if self._is_updating: + self._queued_animations.append(animation) + return + self._animations.append(animation) diff --git a/arepy_ui/core/fonts.py b/arepy_ui/core/fonts.py index d2be0bb..f55d1b2 100644 --- a/arepy_ui/core/fonts.py +++ b/arepy_ui/core/fonts.py @@ -1,13 +1,27 @@ from arepy_ui.logging import logger from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Iterable, Optional, Tuple from arepy import ArepyTexture, TextureFilter from ..runtime import get_runtime from .types import Color +_ASCII_FONT_CHARS = list(range(32, 127)) + + +@dataclass(frozen=True, slots=True) +class FontLoadRequest: + """Configuration for loading a font into the FontManager.""" + + name: str + path: str + base_size: int = 64 + set_as_default: bool = False + texture_filter: Optional[TextureFilter] = None + glyphs: Optional[str | tuple[int, ...]] = None + @dataclass class TextMetrics: @@ -32,6 +46,7 @@ class FontInfo: font: Any # ArepyFont from arepy name: str base_size: int + glyph_count: int = 0 def get_scale(self, target_size: float) -> float: """Get the scale factor for a target font size.""" @@ -50,6 +65,8 @@ class FontManager: def __init__(self): self._fonts: Dict[str, FontInfo] = {} self._default_font_name: Optional[str] = None + self._measurement_cache: Dict[Tuple[str, str, float, float], TextMetrics] = {} + self._measurement_cache_limit = 2048 # Default texture filter for fonts; can be changed dynamically via UIConfig self.texture_filter: Optional[TextureFilter] = TextureFilter.BILINEAR @@ -94,6 +111,119 @@ def set_texture_filter(self, texture_filter: Optional[TextureFilter]) -> None: # Ignore per-font failures pass + def _normalize_glyphs(self, glyphs: Optional[str | tuple[int, ...]]) -> list[int]: + if glyphs is None: + return _ASCII_FONT_CHARS + + if isinstance(glyphs, str): + seen: set[int] = set() + normalized: list[int] = [] + for character in glyphs: + codepoint = ord(character) + if codepoint not in seen: + seen.add(codepoint) + normalized.append(codepoint) + return normalized or _ASCII_FONT_CHARS + + seen_ints: set[int] = set() + normalized_ints: list[int] = [] + for codepoint in glyphs: + value = int(codepoint) + if value not in seen_ints: + seen_ints.add(value) + normalized_ints.append(value) + return normalized_ints or _ASCII_FONT_CHARS + + def _invalidate_measurement_cache( + self, + font_names: Iterable[str], + include_default: bool = False, + ) -> None: + names = set(font_names) + if not names and not include_default: + return + + self._measurement_cache = { + key: metrics + for key, metrics in self._measurement_cache.items() + if key[0] not in names and not (include_default and key[0] == "__default__") + } + + def _apply_texture_filter( + self, + runtime, + font: Any, + base_size: int, + texture_filter: Optional[TextureFilter], + ) -> None: + if texture_filter is None: + return + + try: + temp_texture = ArepyTexture(-1, size=(base_size, base_size)) + temp_texture._ref_texture = font._ref_font.texture # type: ignore + runtime.renderer.set_texture_filter(temp_texture, texture_filter) + except Exception as e: + logger.error( + f"Failed to apply texture filter: {texture_filter} - {e}", + exc_info=True, + ) + + def _load_font_request( + self, + runtime, + request: FontLoadRequest, + clear_cache: bool = True, + ) -> bool: + glyph_codes = self._normalize_glyphs(request.glyphs) + font = runtime.renderer.load_font_ex( + Path(request.path), + request.base_size, + glyph_codes, + len(glyph_codes), + ) + + if font is None: + raise ValueError(f"Font texture not loaded from path: {request.path}") + + try: + if ( + hasattr(font, "texture") + and hasattr(font.texture, "id") + and font.texture.id == 0 + ): + raise ValueError(f"Font texture not loaded from path: {request.path}") + except (AttributeError, TypeError): + pass + + applied_filter = ( + request.texture_filter + if request.texture_filter is not None + else self.texture_filter + ) + self._apply_texture_filter(runtime, font, request.base_size, applied_filter) + + replacing_existing = request.name in self._fonts + default_will_change = request.set_as_default + + self._fonts[request.name] = FontInfo( + font=font, + name=request.name, + base_size=request.base_size, + glyph_count=len(glyph_codes), + ) + + if request.set_as_default: + self._default_font_name = request.name + + if clear_cache: + self._invalidate_measurement_cache( + {request.name} if replacing_existing else set(), + include_default=default_will_change, + ) + + return True + def load_font( self, name: str, @@ -101,6 +231,7 @@ def load_font( base_size: int = 64, set_as_default: bool = False, texture_filter: Optional[TextureFilter] = None, + glyphs: Optional[str | tuple[int, ...]] = None, ) -> bool: """ Load a font from a file. @@ -112,47 +243,43 @@ def load_font( set_as_default: Whether to set this as the default font texture_filter: Optional TextureFilter to apply to this font's texture. If None, FontManager's default `self.texture_filter` is used. + glyphs: Optional glyph subset. Pass a string like `"ABC123"` or a tuple + of integer codepoints to limit what gets loaded. Returns: True if loaded successfully """ runtime = get_runtime() - # Load font with specified size for quality - # Provide ASCII character codes (32-126) for glyphCount=95 - font_chars = list(range(32, 127)) - font = runtime.renderer.load_font_ex(Path(path), base_size, font_chars, 95) - - if font is None: - raise ValueError(f"Font texture not loaded from path: {path}") - - # Check if font texture was loaded successfully - try: - if hasattr(font, 'texture') and hasattr(font.texture, 'id') and font.texture.id == 0: - raise ValueError(f"Font texture not loaded from path: {path}") - except (AttributeError, TypeError): - # If we can't check texture.id, assume font loaded successfully - pass - - # Determine which filter to apply (per-call overrides manager default) - applied_filter = ( - texture_filter if texture_filter is not None else self.texture_filter + request = FontLoadRequest( + name=name, + path=path, + base_size=base_size, + set_as_default=set_as_default, + texture_filter=texture_filter, + glyphs=glyphs, ) - if applied_filter is not None: - try: - _temp_texture = ArepyTexture(-1, size=(base_size, base_size)) - _temp_texture._ref_texture = font._ref_font.texture # type: ignore - runtime.renderer.set_texture_filter(_temp_texture, applied_filter) - except Exception as e: - logger.error(f"Failed to apply texture filter: {applied_filter} - {e}", exc_info=True) - - font_info = FontInfo(font=font, name=name, base_size=base_size) - - self._fonts[name] = font_info + return self._load_font_request(runtime, request) - if set_as_default: - self._default_font_name = name + def load_fonts(self, font_requests: list[FontLoadRequest]) -> list[str]: + """Load multiple fonts efficiently using a single runtime lookup.""" + if not font_requests: + return [] - return True + runtime = get_runtime() + loaded_fonts: list[str] = [] + replaced_names = { + request.name for request in font_requests if request.name in self._fonts + } + + for request in font_requests: + self._load_font_request(runtime, request, clear_cache=False) + loaded_fonts.append(request.name) + + include_default = any(request.set_as_default for request in font_requests) + self._invalidate_measurement_cache( + replaced_names, include_default=include_default + ) + return loaded_fonts def get_font_info(self, name: Optional[str] = None) -> Optional[FontInfo]: """Get font info by name, or the default font info.""" @@ -191,9 +318,88 @@ def set_default_font(self, name: str) -> bool: """Set the default font by name.""" if name in self._fonts: self._default_font_name = name + self._invalidate_measurement_cache(set(), include_default=True) return True return False + def unload_font(self, name: str) -> bool: + """Unload a single font and remove it from the registry.""" + font_info = self._fonts.get(name) + if font_info is None: + return False + + runtime = get_runtime() + runtime.renderer.unload_font(font_info.font) + del self._fonts[name] + + removed_default = self._default_font_name == name + if removed_default: + self._default_font_name = None + + self._invalidate_measurement_cache({name}, include_default=removed_default) + return True + + def unload_fonts(self, names: list[str]) -> list[str]: + """Unload multiple fonts and return the names successfully removed.""" + if not names: + return [] + + runtime = get_runtime() + unloaded: list[str] = [] + removed_default = False + + for name in names: + font_info = self._fonts.get(name) + if font_info is None: + continue + + runtime.renderer.unload_font(font_info.font) + del self._fonts[name] + unloaded.append(name) + + if self._default_font_name == name: + removed_default = True + + if removed_default: + self._default_font_name = None + + self._invalidate_measurement_cache( + set(unloaded), include_default=removed_default + ) + return unloaded + + def _clear_measurement_cache(self) -> None: + self._measurement_cache.clear() + + def _get_measurement_cache_key( + self, + text: str, + font_size: float, + font_name: Optional[str], + spacing: float, + ) -> Tuple[str, str, float, float]: + return ( + font_name or "__default__", + text, + round(font_size, 4), + round(spacing, 4), + ) + + def _cache_measurement( + self, + cache_key: Tuple[str, str, float, float], + metrics: TextMetrics, + ) -> TextMetrics: + if cache_key in self._measurement_cache: + return self._measurement_cache[cache_key] + + if len(self._measurement_cache) >= self._measurement_cache_limit: + oldest_key = next(iter(self._measurement_cache)) + self._measurement_cache.pop(oldest_key, None) + + self._measurement_cache[cache_key] = metrics + return metrics + def measure_text_ex( self, text: str, @@ -214,6 +420,11 @@ def measure_text_ex( Returns: TextMetrics with width, height, and line_height """ + cache_key = self._get_measurement_cache_key(text, font_size, font_name, spacing) + cached_metrics = self._measurement_cache.get(cache_key) + if cached_metrics is not None: + return cached_metrics + runtime = get_runtime() font_info = self.get_font_info(font_name) @@ -233,7 +444,10 @@ def measure_text_ex( # Line height = font height + some padding for readability line_height = size_y + (getattr(font, "glyphPadding", 0) * scale * 2) - return TextMetrics(width=size_x, height=size_y, line_height=line_height) + return self._cache_measurement( + cache_key, + TextMetrics(width=size_x, height=size_y, line_height=line_height), + ) except Exception: # Fall back to default font if custom font fails (e.g., missing glyphs) pass @@ -245,7 +459,10 @@ def measure_text_ex( ) # Default font: use 1.2x height as standard line height line_height = size_y * 1.2 - return TextMetrics(width=size_x, height=size_y, line_height=line_height) + return self._cache_measurement( + cache_key, + TextMetrics(width=size_x, height=size_y, line_height=line_height), + ) def measure_text( self, @@ -339,6 +556,7 @@ def unload_all(self) -> None: runtime.renderer.unload_font(font_info.font) self._fonts.clear() self._default_font_name = None + self._clear_measurement_cache() # Convenience functions @@ -348,10 +566,37 @@ def get_font_manager() -> FontManager: def load_font( - name: str, path: str, base_size: int = 32, set_as_default: bool = False + name: str, + path: str, + base_size: int = 32, + set_as_default: bool = False, + texture_filter: Optional[TextureFilter] = None, + glyphs: Optional[str | tuple[int, ...]] = None, ) -> bool: """Load a font.""" - return get_font_manager().load_font(name, path, base_size, set_as_default) + return get_font_manager().load_font( + name, + path, + base_size, + set_as_default, + texture_filter, + glyphs, + ) + + +def load_fonts(font_requests: list[FontLoadRequest]) -> list[str]: + """Load multiple fonts in one call.""" + return get_font_manager().load_fonts(font_requests) + + +def unload_font(name: str) -> bool: + """Unload a single font by name.""" + return get_font_manager().unload_font(name) + + +def unload_fonts(names: list[str]) -> list[str]: + """Unload multiple fonts by name.""" + return get_font_manager().unload_fonts(names) def get_font(name: Optional[str] = None) -> Any: diff --git a/arepy_ui/core/node.py b/arepy_ui/core/node.py index 92fb0c2..156f2c8 100644 --- a/arepy_ui/core/node.py +++ b/arepy_ui/core/node.py @@ -25,8 +25,10 @@ def __init__( children: Optional[List["Node"]] = None, id: Optional[str] = None, ): + initial_style = style or Style() + self.id = id - self.style = style or Style() + self._style = initial_style self.children: List["Node"] = children or [] self.parent: Optional["Node"] = None self._manager = None # Reference to UIManager @@ -50,14 +52,48 @@ def __init__( self.pickable: bool = ( True # If False, input passes through (unless children consume it) ) + self._layout_viewport_size: Optional[tuple[float, float]] = None + self._last_layout_request: Optional[tuple[float, float, float, float]] = None + self._style._bind_owner(self) + + @property + def style(self) -> Style: + return self._style + + @style.setter + def style(self, value: Style): + self._style = value + self._style._bind_owner(self) + if hasattr(self, "computed_width"): + self.mark_dirty() def mark_dirty(self): """Mark the UI as dirty to trigger re-layout.""" if self._manager: - self._manager.mark_dirty() + if hasattr(self._manager, "mark_dirty_node"): + self._manager.mark_dirty_node(self._get_relayout_root()) + else: + self._manager.mark_dirty() elif self.parent: self.parent.mark_dirty() + def is_ancestor_of(self, node: Optional["Node"]) -> bool: + current = node + while current is not None: + if current is self: + return True + current = current.parent + return False + + def _get_relayout_root(self) -> "Node": + candidate = self.parent or self + while candidate.parent is not None and ( + candidate.style.width.type == UnitType.AUTO + or candidate.style.height.type == UnitType.AUTO + ): + candidate = candidate.parent + return candidate + def add_child(self, child: "Node"): child.parent = self # Propagate manager to new child and its descendants @@ -102,12 +138,17 @@ def calculate_layout( """ Simplified layout calculation. """ + self._set_layout_request(parent_x, parent_y, parent_width, parent_height) + # Optimization: If not visible, skip layout if not self.style.visible: self.computed_width = 0 self.computed_height = 0 return + if self.parent is None: + self._layout_viewport_size = None + # 1. Calculate own dimensions self.computed_width = self._resolve_unit(self.style.width, parent_width) self.computed_height = self._resolve_unit(self.style.height, parent_height) @@ -198,6 +239,7 @@ def calculate_layout( flex_children = [] # Only non-absolute children for child in self.children: + child._layout_viewport_size = self._layout_viewport_size # Recursive layout with available space child.calculate_layout(current_x, current_y, content_width, content_height) @@ -280,18 +322,43 @@ def calculate_layout( # Update child position based on flex alignment + margins if self.style.flex_direction == FlexDirection.COLUMN: + child_parent_x = start_x + cross_offset + child_parent_y = current_y child.computed_x = start_x + cross_offset + child_margin_left child.computed_y = current_y + child_margin_top + child._set_layout_request( + child_parent_x, + child_parent_y, + content_width, + content_height, + ) current_y += child.computed_height + self.style.gap else: + child_parent_x = current_x + child_parent_y = start_y + cross_offset child.computed_x = current_x + child_margin_left child.computed_y = start_y + cross_offset + child_margin_top + child._set_layout_request( + child_parent_x, + child_parent_y, + content_width, + content_height, + ) current_x += child.computed_width + self.style.gap # Recursively update grandchildren positions if the child moved if child.children: self._propagate_position_to_children(child) + def _set_layout_request( + self, + parent_x: float, + parent_y: float, + parent_width: float, + parent_height: float, + ) -> None: + self._last_layout_request = (parent_x, parent_y, parent_width, parent_height) + def _propagate_position_to_children(self, node: "Node"): """ Propagate position changes to children. @@ -327,46 +394,44 @@ def _propagate_position_to_children(self, node: "Node"): continue if child.style.position == PositionType.ABSOLUTE: # Handle absolute children - margin_left = self._resolve_unit( - child.style.margin.left, node.computed_width - ) - margin_top = self._resolve_unit( - child.style.margin.top, node.computed_height + margin_left = self._resolve_unit(child.style.margin.left, content_width) + margin_top = self._resolve_unit(child.style.margin.top, content_height) + child._set_layout_request( + start_x, start_y, content_width, content_height ) if child.style.left is not None: - left = self._resolve_unit(child.style.left, node.computed_width) - child.computed_x = node.computed_x + left + margin_left + left = self._resolve_unit(child.style.left, content_width) + child.computed_x = start_x + left + margin_left elif child.style.right is not None: - right = self._resolve_unit(child.style.right, node.computed_width) + right = self._resolve_unit(child.style.right, content_width) child.computed_x = ( - node.computed_x - + node.computed_width + start_x + + content_width - child.computed_width - right - margin_left ) else: - child.computed_x = node.computed_x + margin_left + child.computed_x = start_x + margin_left if child.style.top is not None: - top = self._resolve_unit(child.style.top, node.computed_height) - child.computed_y = node.computed_y + top + margin_top + top = self._resolve_unit(child.style.top, content_height) + child.computed_y = start_y + top + margin_top elif child.style.bottom is not None: - bottom = self._resolve_unit( - child.style.bottom, node.computed_height - ) + bottom = self._resolve_unit(child.style.bottom, content_height) child.computed_y = ( - node.computed_y - + node.computed_height + start_y + + content_height - child.computed_height - bottom - margin_top ) else: - child.computed_y = node.computed_y + margin_top + child.computed_y = start_y + margin_top if child.children: + child._layout_viewport_size = node._layout_viewport_size self._propagate_position_to_children(child) continue @@ -418,27 +483,50 @@ def _propagate_position_to_children(self, node: "Node"): cross_offset = (content_height - child.computed_height) / 2 if node.style.flex_direction == FlexDirection.COLUMN: + child_parent_x = start_x + cross_offset + child_parent_y = current_y child.computed_x = start_x + cross_offset + child_margin_left child.computed_y = current_y + child_margin_top + child._set_layout_request( + child_parent_x, + child_parent_y, + content_width, + content_height, + ) current_y += child.computed_height + node.style.gap else: + child_parent_x = current_x + child_parent_y = start_y + cross_offset child.computed_x = current_x + child_margin_left child.computed_y = start_y + cross_offset + child_margin_top + child._set_layout_request( + child_parent_x, + child_parent_y, + content_width, + content_height, + ) current_x += child.computed_width + node.style.gap if child.children: + child._layout_viewport_size = node._layout_viewport_size self._propagate_position_to_children(child) + def _get_layout_viewport_size(self, refresh: bool = False) -> tuple[float, float]: + if refresh or self._layout_viewport_size is None: + width, height = get_runtime().display.get_window_size() + self._layout_viewport_size = (float(width), float(height)) + return self._layout_viewport_size + def _resolve_unit(self, unit: Unit, parent_value: float) -> float: if unit.type == UnitType.PIXEL: return unit.value elif unit.type == UnitType.PERCENT: return parent_value * (unit.value / 100.0) elif unit.type == UnitType.VIEWPORT_WIDTH: - width, _ = get_runtime().display.get_window_size() + width, _ = self._get_layout_viewport_size() return width * (unit.value / 100.0) elif unit.type == UnitType.VIEWPORT_HEIGHT: - _, height = get_runtime().display.get_window_size() + _, height = self._get_layout_viewport_size() return height * (unit.value / 100.0) elif unit.type == UnitType.AUTO: # For auto, we usually default to 0 or content size. @@ -456,6 +544,15 @@ def translate(self, dx: float, dy: float): self.computed_x += dx self.computed_y += dy + if self._last_layout_request is not None: + parent_x, parent_y, parent_width, parent_height = self._last_layout_request + self._last_layout_request = ( + parent_x + dx, + parent_y + dy, + parent_width, + parent_height, + ) + for child in self.children: child.translate(dx, dy) diff --git a/arepy_ui/core/style.py b/arepy_ui/core/style.py index bc1bd85..6c26d75 100644 --- a/arepy_ui/core/style.py +++ b/arepy_ui/core/style.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Optional +from typing import Iterable, Optional from .types import ( AlignItems, @@ -9,6 +9,7 @@ JustifyContent, PositionType, Unit, + UnitType, ) @@ -19,6 +20,24 @@ class Spacing: bottom: Unit = field(default_factory=lambda: Unit.px(0)) left: Unit = field(default_factory=lambda: Unit.px(0)) + def __post_init__(self): + object.__setattr__(self, "_owner_style", None) + + def __setattr__(self, name, value): + object.__setattr__(self, name, value) + if name.startswith("_"): + return + + if "_owner_style" not in self.__dict__: + return + + owner_style = self._owner_style + if owner_style is not None: + owner_style._notify_spacing_changed() + + def _bind_style(self, style: Optional["Style"]): + object.__setattr__(self, "_owner_style", style) + @staticmethod def all(value: float) -> "Spacing": u = Unit.px(value) @@ -73,3 +92,127 @@ class Style: # Cursor cursor: Optional[CursorType] = None + + _LAYOUT_FIELDS = { + "width", + "height", + "min_width", + "max_width", + "min_height", + "max_height", + "margin", + "padding", + "flex_direction", + "justify_content", + "align_items", + "gap", + "position", + "top", + "left", + "right", + "bottom", + "visible", + } + + def __post_init__(self): + object.__setattr__(self, "_owner", None) + self._bind_spacing_owner(self.margin) + self._bind_spacing_owner(self.padding) + + def __setattr__(self, name, value): + object.__setattr__(self, name, value) + if name.startswith("_"): + return + + if "_owner" not in self.__dict__: + return + + if name in {"margin", "padding"} and isinstance(value, Spacing): + self._bind_spacing_owner(value) + + if name in self._LAYOUT_FIELDS: + self._notify_layout_changed() + + def _bind_owner(self, owner) -> None: + object.__setattr__(self, "_owner", owner) + self._bind_spacing_owner(self.margin) + self._bind_spacing_owner(self.padding) + + def _bind_spacing_owner(self, spacing: Spacing) -> None: + spacing._bind_style(self) + + def _notify_spacing_changed(self) -> None: + self._notify_layout_changed() + + def set_measured_size(self, width: float, height: float) -> bool: + width_changed = not _is_pixel_unit_value(self.width, width) + height_changed = not _is_pixel_unit_value(self.height, height) + if not width_changed and not height_changed: + return False + + if width_changed: + object.__setattr__(self, "width", Unit.px(width)) + if height_changed: + object.__setattr__(self, "height", Unit.px(height)) + + self._notify_layout_changed() + return True + + def _notify_layout_changed(self) -> None: + owner = getattr(self, "_owner", None) + if owner is not None and hasattr(owner, "mark_dirty"): + owner.mark_dirty() + + +def clone_spacing(spacing: Spacing) -> Spacing: + return Spacing( + top=spacing.top, + right=spacing.right, + bottom=spacing.bottom, + left=spacing.left, + ) + + +def _is_pixel_unit_value(unit: Unit, value: float) -> bool: + return unit.type == UnitType.PIXEL and unit.value == value + + +_DEFAULT_STYLE_REFERENCE = Style() + + +def merge_style_fields( + base_style: Style, override_style: Optional[Style], fields: Iterable[str] +) -> Style: + if override_style is None: + return base_style + + for field_name in fields: + value = getattr(override_style, field_name) + if isinstance(value, Spacing): + value = clone_spacing(value) + setattr(base_style, field_name, value) + + return base_style + + +def merge_non_default_style_fields( + base_style: Style, + override_style: Optional[Style], + fields: Iterable[str], + default_style: Optional[Style] = None, +) -> Style: + if override_style is None: + return base_style + + reference_style = default_style or _DEFAULT_STYLE_REFERENCE + + for field_name in fields: + value = getattr(override_style, field_name) + reference_value = getattr(reference_style, field_name) + if value == reference_value: + continue + if isinstance(value, Spacing): + value = clone_spacing(value) + setattr(base_style, field_name, value) + + return base_style diff --git a/arepy_ui/core/timers.py b/arepy_ui/core/timers.py new file mode 100644 index 0000000..c7c4702 --- /dev/null +++ b/arepy_ui/core/timers.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import Callable, TypeAlias + +TimerCallback: TypeAlias = Callable[[], bool | None] + + +class Timer: + """A scheduled callback managed by Timers.""" + + def __init__( + self, + interval: float, + callback: TimerCallback, + repeat: bool = False, + ) -> None: + if interval < 0.0: + raise ValueError("Timer interval must be >= 0.") + + self.interval = interval + self.callback = callback + self.repeat = repeat + self.elapsed = 0.0 + self.active = True + self.paused = False + + def cancel(self) -> None: + self.active = False + + def pause(self) -> None: + self.paused = True + + def resume(self) -> None: + self.paused = False + + def restart(self) -> None: + self.elapsed = 0.0 + self.active = True + self.paused = False + + def update(self, dt: float) -> bool: + if not self.active or self.paused: + return self.active + + if self.interval == 0.0: + keep_running = self.callback() is not False + if self.repeat and keep_running: + return True + self.active = False + return False + + self.elapsed += dt + + if not self.repeat: + if self.elapsed < self.interval: + return True + self.active = False + self.callback() + return False + + while self.elapsed >= self.interval and self.active and not self.paused: + self.elapsed -= self.interval + if self.callback() is False: + self.active = False + + return self.active + + +class Timers: + """Scheduler for one-shot and repeating timers.""" + + def __init__(self) -> None: + self._timers: list[Timer] = [] + self._queued_timers: list[Timer] = [] + self._is_updating = False + + @property + def timers(self) -> tuple[Timer, ...]: + return tuple(self._timers + self._queued_timers) + + def __len__(self) -> int: + return len(self._timers) + + def clear(self) -> None: + for timer in self._timers: + timer.cancel() + for timer in self._queued_timers: + timer.cancel() + self._timers.clear() + self._queued_timers.clear() + + def after(self, delay: float, callback: TimerCallback) -> Timer: + timer = Timer(interval=delay, callback=callback, repeat=False) + if self._is_updating: + self._queued_timers.append(timer) + else: + self._timers.append(timer) + return timer + + def every(self, interval: float, callback: TimerCallback) -> Timer: + timer = Timer(interval=interval, callback=callback, repeat=True) + if self._is_updating: + self._queued_timers.append(timer) + else: + self._timers.append(timer) + return timer + + def update(self, dt: float) -> None: + if dt < 0.0: + raise ValueError("Timers delta time must be >= 0.") + + active: list[Timer] = [] + self._is_updating = True + try: + for timer in self._timers: + if timer.update(dt): + active.append(timer) + finally: + self._is_updating = False + + if self._queued_timers: + active.extend(self._queued_timers) + self._queued_timers.clear() + + self._timers = active diff --git a/arepy_ui/core/transitions.py b/arepy_ui/core/transitions.py index 17dfd7d..69c3cfa 100644 --- a/arepy_ui/core/transitions.py +++ b/arepy_ui/core/transitions.py @@ -1,12 +1,16 @@ +from __future__ import annotations + +from bisect import bisect_right from dataclasses import dataclass, field from enum import Enum, auto -from typing import Any, Callable, List, Optional, Tuple, Union +from typing import Any, Callable, Optional, TypeAlias from arepy.engine.renderer import Rect from ..runtime import get_runtime -from .animation import apply_easing +from .animation import Animator, _copy_value, _write_property, apply_easing from .easing import Easing +from .timers import Timer, Timers from .types import Color @@ -24,7 +28,7 @@ class KeyFrame: """A single keyframe in an animation timeline.""" time: float # Time in seconds when this keyframe should be reached - value: float + value: Any easing: int = Easing.EASE_OUT_CUBIC @@ -34,67 +38,86 @@ class PropertyAnimation: target: Any property_name: str - keyframes: List[KeyFrame] + keyframes: list[KeyFrame] _current_keyframe_index: int = field(default=0, init=False) _elapsed: float = field(default=0.0, init=False) _is_finished: bool = field(default=False, init=False) + _animator: Animator = field(default_factory=Animator, init=False, repr=False) + + def __post_init__(self) -> None: + self.keyframes.sort(key=lambda keyframe: keyframe.time) + self.reset() def update(self, dt: float) -> bool: """Update animation. Returns True if still running.""" + if dt < 0.0: + raise ValueError("PropertyAnimation delta time must be >= 0.") + if self._is_finished: return False self._elapsed += dt + self._current_keyframe_index = self._resolve_current_keyframe_index( + self._elapsed + ) + self._animator.update(dt) - # Find current keyframe segment - if self._current_keyframe_index >= len(self.keyframes) - 1: + if len(self._animator.animations) == 0: self._is_finished = True + if self.keyframes: + self._current_keyframe_index = len(self.keyframes) - 1 return False - start_kf = self.keyframes[self._current_keyframe_index] - end_kf = self.keyframes[self._current_keyframe_index + 1] + return True - # Progress through current segment - segment_duration = end_kf.time - start_kf.time - if segment_duration <= 0: - self._current_keyframe_index += 1 - return True + def _resolve_current_keyframe_index(self, elapsed: float) -> int: + if not self.keyframes: + return 0 - segment_elapsed = self._elapsed - start_kf.time - t = min(segment_elapsed / segment_duration, 1.0) - eased_t = apply_easing(t, end_kf.easing) + times = [keyframe.time for keyframe in self.keyframes] + index = bisect_right(times, elapsed) - 1 + return max(0, min(index, len(self.keyframes) - 1)) - # Interpolate value - current_value = start_kf.value + (end_kf.value - start_kf.value) * eased_t + def reset(self) -> None: + """Reset the animation to the beginning.""" + self._animator.clear() + self._current_keyframe_index = 0 + self._elapsed = 0.0 - # Set property - self._set_property(current_value) + if not self.keyframes: + self._is_finished = True + return - # Check if we need to move to next keyframe - if t >= 1.0: - self._current_keyframe_index += 1 - if self._current_keyframe_index >= len(self.keyframes) - 1: - self._is_finished = True - self._set_property(self.keyframes[-1].value) # Ensure final value + first_keyframe = self.keyframes[0] + _write_property( + self.target, + self.property_name, + _copy_value(first_keyframe.value), + ) - return True + if len(self.keyframes) == 1: + self._is_finished = True + return - def _set_property(self, value: float) -> None: - """Set the property value on the target.""" - if "." in self.property_name: - obj = self.target - parts = self.property_name.split(".") - for part in parts[:-1]: - obj = getattr(obj, part) - setattr(obj, parts[-1], value) - else: - setattr(self.target, self.property_name, value) + animation = self._animator.create() + previous_keyframe = first_keyframe - def reset(self) -> None: - """Reset the animation to the beginning.""" - self._current_keyframe_index = 0 - self._elapsed = 0.0 + if previous_keyframe.time > 0.0: + animation.wait(previous_keyframe.time) + + for keyframe in self.keyframes[1:]: + segment_duration = max(0.0, keyframe.time - previous_keyframe.time) + animation.to( + self.target, + self.property_name, + _copy_value(keyframe.value), + segment_duration, + keyframe.easing, + ) + previous_keyframe = keyframe + + animation.start() self._is_finished = False @@ -119,21 +142,22 @@ class Timeline: auto_start: bool = True on_complete: Optional[Callable[[], None]] = None - _animations: List[PropertyAnimation] = field(default_factory=list) - _events: List[TimelineEvent] = field(default_factory=list) + _animations: list[PropertyAnimation] = field(default_factory=list) + _events: list[TimelineEvent] = field(default_factory=list) _elapsed: float = 0.0 _is_running: bool = False _is_finished: bool = False + _timers: Timers = field(default_factory=Timers, init=False, repr=False) def __post_init__(self): if self.auto_start: - self._is_running = True + self.start() def add_animation( self, target: Any, property_name: str, - keyframes: List[Tuple[float, float, Optional[int]]], + keyframes: list[tuple[float, Any, int | None]], ) -> "Timeline": """ Add an animation to the timeline. @@ -147,7 +171,11 @@ def add_animation( Self for chaining """ kfs = [ - KeyFrame(time=t, value=v, easing=e or Easing.EASE_OUT_CUBIC) + KeyFrame( + time=t, + value=v, + easing=Easing.EASE_OUT_CUBIC if e is None else e, + ) for t, v, e in keyframes ] @@ -164,49 +192,63 @@ def add_animation( def add_event(self, time: float, callback: Callable[[], None]) -> "Timeline": """Add a callback event at a specific time.""" - self._events.append(TimelineEvent(time=time, callback=callback)) + event = TimelineEvent(time=time, callback=callback) + self._events.append(event) + if self._is_running and not self._is_finished: + self._schedule_event(event) return self + def _schedule_event(self, event: TimelineEvent) -> None: + delay = max(0.0, event.time - self._elapsed) + self._timers.after(delay, lambda event=event: self._fire_event(event)) + def start(self) -> None: """Start the timeline.""" self._is_running = True self._is_finished = False self._elapsed = 0.0 + self._timers.clear() - # Reset all animations for anim in self._animations: anim.reset() - # Reset events for event in self._events: event._triggered = False + self._schedule_event(event) + + def _fire_event(self, event: TimelineEvent) -> None: + if event._triggered or not self._is_running or self._is_finished: + return + event._triggered = True + event.callback() def pause(self) -> None: """Pause the timeline.""" self._is_running = False + for timer in self._timers.timers: + timer.pause() def resume(self) -> None: """Resume the timeline.""" self._is_running = True + for timer in self._timers.timers: + timer.resume() def update(self, dt: float) -> bool: """Update the timeline. Returns True if still running.""" + if dt < 0.0: + raise ValueError("Timeline delta time must be >= 0.") + if not self._is_running or self._is_finished: return False self._elapsed += dt - # Update animations for anim in self._animations: anim.update(dt) - # Trigger events - for event in self._events: - if not event._triggered and self._elapsed >= event.time: - event._triggered = True - event.callback() + self._timers.update(dt) - # Check completion if self._elapsed >= self.duration: if self.loop: self.start() @@ -259,12 +301,26 @@ def __init__( self.on_complete = on_complete self._elapsed = 0.0 - self._is_running = True + self._is_running = False + self._is_finished = False + self._current_radius = self.start_radius + self.start() + + def reset(self) -> None: + self._elapsed = 0.0 + self._is_running = False self._is_finished = False self._current_radius = self.start_radius + def start(self) -> None: + self.reset() + self._is_running = True + def update(self, dt: float) -> bool: """Update the reveal. Returns True if still running.""" + if dt < 0.0: + raise ValueError("CircleReveal delta time must be >= 0.") + if not self._is_running or self._is_finished: return False @@ -357,11 +413,25 @@ def __init__( self.on_complete = on_complete self._elapsed = 0.0 - self._is_running = True + self._is_running = False self._is_finished = False self._alpha = 255 if fade_in else 0 + self.start() + + def reset(self) -> None: + self._elapsed = 0.0 + self._is_running = False + self._is_finished = False + self._alpha = 255 if self.fade_in else 0 + + def start(self) -> None: + self.reset() + self._is_running = True def update(self, dt: float) -> bool: + if dt < 0.0: + raise ValueError("FadeTransition delta time must be >= 0.") + if not self._is_running or self._is_finished: return False @@ -405,85 +475,113 @@ class SequenceRunner: """ def __init__(self, on_complete: Optional[Callable[[], None]] = None): - self._sequences: List[ - Tuple[ - Union[Timeline, CircleReveal, FadeTransition, Callable[[], None]], float - ] - ] = [] + self._sequences: list[tuple[SequenceItem, float]] = [] self._current_index = 0 - self._delay_timer = 0.0 + self._delay_timer: Timer | None = None + self._timers = Timers() + self._current_item_started = False + self._started_this_frame = False self._is_running = False self._is_finished = False self.on_complete = on_complete def add( self, - item: Union[Timeline, CircleReveal, FadeTransition, Callable[[], None]], + item: SequenceItem, delay: float = 0.0, ) -> "SequenceRunner": """Add an item to the sequence with optional delay before it.""" + if delay < 0.0: + raise ValueError("SequenceRunner delay must be >= 0.") self._sequences.append((item, delay)) return self def start(self) -> None: """Start running the sequence.""" self._current_index = 0 - self._delay_timer = 0.0 + self._delay_timer = None + self._timers.clear() + self._current_item_started = False + self._started_this_frame = False self._is_running = True self._is_finished = False + if not self._sequences: + self._finish() + return + self._schedule_current_item() + + def _finish(self) -> None: + self._timers.clear() + self._delay_timer = None + self._current_item_started = False + self._started_this_frame = False + self._is_finished = True + self._is_running = False + if self.on_complete: + self.on_complete() + + def _schedule_current_item(self) -> None: + if self._current_index >= len(self._sequences): + self._finish() + return + + _, delay = self._sequences[self._current_index] + self._current_item_started = False - # Start first item if no delay - if self._sequences and self._sequences[0][1] <= 0: + if delay <= 0.0: self._start_current_item() + return + + self._delay_timer = self._timers.after(delay, self._start_current_item) def _start_current_item(self) -> None: """Start the current sequence item.""" + self._delay_timer = None if self._current_index >= len(self._sequences): return item, _ = self._sequences[self._current_index] + self._current_item_started = True + self._started_this_frame = True - if callable(item) and not isinstance( - item, (Timeline, CircleReveal, FadeTransition) - ): - # It's a callback + if isinstance(item, (Timeline, CircleReveal, FadeTransition)): + item.start() + return + + if callable(item): item() self._advance() - elif isinstance(item, Timeline): - item.start() - # CircleReveal and FadeTransition auto-start def _advance(self) -> None: """Move to next item in sequence.""" self._current_index += 1 - self._delay_timer = 0.0 if self._current_index >= len(self._sequences): - self._is_finished = True - self._is_running = False - if self.on_complete: - self.on_complete() + self._finish() + return + + self._schedule_current_item() def update(self, dt: float) -> bool: """Update the sequence. Returns True if still running.""" + if dt < 0.0: + raise ValueError("SequenceRunner delta time must be >= 0.") + if not self._is_running or self._is_finished: return False + self._started_this_frame = False + self._timers.update(dt) + if self._current_index >= len(self._sequences): - self._is_finished = True - self._is_running = False + self._finish() return False - item, delay = self._sequences[self._current_index] - - # Handle delay - if self._delay_timer < delay: - self._delay_timer += dt - if self._delay_timer >= delay: - self._start_current_item() + if not self._current_item_started or self._started_this_frame: return True - # Update current item + item, _ = self._sequences[self._current_index] + if isinstance(item, (Timeline, CircleReveal, FadeTransition)): still_running = item.update(dt) if not still_running: @@ -499,9 +597,9 @@ def render(self) -> None: if self._current_index >= len(self._sequences): return - item, delay = self._sequences[self._current_index] + item, _ = self._sequences[self._current_index] - if self._delay_timer < delay: + if not self._current_item_started: return if isinstance(item, (CircleReveal, FadeTransition)): @@ -510,3 +608,6 @@ def render(self) -> None: @property def is_finished(self) -> bool: return self._is_finished + + +SequenceItem: TypeAlias = Timeline | CircleReveal | FadeTransition | Callable[[], None] diff --git a/arepy_ui/debug.py b/arepy_ui/debug.py index b25e6b9..556ce61 100644 --- a/arepy_ui/debug.py +++ b/arepy_ui/debug.py @@ -1,7 +1,9 @@ +from dataclasses import dataclass from typing import Optional, cast from arepy.engine.renderer import Rect +from .components.scroll import ScrollView from .components.text import Text from .core.node import Node from .core.style import Spacing @@ -10,6 +12,14 @@ from .runtime import get_runtime +@dataclass +class DebugFrameNode: + node: Node + depth: int + rect: tuple[int, int, int, int] + visible_rect: Optional[tuple[int, int, int, int]] + + class UIDebugger: """Visual debugger for UI layout with detailed component inspection.""" @@ -35,10 +45,33 @@ def __init__(self): self.show_padding = False self.show_info = True self.show_tree = False + self.toggle_hotkey_label = "F3" + self.bounds_hotkey_label = "F4" + self.padding_hotkey_label = "F5" + self.tree_hotkey_label = "F6" self.hovered_node: Optional[Node] = None self._font_size = 11 self._line_height = 14 self._tree_scroll = 0 + self._frame_nodes: list[DebugFrameNode] = [] + self._frame_index: dict[Node, DebugFrameNode] = {} + self._color_cache: dict[str, Color] = {} + + def set_hotkey_labels( + self, + toggle: Optional[str] = None, + bounds: Optional[str] = None, + padding: Optional[str] = None, + tree: Optional[str] = None, + ): + if toggle is not None: + self.toggle_hotkey_label = toggle + if bounds is not None: + self.bounds_hotkey_label = bounds + if padding is not None: + self.padding_hotkey_label = padding + if tree is not None: + self.tree_hotkey_label = tree def toggle(self): """Toggle debug overlay on/off.""" @@ -63,10 +96,11 @@ def render(self, root: Optional[Node]): runtime = get_runtime() mx, my = runtime.input.get_mouse_position() - self.hovered_node = self._find_node_at(root, mx, my) + self._build_frame(root) + self.hovered_node = self._find_node_at(mx, my) if self.show_bounds: - self._render_node_debug(root, depth=0) + self._render_node_debug(runtime) if self.hovered_node and self.show_info: self._render_node_info(self.hovered_node, mx, my) @@ -80,65 +114,154 @@ def _get_component_color(self, node: Node) -> Color: """Get color based on component type.""" class_name = node.__class__.__name__ + cached = self._color_cache.get(class_name) + if cached is not None: + return cached + meta = get_registry().get(class_name) if meta: - return meta.get_debug_color(180) - return Color(150, 150, 150, 180) - - def _find_node_at(self, node: Node, x: float, y: float) -> Optional[Node]: - """Find the deepest node at the given position.""" + color = meta.get_debug_color(180) + else: + color = Color(150, 150, 150, 180) + + self._color_cache[class_name] = color + return color + + def _build_frame(self, root: Node) -> None: + self._frame_nodes = [] + self._frame_index = {} + self._collect_frame_nodes(root, depth=0) + + def _collect_frame_nodes( + self, + node: Node, + depth: int, + offset_x: float = 0.0, + offset_y: float = 0.0, + clip_rect: Optional[tuple[float, float, float, float]] = None, + ) -> None: if not node.style.visible: + return + + rect = ( + node.computed_x + offset_x, + node.computed_y + offset_y, + node.computed_width, + node.computed_height, + ) + visible_rect = self._intersect_rect(rect, clip_rect) + + frame_node = DebugFrameNode( + node=node, + depth=depth, + rect=self._rect_to_int_tuple(rect), + visible_rect=( + self._rect_to_int_tuple(visible_rect) + if visible_rect is not None + else None + ), + ) + self._frame_nodes.append(frame_node) + self._frame_index[node] = frame_node + + next_clip = clip_rect + scroll_clip = self._intersect_rect(rect, clip_rect) + + for child in node.children: + child_offset_x = offset_x + child_offset_y = offset_y + child_clip = next_clip + + if isinstance(node, ScrollView) and child is node.content: + child_offset_y += node.scroll_y + child_clip = scroll_clip + + self._collect_frame_nodes( + child, + depth + 1, + child_offset_x, + child_offset_y, + child_clip, + ) + + def _intersect_rect( + self, + rect: tuple[float, float, float, float], + clip_rect: Optional[tuple[float, float, float, float]], + ) -> Optional[tuple[float, float, float, float]]: + x, y, w, h = rect + if w <= 0 or h <= 0: return None - if not ( - node.computed_x <= x < node.computed_x + node.computed_width - and node.computed_y <= y < node.computed_y + node.computed_height - ): + if clip_rect is None: + return rect + + clip_x, clip_y, clip_w, clip_h = clip_rect + left = max(x, clip_x) + top = max(y, clip_y) + right = min(x + w, clip_x + clip_w) + bottom = min(y + h, clip_y + clip_h) + + width = right - left + height = bottom - top + if width <= 0 or height <= 0: return None - for child in reversed(node.children): - result = self._find_node_at(child, x, y) - if result: - return result + return (left, top, width, height) - return node + def _rect_to_int_tuple( + self, rect: tuple[float, float, float, float] + ) -> tuple[int, int, int, int]: + x, y, w, h = rect + return (int(x), int(y), int(w), int(h)) - def _render_node_debug(self, node: Node, depth: int): - """Recursively render debug info for a node.""" - if not node.style.visible: - return + def _find_node_at(self, x: float, y: float) -> Optional[Node]: + """Find the deepest visible node at the given screen position.""" + for frame_node in reversed(self._frame_nodes): + visible_rect = frame_node.visible_rect + if visible_rect is None: + continue - runtime = get_runtime() - x, y = int(node.computed_x), int(node.computed_y) - w, h = int(node.computed_width), int(node.computed_height) + rect_x, rect_y, rect_w, rect_h = visible_rect + if rect_x <= x < rect_x + rect_w and rect_y <= y < rect_y + rect_h: + return frame_node.node - if w <= 0 or h <= 0: - return + return None - color = self._get_component_color(node) - border_color = Color(color.r, color.g, color.b, 200) + def _render_node_debug(self, runtime): + """Render debug bounds for the current visual frame.""" + for frame_node in self._frame_nodes: + visible_rect = frame_node.visible_rect + if visible_rect is None: + continue - if self.show_padding and node.style.padding: - self._render_padding(node, runtime) + node = frame_node.node + x, y, w, h = visible_rect + if w <= 0 or h <= 0: + continue - runtime.renderer.draw_rectangle_lines_ex(Rect(x, y, w, h), 1, border_color) # type: ignore + color = self._get_component_color(node) + border_color = Color(color.r, color.g, color.b, 200) - if node == self.hovered_node: - highlight = Color(255, 255, 100, 50) - runtime.renderer.draw_rectangle(Rect(x, y, w, h), highlight) # type: ignore - runtime.renderer.draw_rectangle_lines_ex( - Rect(x, y, w, h), - 2, - self.ACCENT_YELLOW, # type: ignore - ) + if self.show_padding and node.style.padding: + self._render_padding(node, runtime, frame_node) - for child in node.children: - self._render_node_debug(child, depth + 1) + runtime.renderer.draw_rectangle_lines_ex( + Rect(x, y, w, h), 1, border_color + ) # type: ignore + + if node == self.hovered_node: + highlight = Color(255, 255, 100, 50) + runtime.renderer.draw_rectangle(Rect(x, y, w, h), highlight) # type: ignore + runtime.renderer.draw_rectangle_lines_ex( + Rect(x, y, w, h), + 2, + self.ACCENT_YELLOW, # type: ignore + ) - def _render_padding(self, node: Node, runtime): + def _render_padding(self, node: Node, runtime, frame_node: DebugFrameNode): """Render padding visualization.""" - x, y = int(node.computed_x), int(node.computed_y) - w, h = int(node.computed_width), int(node.computed_height) + x, y, w, h = frame_node.rect p = node.style.padding if not p: @@ -168,18 +291,28 @@ def _render_node_info(self, node: Node, mouse_x: float, mouse_y: float): """Render detailed info panel for a node.""" runtime = get_runtime() class_name = node.__class__.__name__ + frame_node = self._frame_index.get(node) sections = [] - header = [f"◆ {class_name}"] + header = [f"> {class_name}"] if hasattr(node, "id") and node.id: header.append(f" #{node.id}") sections.append(("header", header)) layout_info = [ - f"Position: ({node.computed_x:.0f}, {node.computed_y:.0f})", - f"Size: {node.computed_width:.0f} × {node.computed_height:.0f}", + f"Layout: ({node.computed_x:.0f}, {node.computed_y:.0f})", + f"Size: {node.computed_width:.0f} x {node.computed_height:.0f}", ] + if frame_node is not None: + screen_x, screen_y, _, _ = frame_node.rect + layout_info.insert(0, f"Screen: ({screen_x}, {screen_y})") + if ( + frame_node.visible_rect is not None + and frame_node.visible_rect != frame_node.rect + ): + _, _, visible_w, visible_h = frame_node.visible_rect + layout_info.append(f"Visible: {visible_w} x {visible_h}") sections.append(("Layout", layout_info)) style_info = [] @@ -266,7 +399,7 @@ def _get_component_props(self, node: Node) -> list: elif class_name == "Video": state = getattr(node, "_state", None) if state is not None: - props.append(f"state: {state.name}") + props.append(f"state: {self._format_debug_value(state)}") duration = getattr(node, "_duration", None) if duration is not None: props.append(f"duration: {duration:.1f}s") @@ -286,6 +419,18 @@ def _get_component_props(self, node: Node) -> list: return props + def _format_debug_value(self, value) -> str: + """Format debug values safely for display.""" + if hasattr(value, "name"): + return str(value.name) + return str(value) + + def _measure_text_width(self, runtime, text: str, font_size: int) -> int: + try: + return int(runtime.renderer.measure_text(text, font_size)) + except Exception: + return len(text) * 7 + def _format_unit(self, unit: Unit) -> str: """Format a Unit value for display.""" if unit.type.name == "PX": @@ -328,7 +473,7 @@ def _render_info_panel( total_height += section_gap for line in lines: - text_width = len(line) * 7 + text_width = self._measure_text_width(runtime, line, self._font_size) max_width = max(max_width, text_width) panel_w = max_width + padding * 2 + 10 @@ -361,7 +506,7 @@ def _render_info_panel( for line in lines: color = ( self.ACCENT_BLUE - if line.startswith("◆") + if line.startswith(">") else self.TEXT_SECONDARY ) runtime.renderer.draw_text( @@ -422,19 +567,28 @@ def _render_tree_view(self, root: Node): self.TEXT_PRIMARY, # type: ignore ) - self._render_tree_node( - root, panel_x + 8, panel_y + 32, 0, panel_w - 16, runtime - ) - - def _render_tree_node( - self, node: Node, x: int, y: int, depth: int, max_width: int, runtime - ) -> int: - """Render a single tree node and return the next y position.""" - if y > runtime.display.get_window_size()[1] - 50: - return y - + self._clamp_tree_scroll(panel_h) + + start_y = panel_y + 32 - self._tree_scroll + for index, frame_node in enumerate(self._frame_nodes): + row_y = start_y + index * self._line_height + if row_y < panel_y + 26: + continue + if row_y > panel_y + panel_h - self._line_height: + break + self._render_tree_row(frame_node, panel_x + 8, row_y, panel_w - 16, runtime) + + def _clamp_tree_scroll(self, panel_h: int) -> None: + content_height = len(self._frame_nodes) * self._line_height + max_scroll = max(0, content_height - max(0, panel_h - 40)) + self._tree_scroll = max(0, min(self._tree_scroll, max_scroll)) + + def _render_tree_row( + self, frame_node: DebugFrameNode, x: int, y: int, max_width: int, runtime + ) -> None: + node = frame_node.node class_name = node.__class__.__name__ - indent = depth * 12 + indent = frame_node.depth * 12 is_hovered = node == self.hovered_node @@ -463,13 +617,6 @@ def _render_tree_node( text_color, ) - y += self._line_height - - for child in node.children: - y = self._render_tree_node(child, x, y, depth + 1, max_width, runtime) - - return y - def _render_toolbar(self): """Render toolbar at top of screen.""" runtime = get_runtime() @@ -482,10 +629,18 @@ def _render_toolbar(self): ) items = [ - ("[F3] Debug", self.enabled, self.ACCENT_GREEN), - ("[F4] Bounds", self.show_bounds, self.ACCENT_BLUE), - ("[F5] Padding", self.show_padding, self.ACCENT_PURPLE), - ("[F6] Tree", self.show_tree, self.ACCENT_ORANGE), + (f"[{self.toggle_hotkey_label}] Debug", self.enabled, self.ACCENT_GREEN), + ( + f"[{self.bounds_hotkey_label}] Bounds", + self.show_bounds, + self.ACCENT_BLUE, + ), + ( + f"[{self.padding_hotkey_label}] Padding", + self.show_padding, + self.ACCENT_PURPLE, + ), + (f"[{self.tree_hotkey_label}] Tree", self.show_tree, self.ACCENT_ORANGE), ] x = 10 @@ -495,12 +650,16 @@ def _render_toolbar(self): x += len(text) * 7 + 20 if self.hovered_node: - info = f"Hovering: {self.hovered_node.__class__.__name__}" + info = f"Hover: {self.hovered_node.__class__.__name__} | Nodes: {len(self._frame_nodes)}" if hasattr(self.hovered_node, "id") and self.hovered_node.id: info += f" #{self.hovered_node.id}" - runtime.renderer.draw_text( - info, - (screen_w - len(info) * 7 - 10, 7), - self._font_size, - self.ACCENT_YELLOW, # type: ignore - ) + else: + info = f"Nodes: {len(self._frame_nodes)}" + + info_width = self._measure_text_width(runtime, info, self._font_size) + runtime.renderer.draw_text( + info, + (screen_w - info_width - 10, 7), + self._font_size, + self.ACCENT_YELLOW if self.hovered_node else self.TEXT_SECONDARY, # type: ignore + ) diff --git a/arepy_ui/manager.py b/arepy_ui/manager.py index 3c4d883..7c03675 100644 --- a/arepy_ui/manager.py +++ b/arepy_ui/manager.py @@ -1,13 +1,25 @@ import os from typing import TYPE_CHECKING, Callable, List, Optional +from arepy import TextureFilter +from arepy.asset_store.asset_store import AssetStore +from arepy.ecs.systems import SystemPipeline +from arepy.ecs.world import World +from arepy.engine.audio import AudioDevice +from arepy.engine.display import Display +from arepy.engine.input import Input, Key +from arepy.engine.renderer.renderer_2d import Renderer2D +from arepy.engine.time import Time + +from .components.scroll import ScrollView from .config import ResizeMode, ScaleTransform, UIConfig, calculate_scale_transform from .core.animation import Animator from .core.fonts import get_font_manager from .core.node import Node +from .core.timers import Timer, Timers from .core.types import Color, CursorType, Vector2 +from .debug import UIDebugger from .runtime import MouseButton, configure_runtime, get_runtime -from arepy import TextureFilter if TYPE_CHECKING: from arepy import ArepyEngine @@ -26,12 +38,23 @@ def clear_overlays(): _overlay_renders.clear() +def _world_update_system(ui_manager: "UIManager", time: Time, input: Input) -> None: + """Update system registered by UIManager.install().""" + ui_manager.update_system(time, input) + + +def _world_render_system(ui_manager: "UIManager") -> None: + """Render system registered by UIManager.install().""" + ui_manager.render_system() + + class UIManager: """Main UI manager that handles layout, input, and rendering.""" def __init__(self, config: Optional[UIConfig] = None): self.root: Optional[Node] = None self.animator = Animator() + self.timers = Timers() self.config = config or UIConfig() runtime = get_runtime() @@ -46,14 +69,14 @@ def __init__(self, config: Optional[UIConfig] = None): self.screen_width = width self.screen_height = height self.is_dirty = True + self._dirty_layout_root: Optional[Node] = None self.is_input_captured = False # Scale transform for non-responsive modes self.scale_transform = ScaleTransform() # Debounce timer - self._resize_debounce_timer = 0.0 - self._pending_resize = False + self._resize_timer: Optional[Timer] = None # Cursor management self._current_cursor = CursorType.DEFAULT @@ -62,7 +85,7 @@ def __init__(self, config: Optional[UIConfig] = None): # Tooltip system self._tooltip_text: Optional[str] = None self._tooltip_delay = 0.5 # seconds before showing - self._tooltip_timer = 0.0 + self._tooltip_timer_handle: Optional[Timer] = None self._tooltip_visible = False self._tooltip_pos = Vector2(0, 0) @@ -80,7 +103,10 @@ def __init__(self, config: Optional[UIConfig] = None): # This value is used by `set_font_scale` to scale nodes that expose a `font_size`. self._font_scale: float = 1.0 - + # Integrated debugger + self.debugger = UIDebugger() + self.debugger.enabled = self.config.debug_enabled + self._sync_debugger_hotkey_labels() @classmethod def from_engine( @@ -102,6 +128,10 @@ def from_engine( Example: ui_manager = UIManager.from_engine(game) ui_manager.set_root(create_ui()) + + For world-based setups, prefer UIManager.install(world, ...) so the + manager is configured, registered as a world resource, and hooked into + the update/render pipelines in one call. """ # Configure the runtime with engine components configure_runtime( @@ -114,6 +144,55 @@ def from_engine( return cls(config=config) + @classmethod + def from_world(cls, world: World, config: Optional[UIConfig] = None) -> "UIManager": + """Create a UIManager from an arepy World and configure runtime services.""" + try: + renderer = world.get_resource(Renderer2D) + input_device = world.get_resource(Input) + display = world.get_resource(Display) + except KeyError as exc: + raise RuntimeError( + "UIManager.from_world() requires a World created by ArepyEngine with Renderer2D, Input, and Display resources." + ) from exc + + try: + asset_store = world.get_resource(AssetStore) + except KeyError: + asset_store = None + + try: + audio_device = world.get_resource(AudioDevice) + except KeyError: + audio_device = None + + configure_runtime( + renderer=renderer, + input=input_device, + display=display, + asset_store=asset_store, + audio_device=audio_device, + ) + return cls(config=config) + + @classmethod + def install( + cls, + world: World, + root: Optional[Node] = None, + config: Optional[UIConfig] = None, + ) -> "UIManager": + """Create, register, and hook a UIManager into a world's UI pipelines.""" + manager = cls.from_world(world, config=config) + world.add_resource(manager) + + if root is not None: + manager.set_root(root) + + world.add_system(SystemPipeline.UPDATE, _world_update_system) + world.add_system(SystemPipeline.RENDER_UI, _world_render_system) + return manager + def _ensure_stencil(self): """Initialize stencil buffer if not already done.""" if self._stencil_initialized: @@ -223,14 +302,83 @@ def _apply_font_scale_to_node(self, node: Node) -> None: # Continue despite failures on particular children pass + def get_debugger(self) -> UIDebugger: + """Return the integrated UI debugger instance.""" + return self.debugger + + def enable_debug_overlay(self, enabled: bool = True) -> None: + self.debugger.enabled = enabled + self.config.debug_enabled = enabled + + def toggle_debug_overlay(self) -> None: + self.debugger.toggle() + self.config.debug_enabled = self.debugger.enabled + + def set_debug_toggle_key(self, key: Optional[Key]) -> None: + self.config.debug_toggle_key = key + self._sync_debugger_hotkey_labels() + + def set_debug_hotkeys( + self, + toggle: Optional[Key] = None, + bounds: Optional[Key] = None, + padding: Optional[Key] = None, + tree: Optional[Key] = None, + ) -> None: + self.config.debug_toggle_key = toggle + self.config.debug_bounds_key = bounds + self.config.debug_padding_key = padding + self.config.debug_tree_key = tree + self._sync_debugger_hotkey_labels() + + def _format_debug_key_label(self, key: Optional[Key]) -> str: + return getattr(key, "name", "OFF") if key is not None else "OFF" + + def _sync_debugger_hotkey_labels(self) -> None: + self.debugger.set_hotkey_labels( + toggle=self._format_debug_key_label(self.config.debug_toggle_key), + bounds=self._format_debug_key_label(self.config.debug_bounds_key), + padding=self._format_debug_key_label(self.config.debug_padding_key), + tree=self._format_debug_key_label(self.config.debug_tree_key), + ) + + def _handle_debug_shortcuts(self, runtime) -> None: + toggle_key = self.config.debug_toggle_key + if toggle_key is not None and runtime.input.is_key_pressed(toggle_key): + self.toggle_debug_overlay() + + if not self.debugger.enabled: + return + + bounds_key = self.config.debug_bounds_key + if bounds_key is not None and runtime.input.is_key_pressed(bounds_key): + self.debugger.toggle_bounds() + + padding_key = self.config.debug_padding_key + if padding_key is not None and runtime.input.is_key_pressed(padding_key): + self.debugger.toggle_padding() + + tree_key = self.config.debug_tree_key + if tree_key is not None and runtime.input.is_key_pressed(tree_key): + self.debugger.toggle_tree() + def set_root(self, node: Node): self.root = node self.is_dirty = True + self._dirty_layout_root = node if self.root: self._propagate_manager(self.root) # Calculate layout immediately to avoid 1-frame glitch self._recalculate_layout() + def update_system(self, time: Time, input: Input) -> None: + """World system adapter that updates the UI manager from injected resources.""" + self.update(time.delta_seconds, wheel_scroll=input.get_mouse_wheel_delta()) + + def render_system(self) -> None: + """World system adapter that renders the current UI tree.""" + self.render() + def _propagate_manager(self, node: Node): """Recursively set manager reference on all nodes.""" node._manager = self @@ -269,6 +417,48 @@ def clear_focus(self): def mark_dirty(self): self.is_dirty = True + self._dirty_layout_root = self.root + + def _find_common_ancestor(self, first: Node, second: Node) -> Optional[Node]: + ancestors = set() + current: Optional[Node] = first + while current is not None: + ancestors.add(current) + current = current.parent + + current = second + while current is not None: + if current in ancestors: + return current + current = current.parent + return None + + def mark_dirty_node(self, node: Optional[Node]): + self.is_dirty = True + + if self.root is None: + self._dirty_layout_root = None + return + + if node is None: + self._dirty_layout_root = self.root + return + + if self._dirty_layout_root is None: + self._dirty_layout_root = node + return + + current_root = self._dirty_layout_root + if current_root is self.root or node is self.root: + self._dirty_layout_root = self.root + elif current_root.is_ancestor_of(node): + return + elif node.is_ancestor_of(current_root): + self._dirty_layout_root = node + else: + self._dirty_layout_root = ( + self._find_common_ancestor(current_root, node) or self.root + ) def update(self, dt: float, wheel_scroll: float = 0.0): # Clear overlays from previous frame @@ -279,6 +469,7 @@ def update(self, dt: float, wheel_scroll: float = 0.0): # Handle Input runtime = get_runtime() + self._handle_debug_shortcuts(runtime) # Get mouse position, converting from screen to UI coords if needed raw_mx, raw_my = runtime.input.get_mouse_position() @@ -352,7 +543,7 @@ def update(self, dt: float, wheel_scroll: float = 0.0): self._update_cursor(mouse_pos) # Update tooltip - self._update_tooltip(mouse_pos, dt) + self._update_tooltip(mouse_pos) # Check for window resize current_w, current_h = runtime.display.get_window_size() @@ -372,27 +563,41 @@ def update(self, dt: float, wheel_scroll: float = 0.0): # Handle debounce if self.config.layout_debounce_ms > 0: - self._resize_debounce_timer = self.config.layout_debounce_ms / 1000.0 - self._pending_resize = True + self._schedule_resize_debounce() else: + self._cancel_resize_timer() self._handle_resize() - # Process debounced resize - if self._pending_resize: - self._resize_debounce_timer -= dt - if self._resize_debounce_timer <= 0: - self._pending_resize = False - self._handle_resize() + self.timers.update(dt) # Normal dirty check (for non-resize layout changes) - if self.root and self.is_dirty and not self._pending_resize: + if self.root and self.is_dirty and not self._has_pending_resize(): self._recalculate_layout() + def _has_pending_resize(self) -> bool: + return self._resize_timer is not None and self._resize_timer.active + + def _cancel_resize_timer(self) -> None: + if self._resize_timer is None: + return + self._resize_timer.cancel() + self._resize_timer = None + + def _schedule_resize_debounce(self) -> None: + self._cancel_resize_timer() + delay = self.config.layout_debounce_ms / 1000.0 + self._resize_timer = self.timers.after(delay, self._flush_debounced_resize) + + def _flush_debounced_resize(self) -> None: + self._resize_timer = None + self._handle_resize() + def _handle_resize(self): """Handle window resize based on config mode.""" if self.config.resize_mode == ResizeMode.RESPONSIVE: # Responsive: just recalculate layout with new size self.is_dirty = True + self._dirty_layout_root = self.root else: # Scale modes: calculate transform self.scale_transform = calculate_scale_transform( @@ -405,6 +610,7 @@ def _handle_resize(self): # For FIXED mode, we don't need to recalculate layout if self.config.resize_mode != ResizeMode.FIXED: self.is_dirty = True + self._dirty_layout_root = self.root def _recalculate_layout(self): """Recalculate UI layout.""" @@ -424,8 +630,18 @@ def _recalculate_layout(self): layout_w = float(self.config.reference_width or self.screen_width) layout_h = float(self.config.reference_height or self.screen_height) - self.root.calculate_layout(0, 0, layout_w, layout_h) + layout_root = self._dirty_layout_root or self.root + if layout_root is self.root: + self.root.calculate_layout(0, 0, layout_w, layout_h) + else: + layout_request = layout_root._last_layout_request + if layout_request is None: + self.root.calculate_layout(0, 0, layout_w, layout_h) + else: + layout_root.calculate_layout(*layout_request) + self.is_dirty = False + self._dirty_layout_root = None # Call after callback if self.config.on_after_layout: @@ -454,6 +670,9 @@ def render(self): # Render tooltip (always on top) self._render_tooltip() + # Render debugger overlay last + self.debugger.render(self.root) + def get_reference_size(self) -> tuple[int, int]: """Get the reference resolution used for layout.""" return ( @@ -508,7 +727,6 @@ def _find_node_with_cursor( # Adjust mouse coords for ScrollView children child_mx, child_my = mx, my - from .components.scroll import ScrollView if isinstance(node, ScrollView): child_my = my - node.scroll_y @@ -583,31 +801,43 @@ def has_modal(self) -> bool: def set_tooltip_delay(self, delay: float): """Set the delay before tooltips appear (in seconds).""" + if delay < 0.0: + raise ValueError("Tooltip delay must be >= 0.") self._tooltip_delay = delay - def _update_tooltip(self, mouse_pos: Vector2, dt: float): + def _cancel_tooltip_timer(self) -> None: + if self._tooltip_timer_handle is None: + return + self._tooltip_timer_handle.cancel() + self._tooltip_timer_handle = None + + def _show_tooltip(self, tooltip: str) -> None: + self._tooltip_timer_handle = None + if self._tooltip_text == tooltip: + self._tooltip_visible = True + + def _update_tooltip(self, mouse_pos: Vector2): """Update tooltip state based on hovered node.""" - # Check if hovered node has tooltip tooltip = None if self._hovered_node: tooltip = getattr(self._hovered_node, "tooltip", None) if tooltip: + self._tooltip_pos = mouse_pos if tooltip != self._tooltip_text: - # New tooltip, reset timer self._tooltip_text = tooltip - self._tooltip_timer = 0.0 self._tooltip_visible = False - else: - # Same tooltip, increment timer - self._tooltip_timer += dt - if self._tooltip_timer >= self._tooltip_delay: - self._tooltip_visible = True - self._tooltip_pos = mouse_pos + self._cancel_tooltip_timer() + if self._tooltip_delay == 0.0: + self._show_tooltip(tooltip) + else: + self._tooltip_timer_handle = self.timers.after( + self._tooltip_delay, + lambda tooltip_text=tooltip: self._show_tooltip(tooltip_text), + ) else: - # No tooltip + self._cancel_tooltip_timer() self._tooltip_text = None - self._tooltip_timer = 0.0 self._tooltip_visible = False def _render_tooltip(self): diff --git a/arepy_ui/markup/builder.py b/arepy_ui/markup/builder.py index c38bdc5..67fe42c 100644 --- a/arepy_ui/markup/builder.py +++ b/arepy_ui/markup/builder.py @@ -4,7 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional +from collections import OrderedDict +from copy import copy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Sequence from arepy_ui.core.style import Style from arepy_ui.markup.converters import ( @@ -61,11 +64,344 @@ "z_index": convert_to_int, } +_MAX_RESOLVED_STYLE_CACHE_ENTRIES = 256 +_MAX_INTERACTION_COLOR_CACHE_ENTRIES = 256 +_MAX_TAG_ATTRIBUTE_PLAN_CACHE_ENTRIES = 256 +_MAX_COMPILED_NODE_PLAN_CACHE_ENTRIES = 1024 + + +@dataclass(slots=True) +class _TagAttributePlan: + static_kwargs: Dict[str, Any] + handler_bindings: tuple[tuple[tuple[str, ...], str, bool], ...] + needs_interaction_colors: bool + + +@dataclass(slots=True) +class _CompiledNodePlan: + node: AUINode + component_name: Optional[str] + child_plans: tuple["_CompiledNodePlan", ...] + is_known_tag: bool + is_scroll: bool + + +_RESOLVED_STYLE_CACHE: OrderedDict[tuple[object, ...], Dict[str, Any]] = OrderedDict() +_INTERACTION_COLOR_CACHE: OrderedDict[tuple[object, ...], Dict[str, Any]] = ( + OrderedDict() +) +_TAG_ATTRIBUTE_PLAN_CACHE: OrderedDict[tuple[object, ...], _TagAttributePlan] = ( + OrderedDict() +) +_COMPILED_NODE_PLAN_CACHE: OrderedDict[int, _CompiledNodePlan] = OrderedDict() + +_NOOP_HANDLER = lambda: None + + +def _cache_get( + cache: OrderedDict[tuple[object, ...], Dict[str, Any]], + key: tuple[object, ...], +) -> Optional[Dict[str, Any]]: + value = cache.get(key) + if value is None: + return None + cache.move_to_end(key) + return value + + +def _cache_put( + cache: OrderedDict[tuple[object, ...], Dict[str, Any]], + key: tuple[object, ...], + value: Dict[str, Any], + max_entries: int, +) -> None: + cache[key] = value + cache.move_to_end(key) + if len(cache) > max_entries: + cache.popitem(last=False) + + +def _clear_builder_caches() -> None: + """Clear memoized style resolution state.""" + _RESOLVED_STYLE_CACHE.clear() + _INTERACTION_COLOR_CACHE.clear() + _TAG_ATTRIBUTE_PLAN_CACHE.clear() + _COMPILED_NODE_PLAN_CACHE.clear() + + +def _build_style_cache_key( + node: AUINode, + stylesheet: Optional[StyleSheet], + class_names: Sequence[str], + element_id: Optional[str], + globals_version: int, +) -> tuple[object, ...]: + return ( + globals_version, + id(stylesheet) if stylesheet is not None else None, + node.tag, + tuple(class_names), + element_id, + node.attributes.get("style", ""), + ) + + +def _build_interaction_cache_key( + node: AUINode, + stylesheet: Optional[StyleSheet], + class_names: Sequence[str], + element_id: Optional[str], + globals_version: int, +) -> tuple[object, ...]: + return ( + globals_version, + id(stylesheet) if stylesheet is not None else None, + node.tag, + tuple(class_names), + element_id, + ) + + +def _option_signature(node: AUINode) -> tuple[tuple[str, str], ...]: + return tuple( + (child.attributes.get("value", ""), child.text_content or "") + for child in node.children + if child.tag == "option" + ) + + +def _build_tag_attribute_plan_key(node: AUINode) -> tuple[object, ...]: + attrs = node.attributes + tag = node.tag + + if tag == "text": + return ( + tag, + node.text_content, + attrs.get("text"), + attrs.get("size"), + attrs.get("color"), + ) + if tag == "button": + return ( + tag, + node.text_content, + attrs.get("text"), + attrs.get("width"), + attrs.get("height"), + ) + if tag == "input": + return ( + tag, + attrs.get("placeholder"), + attrs.get("value"), + attrs.get("width"), + attrs.get("height"), + ) + if tag == "slider": + return (tag, attrs.get("min"), attrs.get("max"), attrs.get("value")) + if tag == "checkbox": + return (tag, attrs.get("checked")) + if tag in ("image", "img"): + return (tag, attrs.get("src")) + if tag == "progress": + return (tag, attrs.get("value"), attrs.get("max")) + if tag == "video": + return ( + tag, + attrs.get("src"), + attrs.get("source"), + attrs.get("width"), + attrs.get("height"), + attrs.get("autoplay"), + attrs.get("loop"), + attrs.get("muted"), + ) + if tag == "select": + return (tag, _option_signature(node)) + if tag == "scroll": + return (tag, attrs.get("width"), attrs.get("height")) + if tag == "colorpicker": + return ( + tag, + attrs.get("width"), + attrs.get("height"), + attrs.get("color"), + attrs.get("show-alpha"), + attrs.get("show-preview"), + ) + + return (tag,) + + +def _clone_plan_kwargs(static_kwargs: Dict[str, Any]) -> Dict[str, Any]: + return { + key: value.copy() if isinstance(value, list) else copy(value) + for key, value in static_kwargs.items() + } + + +def _build_tag_attribute_plan(node: AUINode) -> _TagAttributePlan: + attrs = node.attributes + tag = node.tag + static_kwargs: Dict[str, Any] = {} + handler_bindings: list[tuple[tuple[str, ...], str, bool]] = [] + needs_interaction_colors = False + + if tag == "text": + static_kwargs["text"] = node.text_content or attrs.get("text", "") + if "size" in attrs: + static_kwargs["size"] = float(attrs["size"]) + if "color" in attrs: + color = convert_to_color(attrs["color"]) + if color: + static_kwargs["color"] = color + + elif tag == "button": + static_kwargs["text"] = node.text_content or attrs.get("text", "Button") + if "width" in attrs: + static_kwargs["width"] = convert_to_unit(attrs["width"]) + if "height" in attrs: + static_kwargs["height"] = convert_to_unit(attrs["height"]) + handler_bindings.append((("on-click", "onclick"), "on_click", True)) + needs_interaction_colors = True + + elif tag == "input": + static_kwargs["placeholder"] = attrs.get("placeholder", "") + if "value" in attrs: + static_kwargs["text"] = attrs["value"] + if "width" in attrs: + static_kwargs["width"] = convert_to_unit(attrs["width"]) + if "height" in attrs: + static_kwargs["height"] = convert_to_unit(attrs["height"]) + handler_bindings.append((("on-change",), "on_change", False)) + + elif tag == "slider": + static_kwargs["min_value"] = float(attrs.get("min", 0)) + static_kwargs["max_value"] = float(attrs.get("max", 100)) + static_kwargs["value"] = float(attrs.get("value", 50)) + handler_bindings.append((("on-change",), "on_change", False)) + + elif tag == "checkbox": + checked = attrs.get("checked", False) + static_kwargs["checked"] = checked == "true" or checked is True + handler_bindings.append((("on-change",), "on_change", False)) + needs_interaction_colors = True + + elif tag in ("image", "img"): + static_kwargs["src"] = attrs.get("src", "") + + elif tag == "progress": + static_kwargs["value"] = float(attrs.get("value", 0)) + static_kwargs["max_value"] = float(attrs.get("max", 100)) + + elif tag == "video": + static_kwargs["source"] = attrs.get("src", attrs.get("source", "")) + if "width" in attrs: + static_kwargs["width"] = convert_to_unit(attrs["width"]) + if "height" in attrs: + static_kwargs["height"] = convert_to_unit(attrs["height"]) + static_kwargs["autoplay"] = attrs.get("autoplay", "false").lower() == "true" + static_kwargs["loop"] = attrs.get("loop", "false").lower() == "true" + static_kwargs["muted"] = attrs.get("muted", "false").lower() == "true" + + elif tag == "select": + static_kwargs["options"] = [ + child.attributes.get("value", child.text_content or "") + for child in node.children + if child.tag == "option" + ] + handler_bindings.append((("on-change",), "on_select", False)) + needs_interaction_colors = True + + elif tag == "scroll": + if "width" in attrs: + static_kwargs["width"] = convert_to_unit(attrs["width"]) + if "height" in attrs: + static_kwargs["height"] = convert_to_unit(attrs["height"]) + + elif tag == "colorpicker": + if "width" in attrs: + static_kwargs["width"] = convert_to_unit(attrs["width"]) + if "height" in attrs: + static_kwargs["height"] = convert_to_unit(attrs["height"]) + if "color" in attrs: + static_kwargs["color"] = convert_to_color(attrs["color"]) + if "show-alpha" in attrs: + static_kwargs["show_alpha"] = attrs["show-alpha"].lower() == "true" + if "show-preview" in attrs: + static_kwargs["show_preview"] = attrs["show-preview"].lower() == "true" + handler_bindings.append((("on-change",), "on_change", False)) + + return _TagAttributePlan( + static_kwargs=static_kwargs, + handler_bindings=tuple(handler_bindings), + needs_interaction_colors=needs_interaction_colors, + ) + + +def _get_tag_attribute_plan(node: AUINode) -> _TagAttributePlan: + cache_key = _build_tag_attribute_plan_key(node) + cached = _TAG_ATTRIBUTE_PLAN_CACHE.get(cache_key) + if cached is not None: + _TAG_ATTRIBUTE_PLAN_CACHE.move_to_end(cache_key) + return cached + + plan = _build_tag_attribute_plan(node) + _TAG_ATTRIBUTE_PLAN_CACHE[cache_key] = plan + _TAG_ATTRIBUTE_PLAN_CACHE.move_to_end(cache_key) + if len(_TAG_ATTRIBUTE_PLAN_CACHE) > _MAX_TAG_ATTRIBUTE_PLAN_CACHE_ENTRIES: + _TAG_ATTRIBUTE_PLAN_CACHE.popitem(last=False) + return plan + + +def _compile_node_plan_uncached(node: AUINode) -> _CompiledNodePlan: + tag = node.tag + component_name = TAG_TO_COMPONENT.get(tag) + is_known_tag = tag in TAG_TO_COMPONENT + + if tag == "scroll": + child_plans = tuple(_compile_node_plan(child) for child in node.children) + elif tag in ("select", "option"): + child_plans = () + else: + child_plans = tuple( + _compile_node_plan(child) + for child in node.children + if child.tag != "option" + ) + + return _CompiledNodePlan( + node=node, + component_name=component_name, + child_plans=child_plans, + is_known_tag=is_known_tag, + is_scroll=tag == "scroll", + ) + + +def _compile_node_plan(node: AUINode) -> _CompiledNodePlan: + cache_key = id(node) + cached = _COMPILED_NODE_PLAN_CACHE.get(cache_key) + if cached is not None: + _COMPILED_NODE_PLAN_CACHE.move_to_end(cache_key) + return cached + + plan = _compile_node_plan_uncached(node) + _COMPILED_NODE_PLAN_CACHE[cache_key] = plan + _COMPILED_NODE_PLAN_CACHE.move_to_end(cache_key) + if len(_COMPILED_NODE_PLAN_CACHE) > _MAX_COMPILED_NODE_PLAN_CACHE_ENTRIES: + _COMPILED_NODE_PLAN_CACHE.popitem(last=False) + return plan + def _resolve_pseudo_styles( node: AUINode, stylesheet: Optional[StyleSheet], pseudo: str, + *, + class_names: Sequence[str], + element_id: Optional[str], ) -> Dict[str, Any]: """ Resolve styles for a pseudo-state (hover, active) from stylesheet. @@ -81,7 +417,6 @@ def _resolve_pseudo_styles( from arepy_ui.markup.globals import get_global_styles result: Dict[str, Any] = {} - attrs = node.attributes globals_registry = get_global_styles() # 1. Global element pseudo-styles @@ -89,12 +424,11 @@ def _resolve_pseudo_styles( result.update(global_element) # 2. Global class pseudo-styles - for class_name in node.get_classes(): + for class_name in class_names: global_class = globals_registry.resolve_for_class_pseudo(class_name, pseudo) result.update(global_class) # 3. Global ID pseudo-styles - element_id = attrs.get("id") if element_id: global_id = globals_registry.resolve_for_id_pseudo(element_id, pseudo) result.update(global_id) @@ -107,7 +441,7 @@ def _resolve_pseudo_styles( result.update(element_styles) if hasattr(stylesheet, "resolve_class_pseudo"): - for class_name in node.get_classes(): + for class_name in class_names: class_styles = stylesheet.resolve_class_pseudo(class_name, pseudo) result.update(class_styles) @@ -118,6 +452,61 @@ def _resolve_pseudo_styles( return result +def _resolve_interaction_colors( + node: AUINode, + stylesheet: Optional[StyleSheet], + *, + class_names: Sequence[str], + element_id: Optional[str], +) -> Dict[str, Any]: + """Resolve hover and pressed colors for interactive components.""" + from arepy_ui.markup.globals import get_global_styles + + globals_version = get_global_styles().version + cache_key = _build_interaction_cache_key( + node, stylesheet, class_names, element_id, globals_version + ) + cached = _cache_get(_INTERACTION_COLOR_CACHE, cache_key) + if cached is not None: + return cached.copy() + + kwargs: Dict[str, Any] = {} + + hover_styles = _resolve_pseudo_styles( + node, + stylesheet, + "hover", + class_names=class_names, + element_id=element_id, + ) + if "background" in hover_styles or "background-color" in hover_styles: + bg = hover_styles.get("background") or hover_styles.get("background-color") + hover_color = convert_to_color(bg) + if hover_color: + kwargs["hover_color"] = hover_color + + active_styles = _resolve_pseudo_styles( + node, + stylesheet, + "active", + class_names=class_names, + element_id=element_id, + ) + if "background" in active_styles or "background-color" in active_styles: + bg = active_styles.get("background") or active_styles.get("background-color") + pressed_color = convert_to_color(bg) + if pressed_color: + kwargs["pressed_color"] = pressed_color + + _cache_put( + _INTERACTION_COLOR_CACHE, + cache_key, + kwargs.copy(), + _MAX_INTERACTION_COLOR_CACHE_ENTRIES, + ) + return kwargs + + def resolve_styles( node: AUINode, stylesheet: Optional[StyleSheet], @@ -146,6 +535,18 @@ def resolve_styles( style_dict: Dict[str, Any] = {} globals_registry = get_global_styles() + class_names = tuple(node.get_classes()) + element_id = node.attributes.get("id") + cache_key = _build_style_cache_key( + node, + stylesheet, + class_names, + element_id, + globals_registry.version, + ) + cached = _cache_get(_RESOLVED_STYLE_CACHE, cache_key) + if cached is not None: + return cached.copy() # 1. Global element styles (lowest priority) global_element = globals_registry.resolve_for_element(node.tag) @@ -155,7 +556,7 @@ def resolve_styles( style_dict[style_key] = _convert_style_value(style_key, value) # 2. Global class styles - for class_name in node.get_classes(): + for class_name in class_names: global_class = globals_registry.resolve_for_class(class_name) for css_key, value in global_class.items(): if css_key in CSS_TO_STYLE: @@ -163,7 +564,6 @@ def resolve_styles( style_dict[style_key] = _convert_style_value(style_key, value) # 3. Global ID styles - element_id = node.attributes.get("id") if element_id: global_id = globals_registry.resolve_for_id(element_id) for css_key, value in global_id.items(): @@ -180,7 +580,7 @@ def resolve_styles( style_dict[style_key] = _convert_style_value(style_key, value) # 5. Local class styles - for class_name in node.get_classes(): + for class_name in class_names: class_styles = stylesheet.resolve_class(class_name) for css_key, value in class_styles.items(): if css_key in CSS_TO_STYLE: @@ -213,6 +613,12 @@ def resolve_styles( elif node.tag == "column": style_dict.setdefault("flex_direction", FlexDirection.COLUMN) + _cache_put( + _RESOLVED_STYLE_CACHE, + cache_key, + style_dict.copy(), + _MAX_RESOLVED_STYLE_CACHE_ENTRIES, + ) return style_dict @@ -247,17 +653,35 @@ def build_component( if errors is None: errors = ErrorCollector() + return _build_component_from_plan( + _compile_node_plan(node), + stylesheet, + handlers, + components, + errors, + ) + + +def _build_component_from_plan( + plan: _CompiledNodePlan, + stylesheet: Optional[StyleSheet], + handlers: Dict[str, Callable[..., Any]], + components: Dict[str, type], + errors: ErrorCollector, +) -> Optional[Node]: + node = plan.node tag = node.tag + line_number = getattr(node, "line_number", None) - if tag not in TAG_TO_COMPONENT: + if not plan.is_known_tag: errors.warning( f"Unknown tag '{tag}' will be ignored", tag=tag, - line=getattr(node, "line_number", None), + line=line_number, ) return None - component_name = TAG_TO_COMPONENT[tag] + component_name = plan.component_name if component_name is None: return None @@ -266,29 +690,31 @@ def build_component( errors.warning( f"Component '{component_name}' not available", tag=tag, - line=getattr(node, "line_number", None), + line=line_number, ) return None - # Resolve styles style_props = resolve_styles(node, stylesheet) style = Style(**style_props) if style_props else None - # Build component kwargs kwargs: Dict[str, Any] = {} if style: kwargs["style"] = style - # Handle common attributes if "id" in node.attributes: kwargs["id"] = node.attributes["id"] - # Tag-specific attribute handling (pass stylesheet for text color resolution) - _apply_tag_attributes(tag, node, handlers, kwargs, stylesheet) + _apply_tag_attributes( + tag, + node, + handlers, + kwargs, + style_props, + stylesheet, + ) - # Special handling for ScrollView - needs content parameter - if tag == "scroll": + if plan.is_scroll: from arepy_ui.core.node import Node as CoreNode from arepy_ui.core.types import Unit @@ -300,9 +726,9 @@ def build_component( ) ) - for child_node in node.children: - child = build_component( - child_node, stylesheet, handlers, components, errors + for child_plan in plan.child_plans: + child = _build_component_from_plan( + child_plan, stylesheet, handlers, components, errors ) if child is not None: content.add_child(child) @@ -325,7 +751,7 @@ def build_component( errors.error( f"Failed to create ScrollView: {e}", tag=tag, - line=getattr(node, "line_number", None), + line=line_number, ) return None @@ -338,21 +764,16 @@ def build_component( errors.error( f"Failed to create {component_name}: {e}", tag=tag, - line=getattr(node, "line_number", None), + line=line_number, ) return None - # Build children (except for special cases) - if tag not in ("select", "option", "scroll"): - for child_node in node.children: - if child_node.tag == "option": - continue - - child = build_component( - child_node, stylesheet, handlers, components, errors - ) - if child is not None: - component.add_child(child) + for child_plan in plan.child_plans: + child = _build_component_from_plan( + child_plan, stylesheet, handlers, components, errors + ) + if child is not None: + component.add_child(child) return component @@ -362,182 +783,38 @@ def _apply_tag_attributes( node: AUINode, handlers: Dict[str, Callable[..., Any]], kwargs: Dict[str, Any], + style_props: Dict[str, Any], stylesheet: Optional[StyleSheet] = None, ) -> None: """Apply tag-specific attributes to kwargs.""" attrs = node.attributes + class_names = tuple(node.get_classes()) + element_id = attrs.get("id") + plan = _get_tag_attribute_plan(node) + kwargs.update(_clone_plan_kwargs(plan.static_kwargs)) - if tag == "text": - kwargs["text"] = node.text_content or attrs.get("text", "") - if "size" in attrs: - kwargs["size"] = float(attrs["size"]) - - # Get color from: 1) attribute, 2) stylesheet, 3) default - text_color = None - if "color" in attrs: - text_color = convert_to_color(attrs["color"]) - elif stylesheet: - # Check ID styles first - element_id = attrs.get("id") - if element_id: - id_styles = stylesheet.resolve_id(element_id) - if "color" in id_styles: - text_color = convert_to_color(id_styles["color"]) - - # Then check class styles - if text_color is None: - class_attr = attrs.get("class", "") - for class_name in class_attr.split(): - class_styles = stylesheet.resolve_class(class_name) - if "color" in class_styles: - text_color = convert_to_color(class_styles["color"]) - break - - if text_color: - kwargs["color"] = text_color - - elif tag == "button": - kwargs["text"] = node.text_content or attrs.get("text", "Button") - if "width" in attrs: - kwargs["width"] = convert_to_unit(attrs["width"]) - if "height" in attrs: - kwargs["height"] = convert_to_unit(attrs["height"]) - handler_name = attrs.get("on-click") or attrs.get("onclick") - if handler_name and handler_name in handlers: - kwargs["on_click"] = handlers[handler_name] - else: - # Default empty handler if no on_click provided - kwargs["on_click"] = lambda: None - - # Resolve :hover pseudo-styles - hover_styles = _resolve_pseudo_styles(node, stylesheet, "hover") - if "background" in hover_styles or "background-color" in hover_styles: - bg = hover_styles.get("background") or hover_styles.get("background-color") - hover_color = convert_to_color(bg) - if hover_color: - kwargs["hover_color"] = hover_color - - # Resolve :active pseudo-styles - active_styles = _resolve_pseudo_styles(node, stylesheet, "active") - if "background" in active_styles or "background-color" in active_styles: - bg = active_styles.get("background") or active_styles.get( - "background-color" - ) - pressed_color = convert_to_color(bg) - if pressed_color: - kwargs["pressed_color"] = pressed_color - - elif tag == "input": - kwargs["placeholder"] = attrs.get("placeholder", "") - if "value" in attrs: - kwargs["text"] = attrs["value"] - if "width" in attrs: - kwargs["width"] = convert_to_unit(attrs["width"]) - if "height" in attrs: - kwargs["height"] = convert_to_unit(attrs["height"]) - handler_name = attrs.get("on-change") - if handler_name and handler_name in handlers: - kwargs["on_change"] = handlers[handler_name] - - elif tag == "slider": - kwargs["min_value"] = float(attrs.get("min", 0)) - kwargs["max_value"] = float(attrs.get("max", 100)) - kwargs["value"] = float(attrs.get("value", 50)) - handler_name = attrs.get("on-change") - if handler_name and handler_name in handlers: - kwargs["on_change"] = handlers[handler_name] - - elif tag == "checkbox": - checked = attrs.get("checked", False) - kwargs["checked"] = checked == "true" or checked is True - handler_name = attrs.get("on-change") + for attr_names, target_kwarg, use_default in plan.handler_bindings: + handler_name = next( + (attrs.get(name) for name in attr_names if attrs.get(name)), None + ) if handler_name and handler_name in handlers: - kwargs["on_change"] = handlers[handler_name] - - # Resolve :hover pseudo-styles - hover_styles = _resolve_pseudo_styles(node, stylesheet, "hover") - if "background" in hover_styles or "background-color" in hover_styles: - bg = hover_styles.get("background") or hover_styles.get("background-color") - hover_color = convert_to_color(bg) - if hover_color: - kwargs["hover_color"] = hover_color - - # Resolve :active pseudo-styles - active_styles = _resolve_pseudo_styles(node, stylesheet, "active") - if "background" in active_styles or "background-color" in active_styles: - bg = active_styles.get("background") or active_styles.get( - "background-color" - ) - pressed_color = convert_to_color(bg) - if pressed_color: - kwargs["pressed_color"] = pressed_color - - elif tag in ("image", "img"): - kwargs["src"] = attrs.get("src", "") - - elif tag == "progress": - kwargs["value"] = float(attrs.get("value", 0)) - kwargs["max_value"] = float(attrs.get("max", 100)) - - elif tag == "video": - kwargs["source"] = attrs.get("src", attrs.get("source", "")) - if "width" in attrs: - kwargs["width"] = convert_to_unit(attrs["width"]) - if "height" in attrs: - kwargs["height"] = convert_to_unit(attrs["height"]) - kwargs["autoplay"] = attrs.get("autoplay", "false").lower() == "true" - kwargs["loop"] = attrs.get("loop", "false").lower() == "true" - kwargs["muted"] = attrs.get("muted", "false").lower() == "true" - - elif tag == "select": - # Resolve :hover pseudo-styles - hover_styles = _resolve_pseudo_styles(node, stylesheet, "hover") - if "background" in hover_styles or "background-color" in hover_styles: - bg = hover_styles.get("background") or hover_styles.get("background-color") - hover_color = convert_to_color(bg) - if hover_color: - kwargs["hover_color"] = hover_color - - # Resolve :active pseudo-styles - active_styles = _resolve_pseudo_styles(node, stylesheet, "active") - if "background" in active_styles or "background-color" in active_styles: - bg = active_styles.get("background") or active_styles.get( - "background-color" + kwargs[target_kwarg] = handlers[handler_name] + elif use_default: + kwargs[target_kwarg] = _NOOP_HANDLER + + if ( + tag == "text" + and "color" not in kwargs + and style_props.get("text_color") is not None + ): + kwargs["color"] = style_props["text_color"] + + if plan.needs_interaction_colors: + kwargs.update( + _resolve_interaction_colors( + node, + stylesheet, + class_names=class_names, + element_id=element_id, ) - pressed_color = convert_to_color(bg) - if pressed_color: - kwargs["pressed_color"] = pressed_color - - # Extract options from child nodes - options = [] - for child in node.children: - if child.tag == "option": - value = child.attributes.get("value", child.text_content or "") - options.append(value) - kwargs["options"] = options - handler_name = attrs.get("on-change") - if handler_name and handler_name in handlers: - kwargs["on_select"] = handlers[handler_name] - - elif tag == "scroll": - # Handle width/height from attributes - if "width" in attrs: - kwargs["width"] = convert_to_unit(attrs["width"]) - if "height" in attrs: - kwargs["height"] = convert_to_unit(attrs["height"]) - - elif tag == "colorpicker": - # ColorPicker attributes - if "width" in attrs: - kwargs["width"] = convert_to_unit(attrs["width"]) - if "height" in attrs: - kwargs["height"] = convert_to_unit(attrs["height"]) - if "color" in attrs: - kwargs["color"] = convert_to_color(attrs["color"]) - if "show-alpha" in attrs: - kwargs["show_alpha"] = attrs["show-alpha"].lower() == "true" - if "show-preview" in attrs: - kwargs["show_preview"] = attrs["show-preview"].lower() == "true" - handler_name = attrs.get("on-change") - if handler_name and handler_name in handlers: - kwargs["on_change"] = handlers[handler_name] + ) diff --git a/arepy_ui/markup/globals.py b/arepy_ui/markup/globals.py index 7c61219..4efb7f7 100644 --- a/arepy_ui/markup/globals.py +++ b/arepy_ui/markup/globals.py @@ -42,10 +42,19 @@ def set_base(self, variables: Dict[str, str]) -> None: """Set base :root variables.""" self._base = variables.copy() + def merge_base(self, variables: Dict[str, str]) -> None: + """Merge base variables from an additional stylesheet.""" + self._base.update(variables) + def add_variant(self, name: str, variables: Dict[str, str]) -> None: """Add a theme variant (e.g., 'light', 'dark').""" self._variants[name] = variables.copy() + def merge_variant(self, name: str, variables: Dict[str, str]) -> None: + """Merge variables into an existing theme variant.""" + existing = self._variants.setdefault(name, {}) + existing.update(variables) + def activate(self, variant: Optional[str]) -> None: """Activate a theme variant. None returns to base.""" self._active_variant = variant @@ -93,6 +102,7 @@ class GlobalStyleRegistry: _variables: ThemeVariables _cache: Dict[str, Dict[str, object]] _dirty: bool + _version: int def __new__(cls) -> GlobalStyleRegistry: if cls._instance is None: @@ -101,6 +111,7 @@ def __new__(cls) -> GlobalStyleRegistry: instance._variables = ThemeVariables() instance._cache = {} instance._dirty = False + instance._version = 0 cls._instance = instance return cls._instance @@ -142,7 +153,7 @@ def _add_stylesheet(self, sheet: StyleSheet) -> None: def _extract_variables(self, sheet: StyleSheet) -> None: """Extract :root and :root.variant variables.""" - self._variables.set_base(sheet.variables) + self._variables.merge_base(sheet.variables) variant_pattern = re.compile(r":root\.(\w+)") for rule in sheet.rules: @@ -150,7 +161,7 @@ def _extract_variables(self, sheet: StyleSheet) -> None: if match: variant_name = match.group(1) variant_vars = self._parse_variant_variables(rule.properties) - self._variables.add_variant(variant_name, variant_vars) + self._variables.merge_variant(variant_name, variant_vars) def _parse_variant_variables(self, props: Dict[str, object]) -> Dict[str, str]: """Extract CSS variables from properties dict.""" @@ -180,6 +191,11 @@ def theme(self) -> Optional[str]: """Get currently active theme variant.""" return self._variables.active + @property + def version(self) -> int: + """Get the current cache version for invalidation-sensitive consumers.""" + return self._version + def get_variable(self, name: str) -> Optional[str]: """Get a CSS variable value for current theme.""" return self._variables.get(name) @@ -196,10 +212,7 @@ def resolve_for_element(self, tag: str) -> Dict[str, object]: result: Dict[str, object] = {} for sheet in self._stylesheets: - # Get raw properties without variable resolution - for rule in sheet.rules: - if rule.selector == tag: - result.update(rule.properties) + result.update(sheet.raw_resolve_element(tag)) result = self._resolve_variables_in_props(result) self._cache[cache_key] = result @@ -213,11 +226,7 @@ def resolve_for_class(self, class_name: str) -> Dict[str, object]: result: Dict[str, object] = {} for sheet in self._stylesheets: - # Get raw properties without variable resolution - selector_dot = "." + class_name - for rule in sheet.rules: - if rule.selector == selector_dot or rule.selector == class_name: - result.update(rule.properties) + result.update(sheet.raw_resolve_class(class_name)) result = self._resolve_variables_in_props(result) self._cache[cache_key] = result @@ -238,11 +247,7 @@ def resolve_for_id(self, id_name: str) -> Dict[str, object]: result: Dict[str, object] = {} for sheet in self._stylesheets: - # Get raw properties without variable resolution - selector_hash = "#" + id_name - for rule in sheet.rules: - if rule.selector == selector_hash or rule.selector == id_name: - result.update(rule.properties) + result.update(sheet.raw_resolve_id(id_name)) result = self._resolve_variables_in_props(result) self._cache[cache_key] = result @@ -255,11 +260,8 @@ def resolve_for_element_pseudo(self, tag: str, pseudo: str) -> Dict[str, object] return self._cache[cache_key] result: Dict[str, object] = {} - target = f"{tag}:{pseudo}" for sheet in self._stylesheets: - for rule in sheet.rules: - if rule.selector == target: - result.update(rule.properties) + result.update(sheet.raw_resolve_element_pseudo(tag, pseudo)) result = self._resolve_variables_in_props(result) self._cache[cache_key] = result @@ -274,11 +276,8 @@ def resolve_for_class_pseudo( return self._cache[cache_key] result: Dict[str, object] = {} - target = f".{class_name}:{pseudo}" for sheet in self._stylesheets: - for rule in sheet.rules: - if rule.selector == target: - result.update(rule.properties) + result.update(sheet.raw_resolve_class_pseudo(class_name, pseudo)) result = self._resolve_variables_in_props(result) self._cache[cache_key] = result @@ -291,11 +290,8 @@ def resolve_for_id_pseudo(self, id_name: str, pseudo: str) -> Dict[str, object]: return self._cache[cache_key] result: Dict[str, object] = {} - target = f"#{id_name}:{pseudo}" for sheet in self._stylesheets: - for rule in sheet.rules: - if rule.selector == target: - result.update(rule.properties) + result.update(sheet.raw_resolve_id_pseudo(id_name, pseudo)) result = self._resolve_variables_in_props(result) self._cache[cache_key] = result @@ -324,6 +320,7 @@ def _invalidate_cache(self) -> None: """Clear cached resolved styles.""" self._cache.clear() self._dirty = True + self._version += 1 def clear(self) -> None: """Clear all global stylesheets and variables.""" @@ -331,6 +328,7 @@ def clear(self) -> None: self._variables.clear() self._cache.clear() self._dirty = False + self._version += 1 _registry: Optional[GlobalStyleRegistry] = None diff --git a/arepy_ui/markup/loader.py b/arepy_ui/markup/loader.py index eed4715..fbe0781 100644 --- a/arepy_ui/markup/loader.py +++ b/arepy_ui/markup/loader.py @@ -4,6 +4,8 @@ from __future__ import annotations +from collections import OrderedDict +from dataclasses import dataclass import os import re from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union @@ -20,6 +22,152 @@ if TYPE_CHECKING: from arepy_ui.core.node import Node + from arepy_ui.markup.parsers import AUINode, StyleSheet + + +@dataclass(slots=True) +class _AUIFileCacheEntry: + mtime_ns: int + size: int + root_node: AUINode | None + parser_errors: tuple[str, ...] + + +@dataclass(slots=True) +class _ACSSFileCacheEntry: + mtime_ns: int + size: int + stylesheet: StyleSheet + + +@dataclass(slots=True) +class _AUIStringCacheEntry: + root_node: AUINode | None + parser_errors: tuple[str, ...] + + +_MAX_AUI_FILE_CACHE_ENTRIES = 64 +_MAX_ACSS_FILE_CACHE_ENTRIES = 64 +_MAX_AUI_STRING_CACHE_ENTRIES = 64 +_MAX_INLINE_STYLESHEET_CACHE_ENTRIES = 64 + +_AUI_FILE_CACHE: OrderedDict[str, _AUIFileCacheEntry] = OrderedDict() +_ACSS_FILE_CACHE: OrderedDict[str, _ACSSFileCacheEntry] = OrderedDict() +_AUI_STRING_CACHE: OrderedDict[str, _AUIStringCacheEntry] = OrderedDict() +_INLINE_STYLESHEET_CACHE: OrderedDict[str, StyleSheet] = OrderedDict() + + +def _cache_get[K, V](cache: OrderedDict[K, V], key: K) -> Optional[V]: + value = cache.get(key) + if value is None: + return None + cache.move_to_end(key) + return value + + +def _cache_put[K, V]( + cache: OrderedDict[K, V], key: K, value: V, max_entries: int +) -> None: + cache[key] = value + cache.move_to_end(key) + if len(cache) > max_entries: + cache.popitem(last=False) + + +def _normalize_path(path: str) -> str: + return os.path.abspath(path) + + +def _clear_load_caches() -> None: + """Clear cached parsed markup and stylesheet artifacts.""" + _AUI_FILE_CACHE.clear() + _ACSS_FILE_CACHE.clear() + _AUI_STRING_CACHE.clear() + _INLINE_STYLESHEET_CACHE.clear() + + +def _load_cached_aui_file(path: str) -> tuple[AUINode | None, list[str]]: + normalized_path = _normalize_path(path) + stat = os.stat(normalized_path) + cached = _cache_get(_AUI_FILE_CACHE, normalized_path) + if ( + cached is not None + and cached.mtime_ns == stat.st_mtime_ns + and cached.size == stat.st_size + ): + return cached.root_node, list(cached.parser_errors) + + root_node, parser_errors = parse_aui_file(normalized_path) + _cache_put( + _AUI_FILE_CACHE, + normalized_path, + _AUIFileCacheEntry( + mtime_ns=stat.st_mtime_ns, + size=stat.st_size, + root_node=root_node, + parser_errors=tuple(parser_errors), + ), + _MAX_AUI_FILE_CACHE_ENTRIES, + ) + return root_node, list(parser_errors) + + +def _load_cached_acss_file(path: str) -> StyleSheet: + normalized_path = _normalize_path(path) + stat = os.stat(normalized_path) + cached = _cache_get(_ACSS_FILE_CACHE, normalized_path) + if ( + cached is not None + and cached.mtime_ns == stat.st_mtime_ns + and cached.size == stat.st_size + ): + return cached.stylesheet + + stylesheet = parse_acss_file(normalized_path) + _cache_put( + _ACSS_FILE_CACHE, + normalized_path, + _ACSSFileCacheEntry( + mtime_ns=stat.st_mtime_ns, + size=stat.st_size, + stylesheet=stylesheet, + ), + _MAX_ACSS_FILE_CACHE_ENTRIES, + ) + return stylesheet + + +def _load_cached_aui_string(content: str) -> tuple[AUINode | None, list[str]]: + cached = _cache_get(_AUI_STRING_CACHE, content) + if cached is not None: + return cached.root_node, list(cached.parser_errors) + + root_node, parser_errors = parse_aui(content) + _cache_put( + _AUI_STRING_CACHE, + content, + _AUIStringCacheEntry( + root_node=root_node, + parser_errors=tuple(parser_errors), + ), + _MAX_AUI_STRING_CACHE_ENTRIES, + ) + return root_node, list(parser_errors) + + +def _load_cached_inline_stylesheet(content: str) -> StyleSheet: + cached = _cache_get(_INLINE_STYLESHEET_CACHE, content) + if cached is not None: + return cached + + stylesheet = parse_acss(content) + _cache_put( + _INLINE_STYLESHEET_CACHE, + content, + stylesheet, + _MAX_INLINE_STYLESHEET_CACHE_ENTRIES, + ) + return stylesheet def _get_components() -> Dict[str, type]: @@ -76,7 +224,7 @@ def load_aui( components = _get_components() errors = ErrorCollector() - root_node, parser_errors = parse_aui_file(path) + root_node, parser_errors = _load_cached_aui_file(path) if parser_errors: for err in _convert_parser_errors(parser_errors): @@ -88,11 +236,11 @@ def load_aui( css = None if stylesheet: - css = parse_acss_file(stylesheet) + css = _load_cached_acss_file(stylesheet) else: acss_path = os.path.splitext(path)[0] + ".acss" if os.path.exists(acss_path): - css = parse_acss_file(acss_path) + css = _load_cached_acss_file(acss_path) component = build_component(root_node, css, handlers, components, errors) @@ -130,7 +278,7 @@ def load_aui_string( components = _get_components() errors = ErrorCollector() - root_node, parser_errors = parse_aui(content) + root_node, parser_errors = _load_cached_aui_string(content) if parser_errors: for err in _convert_parser_errors(parser_errors): @@ -145,7 +293,7 @@ def load_aui_string( if isinstance(stylesheet, StyleSheet): css = stylesheet else: - css = parse_acss(stylesheet) + css = _load_cached_inline_stylesheet(stylesheet) component = build_component(root_node, css, handlers, components, errors) diff --git a/arepy_ui/markup/parsers/__init__.py b/arepy_ui/markup/parsers/__init__.py index 1e5a5bc..89fc713 100644 --- a/arepy_ui/markup/parsers/__init__.py +++ b/arepy_ui/markup/parsers/__init__.py @@ -5,7 +5,7 @@ - aui_parser: HTML-like markup parser - css_parser: CSS-like stylesheet parser -Cython implementations only - requires compiled .pyx files. +Cython implementations only. Type hints provided via .pyi stub files. """ diff --git a/arepy_ui/markup/parsers/aui_parser.pyi b/arepy_ui/markup/parsers/aui_parser.pyi index e432976..c675f06 100644 --- a/arepy_ui/markup/parsers/aui_parser.pyi +++ b/arepy_ui/markup/parsers/aui_parser.pyi @@ -1,21 +1,19 @@ """Type stubs for aui_parser Cython module.""" -from typing import Any, Dict, List, Optional, Tuple - class AUINode: """Represents a parsed AUI element node.""" tag: str - attributes: Dict[str, Any] - children: List["AUINode"] + attributes: dict[str, str | bool] + children: list["AUINode"] text_content: str - parent: Optional["AUINode"] + parent: "AUINode" | None line_number: int def __init__( self, tag: str, - attributes: Optional[Dict[str, Any]] = None, + attributes: dict[str, str | bool] | None = None, line_number: int = 0, ) -> None: ... def add_child(self, child: "AUINode") -> None: @@ -26,7 +24,7 @@ class AUINode: """Get the class attribute value.""" ... - def get_classes(self) -> List[str]: + def get_classes(self) -> list[str]: """Get list of class names.""" ... @@ -34,7 +32,7 @@ class AUINode: """Get the id attribute value.""" ... - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, object]: """Convert node tree to dictionary representation.""" ... @@ -42,16 +40,16 @@ class AUIParser: """Parser for AUI markup syntax.""" @property - def errors(self) -> List[str]: + def errors(self) -> list[str]: """Get list of parsing errors.""" ... def __init__(self, content: str) -> None: ... - def parse(self) -> Optional[AUINode]: + def parse(self) -> AUINode | None: """Parse the content and return the root node.""" ... -def parse_aui(content: str) -> Tuple[Optional[AUINode], List[str]]: +def parse_aui(content: str) -> tuple[AUINode | None, list[str]]: """ Parse AUI markup content. @@ -63,7 +61,7 @@ def parse_aui(content: str) -> Tuple[Optional[AUINode], List[str]]: """ ... -def parse_aui_file(path: str) -> Tuple[Optional[AUINode], List[str]]: +def parse_aui_file(path: str) -> tuple[AUINode | None, list[str]]: """ Parse an AUI file. diff --git a/arepy_ui/markup/parsers/aui_parser.pyx b/arepy_ui/markup/parsers/aui_parser.pyx index e56507d..d3fc733 100644 --- a/arepy_ui/markup/parsers/aui_parser.pyx +++ b/arepy_ui/markup/parsers/aui_parser.pyx @@ -2,28 +2,57 @@ # cython: boundscheck=False # cython: wraparound=False # cython: cdivision=True -""" -Cython-optimized AUI markup parser. +# pyright: reportMissingModuleSource=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportUnknownReturnType=false, reportGeneralTypeIssues=false +"""Cython-optimized AUI markup parser.""" -This is a performance-optimized version of aui_parser.py. -Falls back to pure Python if not compiled. -""" - -from typing import Any, Dict, List, Optional, Tuple +from cpython.unicode cimport PyUnicode_READ_CHAR +from typing import List from arepy_ui.markup.parsers.constants import SELF_CLOSING_TAGS, VALID_TAGS +ctypedef unsigned int Py_UCS4 + + +cdef inline bint _is_whitespace(Py_UCS4 ch) noexcept: + return ch == 32 or ch == 9 or ch == 10 or ch == 13 + + +cdef inline bint _is_identifier_char(Py_UCS4 ch) noexcept: + return ( + (48 <= ch <= 57) + or (65 <= ch <= 90) + or (97 <= ch <= 122) + or ch == 95 + or ch == 45 + or ch == 58 + ) + + +cdef inline bint _is_quote_char(Py_UCS4 ch) noexcept: + return ch == 34 or ch == 39 + + +cdef inline bint _contains_char(str chars, Py_UCS4 ch) noexcept: + cdef Py_ssize_t index + cdef Py_ssize_t length = len(chars) + + for index in range(length): + if PyUnicode_READ_CHAR(chars, index) == ch: + return True + return False + + cdef class AUINode: """Represents a parsed AUI element node.""" - + cdef public str tag cdef public dict attributes cdef public list children cdef public str text_content - cdef public object parent # Optional[AUINode] + cdef public object parent cdef public int line_number - + def __init__(self, str tag, dict attributes=None, int line_number=0): self.tag = tag self.attributes = attributes if attributes is not None else {} @@ -31,29 +60,24 @@ cdef class AUINode: self.text_content = "" self.parent = None self.line_number = line_number - + cpdef void add_child(self, AUINode child): - """Add a child node.""" child.parent = self self.children.append(child) - + cpdef str get_class(self): - """Get the class attribute value.""" return str(self.attributes.get("class", "")) - + cpdef list get_classes(self): - """Get list of class names.""" cdef str class_attr = self.get_class() if class_attr: return class_attr.split() return [] - + cpdef str get_id(self): - """Get the id attribute value.""" return str(self.attributes.get("id", "")) - + cpdef dict to_dict(self): - """Convert node tree to dictionary representation.""" cdef AUINode child return { "tag": self.tag, @@ -61,45 +85,42 @@ cdef class AUINode: "text": self.text_content, "children": [child.to_dict() for child in self.children], } - + def __repr__(self): return f"AUINode(tag={self.tag!r}, children={len(self.children)})" cdef class AUIParser: """Parser for AUI markup syntax.""" - + cdef str _content - cdef int _pos - cdef int _length - cdef int _line + cdef Py_ssize_t _pos + cdef Py_ssize_t _length + cdef Py_ssize_t _line cdef list _errors - + def __init__(self, str content): self._content = content self._pos = 0 self._length = len(content) self._line = 1 self._errors = [] - + @property def errors(self) -> List[str]: - """Get list of parsing errors.""" return self._errors - + cpdef AUINode parse(self): - """Parse the content and return the root node.""" - self._skip_whitespace() - cdef AUINode root = None cdef AUINode node - + + self._skip_whitespace() + while self._pos < self._length: self._skip_whitespace() - if self._pos >= self._length: break - + if self._match("<"): node = self._parse_element() if node is not None: @@ -111,229 +132,222 @@ cdef class AUIParser: ) else: self._pos += 1 - + return root - + cdef void _skip_whitespace(self): - """Skip whitespace characters, tracking line numbers.""" - cdef str char + cdef Py_UCS4 char + while self._pos < self._length: - char = self._content[self._pos] - if char == "\n": + char = PyUnicode_READ_CHAR(self._content, self._pos) + if char == 10: self._line += 1 self._pos += 1 - elif char == " " or char == "\t" or char == "\r": + elif char == 32 or char == 9 or char == 13: self._pos += 1 else: break - + cdef bint _match(self, str text): - """Check if text matches at current position.""" - cdef int text_len = len(text) - return self._content[self._pos:self._pos + text_len] == text - + return self._content.startswith(text, self._pos) + cdef str _read_until(self, str chars): - """Read characters until one of the specified chars is found.""" - cdef int start = self._pos - cdef str char + cdef Py_ssize_t start = self._pos + cdef Py_UCS4 char + while self._pos < self._length: - char = self._content[self._pos] - if char in chars: + char = PyUnicode_READ_CHAR(self._content, self._pos) + if _contains_char(chars, char): break - if char == "\n": + if char == 10: self._line += 1 self._pos += 1 + return self._content[start:self._pos] - + cdef str _read_quoted_string(self): - """Read a quoted string value.""" - cdef str quote_char = self._content[self._pos] + cdef Py_UCS4 quote_char = PyUnicode_READ_CHAR(self._content, self._pos) + cdef Py_ssize_t start + cdef Py_UCS4 char + cdef str result + self._pos += 1 - cdef int start = self._pos - cdef str char - + start = self._pos + while self._pos < self._length: - char = self._content[self._pos] - if char == "\\" and self._pos + 1 < self._length: + char = PyUnicode_READ_CHAR(self._content, self._pos) + if char == 92 and self._pos + 1 < self._length: self._pos += 2 - elif char == quote_char: + continue + if char == quote_char: break - else: - if char == "\n": - self._line += 1 - self._pos += 1 - - cdef str result = self._content[start:self._pos] + if char == 10: + self._line += 1 + self._pos += 1 + + result = self._content[start:self._pos] if self._pos < self._length: self._pos += 1 - + else: + self._errors.append( + f"Line {self._line}: Unterminated quoted attribute value" + ) + return result - + cdef str _read_identifier(self): - """Read an identifier (tag name, attribute name).""" - cdef int start = self._pos - cdef str char + cdef Py_ssize_t start = self._pos + cdef Py_UCS4 char + while self._pos < self._length: - char = self._content[self._pos] - if char.isalnum() or char == "_" or char == "-" or char == ":": + char = PyUnicode_READ_CHAR(self._content, self._pos) + if _is_identifier_char(char): self._pos += 1 else: break + return self._content[start:self._pos] - + cdef dict _parse_attributes(self): - """Parse element attributes.""" cdef dict attrs = {} - cdef str name, value, char - + cdef str name + cdef str value + cdef Py_UCS4 char + while self._pos < self._length: self._skip_whitespace() - if self._pos >= self._length: break - - char = self._content[self._pos] - if char == ">" or char == "/": + + char = PyUnicode_READ_CHAR(self._content, self._pos) + if char == 62 or char == 47: break - + name = self._read_identifier() if not name: + self._errors.append(f"Line {self._line}: Invalid attribute syntax") + self._pos += 1 break - + self._skip_whitespace() - - if self._pos < self._length and self._content[self._pos] == "=": + if self._pos < self._length and PyUnicode_READ_CHAR(self._content, self._pos) == 61: self._pos += 1 self._skip_whitespace() - + if self._pos < self._length: - char = self._content[self._pos] - if char == '"' or char == "'": + char = PyUnicode_READ_CHAR(self._content, self._pos) + if _is_quote_char(char): value = self._read_quoted_string() else: value = self._read_until(" \t\n\r>/") attrs[name] = value + else: + self._errors.append( + f"Line {self._line}: Missing value for attribute '{name}'" + ) else: attrs[name] = True - + return attrs - + cdef AUINode _parse_element(self): - """Parse a single element.""" - cdef int start_line = self._line - self._pos += 1 # Skip < - - # Check for comment + cdef Py_ssize_t start_line = self._line + cdef str tag + cdef dict attrs + cdef bint is_self_closing = 0 + cdef AUINode node + + self._pos += 1 + if self._match("!--"): self._pos += 3 while self._pos < self._length: if self._match("-->"): self._pos += 3 - break - if self._content[self._pos] == "\n": + return None + if PyUnicode_READ_CHAR(self._content, self._pos) == 10: self._line += 1 self._pos += 1 + self._errors.append(f"Line {start_line}: Unterminated comment") return None - - # Read tag name - cdef str tag = self._read_identifier().lower() - + + tag = self._read_identifier().lower() if not tag: self._errors.append(f"Line {start_line}: Empty tag name") return None - + if tag not in VALID_TAGS: self._errors.append(f"Line {start_line}: Unknown tag '{tag}'") - - # Parse attributes - cdef dict attrs = self._parse_attributes() - + + attrs = self._parse_attributes() self._skip_whitespace() - - # Check for self-closing - cdef bint is_self_closing = 0 - if self._pos < self._length and self._content[self._pos] == "/": + + if self._pos < self._length and PyUnicode_READ_CHAR(self._content, self._pos) == 47: self._pos += 1 is_self_closing = 1 - - # Skip > - if self._pos < self._length and self._content[self._pos] == ">": + + if self._pos < self._length and PyUnicode_READ_CHAR(self._content, self._pos) == 62: self._pos += 1 - - cdef AUINode node = AUINode(tag, attrs, start_line) - - # Self-closing tags don't have children + else: + self._errors.append(f"Line {start_line}: Expected '>' after <{tag}>") + + node = AUINode(tag, attrs, start_line) if is_self_closing or tag in SELF_CLOSING_TAGS: return node - - # Parse children + self._parse_children(node) - return node - + cdef void _parse_children(self, AUINode parent): - """Parse child elements and text content.""" - cdef str text, close_tag + cdef str text + cdef str close_tag cdef AUINode child - + cdef bint closed = False + while self._pos < self._length: self._skip_whitespace() - if self._pos >= self._length: break - - # Check for closing tag + if self._match("") - if self._pos < self._length: + if self._pos < self._length and PyUnicode_READ_CHAR(self._content, self._pos) == 62: self._pos += 1 - + else: + self._errors.append( + f"Line {self._line}: Unterminated closing tag for " + ) + if close_tag != parent.tag: self._errors.append( f"Line {self._line}: Mismatched closing tag. " f"Expected , got " ) + closed = True break - - # Check for new element + if self._match("<"): child = self._parse_element() if child is not None: parent.add_child(child) else: - # Text content text = self._read_until("<").strip() if text: parent.text_content = parent.text_content + text + if not closed: + self._errors.append(f"Line {parent.line_number}: Unclosed tag <{parent.tag}>") + cpdef tuple parse_aui(str content): - """ - Parse AUI markup content. - - Args: - content: AUI markup string - - Returns: - Tuple of (root_node, errors) - """ cdef AUIParser parser = AUIParser(content) cdef AUINode root = parser.parse() return (root, parser.errors) cpdef tuple parse_aui_file(str path): - """ - Parse an AUI file. - - Args: - path: Path to .aui file - - Returns: - Tuple of (root_node, errors) - """ cdef str content with open(path, "r", encoding="utf-8") as f: content = f.read() diff --git a/arepy_ui/markup/parsers/constants.py b/arepy_ui/markup/parsers/constants.py index 902730e..a86c75a 100644 --- a/arepy_ui/markup/parsers/constants.py +++ b/arepy_ui/markup/parsers/constants.py @@ -43,6 +43,7 @@ "progress", "canvas", "video", + "colorpicker", # Interactive "draggable", "dropzone", diff --git a/arepy_ui/markup/parsers/css_parser.pyi b/arepy_ui/markup/parsers/css_parser.pyi index 3194efc..ea609ac 100644 --- a/arepy_ui/markup/parsers/css_parser.pyi +++ b/arepy_ui/markup/parsers/css_parser.pyi @@ -1,40 +1,74 @@ """Type stubs for css_parser Cython module.""" -from typing import Any, Dict, List, Optional - class StyleRule: """A CSS rule with selector and properties.""" selector: str - properties: Dict[str, Any] + properties: dict[str, object] specificity: int - def __init__(self, selector: str, properties: Dict[str, Any]) -> None: ... + def __init__(self, selector: str, properties: dict[str, object]) -> None: ... class StyleSheet: """Parsed stylesheet with variables and rules.""" - variables: Dict[str, str] - rules: List[StyleRule] + variables: dict[str, str] + rules: list[StyleRule] def __init__(self) -> None: ... - def resolve_class(self, class_name: str) -> Dict[str, Any]: + def add_rule(self, rule: StyleRule) -> None: + """Add a rule and update selector indexes.""" + ... + + def raw_resolve_class(self, class_name: str) -> dict[str, object]: + """Get unresolved properties for a class selector.""" + ... + + def raw_resolve_classes(self, class_names: list[str]) -> dict[str, object]: + """Get unresolved merged properties for multiple class selectors.""" + ... + + def raw_resolve_id(self, id_name: str) -> dict[str, object]: + """Get unresolved properties for an ID selector.""" + ... + + def raw_resolve_element(self, element_name: str) -> dict[str, object]: + """Get unresolved properties for an element selector.""" + ... + + def raw_resolve_class_pseudo( + self, class_name: str, pseudo: str + ) -> dict[str, object]: + """Get unresolved properties for a class pseudo selector.""" + ... + + def raw_resolve_id_pseudo(self, id_name: str, pseudo: str) -> dict[str, object]: + """Get unresolved properties for an ID pseudo selector.""" + ... + + def raw_resolve_element_pseudo( + self, element_name: str, pseudo: str + ) -> dict[str, object]: + """Get unresolved properties for an element pseudo selector.""" + ... + + def resolve_class(self, class_name: str) -> dict[str, object]: """Get resolved properties for a class selector.""" ... - def resolve_classes(self, class_names: List[str]) -> Dict[str, Any]: + def resolve_classes(self, class_names: list[str]) -> dict[str, object]: """Get merged properties for multiple class selectors.""" ... - def resolve_id(self, id_name: str) -> Dict[str, Any]: + def resolve_id(self, id_name: str) -> dict[str, object]: """Get properties for an ID selector.""" ... - def resolve_element(self, element_name: str) -> Dict[str, Any]: + def resolve_element(self, element_name: str) -> dict[str, object]: """Get properties for an element selector.""" ... - def resolve_class_pseudo(self, class_name: str, pseudo: str) -> Dict[str, Any]: + def resolve_class_pseudo(self, class_name: str, pseudo: str) -> dict[str, object]: """Get resolved properties for a class selector with pseudo-state. Args: @@ -46,7 +80,7 @@ class StyleSheet: """ ... - def resolve_id_pseudo(self, id_name: str, pseudo: str) -> Dict[str, Any]: + def resolve_id_pseudo(self, id_name: str, pseudo: str) -> dict[str, object]: """Get resolved properties for an ID selector with pseudo-state. Args: @@ -58,7 +92,9 @@ class StyleSheet: """ ... - def resolve_element_pseudo(self, element_name: str, pseudo: str) -> Dict[str, Any]: + def resolve_element_pseudo( + self, element_name: str, pseudo: str + ) -> dict[str, object]: """Get resolved properties for an element selector with pseudo-state. Args: diff --git a/arepy_ui/markup/parsers/css_parser.pyx b/arepy_ui/markup/parsers/css_parser.pyx index 19cfda4..7257b4e 100644 --- a/arepy_ui/markup/parsers/css_parser.pyx +++ b/arepy_ui/markup/parsers/css_parser.pyx @@ -2,266 +2,396 @@ # cython: boundscheck=False # cython: wraparound=False # cython: cdivision=True -""" -Cython-optimized CSS-like parser for ACSS stylesheets. +# pyright: reportMissingModuleSource=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportUnknownReturnType=false, reportGeneralTypeIssues=false +"""Cython-optimized CSS-like parser for ACSS stylesheets.""" -This is a performance-optimized version of css_parser.py. -Falls back to pure Python if not compiled. -""" +from cpython.unicode cimport PyUnicode_READ_CHAR -import re -from typing import Any, Dict, List -# Compiled regex patterns -cdef object _VAR_PATTERN = re.compile(r"var\(--([a-zA-Z0-9_-]+)\)") -cdef object _HEX_COLOR_PATTERN = re.compile(r"^#([0-9a-fA-F]{3,8})$") +ctypedef unsigned int Py_UCS4 + + +cdef inline bint _is_ascii_whitespace(Py_UCS4 ch) noexcept: + return ch == 32 or ch == 9 or ch == 10 or ch == 13 + + +cdef inline bint _is_hex_digit(Py_UCS4 ch) noexcept: + return (48 <= ch <= 57) or (65 <= ch <= 70) or (97 <= ch <= 102) + + +cdef inline str _strip_ascii(str value): + return value.strip(" \t\n\r") + + +cdef bint _is_hex_color(str value): + cdef Py_ssize_t length = len(value) + cdef Py_ssize_t index + + if length < 4 or length > 9: + return False + if PyUnicode_READ_CHAR(value, 0) != 35: + return False + + for index in range(1, length): + if not _is_hex_digit(PyUnicode_READ_CHAR(value, index)): + return False + + return True + + +cdef str _strip_comments(str content): + cdef list parts = [] + cdef Py_ssize_t start = 0 + cdef Py_ssize_t pos = 0 + cdef Py_ssize_t length = len(content) + cdef Py_UCS4 ch + cdef Py_UCS4 next_ch + + while pos < length: + ch = PyUnicode_READ_CHAR(content, pos) + if ch == 47 and pos + 1 < length: + next_ch = PyUnicode_READ_CHAR(content, pos + 1) + if next_ch == 42: + if start < pos: + parts.append(content[start:pos]) + pos += 2 + while pos + 1 < length: + if ( + PyUnicode_READ_CHAR(content, pos) == 42 + and PyUnicode_READ_CHAR(content, pos + 1) == 47 + ): + pos += 2 + break + pos += 1 + else: + pos = length + start = pos + continue + if next_ch == 47: + if start < pos: + parts.append(content[start:pos]) + pos += 2 + while pos < length and PyUnicode_READ_CHAR(content, pos) != 10: + pos += 1 + start = pos + continue + pos += 1 + + if start < length: + parts.append(content[start:length]) + + if not parts: + return "" + return "".join(parts) cdef class StyleRule: """A CSS rule with selector and properties.""" - + cdef public str selector cdef public dict properties cdef public int specificity - + def __init__(self, str selector, dict properties): self.selector = selector self.properties = properties self.specificity = self._calculate_specificity(selector) - + cdef int _calculate_specificity(self, str selector): - """Calculate CSS specificity score.""" cdef int score = 0 - if selector.startswith("#"): + + if selector and PyUnicode_READ_CHAR(selector, 0) == 35: score = 100 - elif selector.startswith("."): + elif selector and PyUnicode_READ_CHAR(selector, 0) == 46: score = 10 else: score = 1 - if ":" in selector: + + if selector.find(":") != -1: score += 1 + return score - + def __repr__(self): return f"StyleRule({self.selector!r}, {len(self.properties)} props)" cdef class StyleSheet: """Parsed stylesheet with variables and rules.""" - + cdef public dict variables cdef public list rules cdef dict _cache - + cdef dict _element_rules + cdef dict _class_rules + cdef dict _id_rules + cdef dict _element_pseudo_rules + cdef dict _class_pseudo_rules + cdef dict _id_pseudo_rules + def __init__(self): self.variables = {} self.rules = [] self._cache = {} - + self._element_rules = {} + self._class_rules = {} + self._id_rules = {} + self._element_pseudo_rules = {} + self._class_pseudo_rules = {} + self._id_pseudo_rules = {} + + cpdef void add_rule(self, StyleRule rule): + self.rules.append(rule) + self._index_rule(rule) + + cdef void _index_rule(self, StyleRule rule): + cdef str selector = rule.selector + cdef str base_selector + cdef str pseudo + cdef dict target_props + cdef Py_ssize_t pseudo_sep = selector.find(":") + + if not selector or selector == ":root": + return + + if pseudo_sep != -1: + base_selector = selector[:pseudo_sep] + pseudo = selector[pseudo_sep + 1:] + if base_selector and PyUnicode_READ_CHAR(base_selector, 0) == 35: + target_props = self._id_pseudo_rules.setdefault( + (base_selector[1:], pseudo), {} + ) + elif base_selector and PyUnicode_READ_CHAR(base_selector, 0) == 46: + target_props = self._class_pseudo_rules.setdefault( + (base_selector[1:], pseudo), {} + ) + else: + target_props = self._element_pseudo_rules.setdefault( + (base_selector, pseudo), {} + ) + target_props.update(rule.properties) + return + + if PyUnicode_READ_CHAR(selector, 0) == 35: + target_props = self._id_rules.setdefault(selector[1:], {}) + elif PyUnicode_READ_CHAR(selector, 0) == 46: + target_props = self._class_rules.setdefault(selector[1:], {}) + else: + target_props = self._element_rules.setdefault(selector, {}) + + target_props.update(rule.properties) + + cpdef dict raw_resolve_class(self, str class_name): + return dict(self._class_rules.get(class_name, {})) + + cpdef dict raw_resolve_classes(self, list class_names): + cdef dict result = {} + cdef str name + + for name in class_names: + result.update(self._class_rules.get(name, {})) + return result + + cpdef dict raw_resolve_element(self, str element_name): + return dict(self._element_rules.get(element_name, {})) + + cpdef dict raw_resolve_id(self, str id_name): + return dict(self._id_rules.get(id_name, {})) + + cpdef dict raw_resolve_class_pseudo(self, str class_name, str pseudo): + return dict(self._class_pseudo_rules.get((class_name, pseudo), {})) + + cpdef dict raw_resolve_id_pseudo(self, str id_name, str pseudo): + return dict(self._id_pseudo_rules.get((id_name, pseudo), {})) + + cpdef dict raw_resolve_element_pseudo(self, str element_name, str pseudo): + return dict(self._element_pseudo_rules.get((element_name, pseudo), {})) + cpdef dict resolve_class(self, str class_name): - """Get resolved properties for a class selector.""" + cdef dict result + if class_name in self._cache: return self._cache[class_name] - - cdef dict result = {} - cdef StyleRule rule - - for rule in self.rules: - if rule.selector == "." + class_name or rule.selector == class_name: - result.update(rule.properties) - + + result = self.raw_resolve_class(class_name) result = self._resolve_variables(result) self._cache[class_name] = result return result - + cpdef dict resolve_classes(self, list class_names): - """Get merged properties for multiple class selectors.""" - cdef dict result = {} - cdef str name - for name in class_names: - result.update(self.resolve_class(name)) - return result - + return self._resolve_variables(self.raw_resolve_classes(class_names)) + cpdef dict resolve_element(self, str element_name): - """Get properties for an element selector.""" - cdef dict result = {} - cdef StyleRule rule - - for rule in self.rules: - if rule.selector == element_name: - result.update(rule.properties) - - return self._resolve_variables(result) - + return self._resolve_variables(self.raw_resolve_element(element_name)) + cpdef dict resolve_id(self, str id_name): - """Get properties for an ID selector.""" cdef str cache_key = "#" + id_name + cdef dict result + if cache_key in self._cache: return self._cache[cache_key] - - cdef dict result = {} - cdef StyleRule rule - - for rule in self.rules: - if rule.selector == "#" + id_name or rule.selector == id_name: - result.update(rule.properties) - + + result = self.raw_resolve_id(id_name) result = self._resolve_variables(result) self._cache[cache_key] = result return result cpdef dict resolve_class_pseudo(self, str class_name, str pseudo): - """Get resolved properties for a class selector with pseudo-state. - - Args: - class_name: The class name (without dot) - pseudo: The pseudo-selector (e.g., 'hover', 'active') - - Returns: - Properties for .class_name:pseudo - """ cdef str cache_key = "." + class_name + ":" + pseudo + cdef dict result + if cache_key in self._cache: return self._cache[cache_key] - - cdef dict result = {} - cdef StyleRule rule - cdef str target = "." + class_name + ":" + pseudo - - for rule in self.rules: - if rule.selector == target: - result.update(rule.properties) - + + result = self.raw_resolve_class_pseudo(class_name, pseudo) result = self._resolve_variables(result) self._cache[cache_key] = result return result cpdef dict resolve_id_pseudo(self, str id_name, str pseudo): - """Get resolved properties for an ID selector with pseudo-state. - - Args: - id_name: The ID name (without hash) - pseudo: The pseudo-selector (e.g., 'hover', 'active') - - Returns: - Properties for #id_name:pseudo - """ cdef str cache_key = "#" + id_name + ":" + pseudo + cdef dict result + if cache_key in self._cache: return self._cache[cache_key] - - cdef dict result = {} - cdef StyleRule rule - cdef str target = "#" + id_name + ":" + pseudo - - for rule in self.rules: - if rule.selector == target: - result.update(rule.properties) - + + result = self.raw_resolve_id_pseudo(id_name, pseudo) result = self._resolve_variables(result) self._cache[cache_key] = result return result cpdef dict resolve_element_pseudo(self, str element_name, str pseudo): - """Get resolved properties for an element selector with pseudo-state. - - Args: - element_name: The element/tag name - pseudo: The pseudo-selector (e.g., 'hover', 'active') - - Returns: - Properties for element_name:pseudo - """ cdef str cache_key = element_name + ":" + pseudo + cdef dict result + if cache_key in self._cache: return self._cache[cache_key] - - cdef dict result = {} - cdef StyleRule rule - cdef str target = element_name + ":" + pseudo - - for rule in self.rules: - if rule.selector == target: - result.update(rule.properties) - + + result = self.raw_resolve_element_pseudo(element_name, pseudo) result = self._resolve_variables(result) self._cache[cache_key] = result return result - + cdef dict _resolve_variables(self, dict props): - """Replace var(--name) references with actual values.""" cdef dict result = {} - cdef str key, var_name - cdef object value, match - + cdef str key + cdef object value + cdef str string_value + cdef Py_ssize_t start + cdef Py_ssize_t end + cdef str var_name + cdef object replacement + for key, value in props.items(): - if isinstance(value, str) and "var(" in value: - match = _VAR_PATTERN.search(value) - if match: - var_name = match.group(1) - if var_name in self.variables: - value = _VAR_PATTERN.sub(self.variables[var_name], value) + if isinstance(value, str): + string_value = value + start = string_value.find("var(--") + while start != -1: + end = string_value.find(")", start + 6) + if end == -1: + break + var_name = string_value[start + 6:end] + replacement = self.variables.get(var_name) + if replacement is None: + break + string_value = ( + string_value[:start] + + replacement + + string_value[end + 1:] + ) + start = string_value.find("var(--") + value = string_value result[key] = value - + return result cdef dict _parse_variables(str content): - """Parse CSS variables from :root block.""" cdef dict variables = {} - cdef list declarations = content.split(";") - cdef str decl, name, value - cdef list parts - - for decl in declarations: - decl = decl.strip() - if decl.startswith("--"): - parts = decl.split(":", 1) - if len(parts) == 2: - name = parts[0].strip()[2:] - value = parts[1].strip() - variables[name] = value - + cdef Py_ssize_t pos = 0 + cdef Py_ssize_t length = len(content) + cdef Py_ssize_t name_start + cdef Py_ssize_t name_end + cdef Py_ssize_t value_start + cdef Py_ssize_t value_end + cdef str name + cdef str value + + while pos < length: + while pos < length and ( + _is_ascii_whitespace(PyUnicode_READ_CHAR(content, pos)) + or PyUnicode_READ_CHAR(content, pos) == 59 + ): + pos += 1 + + if pos + 1 >= length: + break + if ( + PyUnicode_READ_CHAR(content, pos) != 45 + or PyUnicode_READ_CHAR(content, pos + 1) != 45 + ): + while pos < length and PyUnicode_READ_CHAR(content, pos) != 59: + pos += 1 + continue + + name_start = pos + 2 + pos = name_start + while pos < length and PyUnicode_READ_CHAR(content, pos) != 58: + pos += 1 + if pos >= length: + break + + name_end = pos + pos += 1 + while pos < length and _is_ascii_whitespace(PyUnicode_READ_CHAR(content, pos)): + pos += 1 + value_start = pos + while pos < length and PyUnicode_READ_CHAR(content, pos) != 59: + pos += 1 + value_end = pos + + name = _strip_ascii(content[name_start:name_end]) + value = _strip_ascii(content[value_start:value_end]) + if name: + variables[name] = value + + if pos < length and PyUnicode_READ_CHAR(content, pos) == 59: + pos += 1 + return variables cdef object _parse_value(str value): - """Parse a CSS value into appropriate Python type.""" - cdef str v = value.strip() - - # Remove quotes + cdef str v = _strip_ascii(value) + cdef Py_ssize_t v_length = len(v) + if (v.startswith('"') and v.endswith('"')) or ( v.startswith("'") and v.endswith("'") ): - return v[1:-1] - - # Percentage + return v[1 : v_length - 1] + if v.endswith("%"): try: - return ("percent", float(v[:-1])) + return ("percent", float(v[: v_length - 1])) except ValueError: return v - - # Pixels + if v.endswith("px"): try: - return ("px", float(v[:-2])) + return ("px", float(v[: v_length - 2])) except ValueError: return v - - # Number + try: if "." in v: return float(v) return int(v) except ValueError: pass - - # Hex color - if _HEX_COLOR_PATTERN.match(v): + + if _is_hex_color(v): return ("color", v) - - # Boolean keywords + if v == "true" or v == "True": return True if v == "false" or v == "False": @@ -270,111 +400,137 @@ cdef object _parse_value(str value): return None if v == "auto": return ("auto",) - + return v cdef dict _parse_properties(str content): - """Parse CSS properties from a rule block.""" cdef dict props = {} - cdef list declarations = content.split(";") - cdef str decl, key, value_str - cdef list parts - - for decl in declarations: - decl = decl.strip() - if ":" in decl: - parts = decl.split(":", 1) - if len(parts) == 2: - key = parts[0].strip() - value_str = parts[1].strip() - props[key] = _parse_value(value_str) - + cdef Py_ssize_t pos = 0 + cdef Py_ssize_t length = len(content) + cdef Py_ssize_t key_start + cdef Py_ssize_t key_end + cdef Py_ssize_t value_start + cdef Py_ssize_t value_end + cdef int nested_depth = 0 + cdef Py_UCS4 ch + cdef str key + cdef str value_str + + while pos < length: + while pos < length and ( + _is_ascii_whitespace(PyUnicode_READ_CHAR(content, pos)) + or PyUnicode_READ_CHAR(content, pos) == 59 + ): + pos += 1 + + if pos >= length: + break + + key_start = pos + while pos < length: + ch = PyUnicode_READ_CHAR(content, pos) + if ch == 58 or ch == 59: + break + pos += 1 + + if pos >= length or PyUnicode_READ_CHAR(content, pos) != 58: + while pos < length and PyUnicode_READ_CHAR(content, pos) != 59: + pos += 1 + continue + + key_end = pos + pos += 1 + while pos < length and _is_ascii_whitespace(PyUnicode_READ_CHAR(content, pos)): + pos += 1 + value_start = pos + nested_depth = 0 + + while pos < length: + ch = PyUnicode_READ_CHAR(content, pos) + if ch == 40: + nested_depth += 1 + elif ch == 41 and nested_depth > 0: + nested_depth -= 1 + elif ch == 59 and nested_depth == 0: + break + pos += 1 + + value_end = pos + key = _strip_ascii(content[key_start:key_end]) + if key: + value_str = _strip_ascii(content[value_start:value_end]) + props[key] = _parse_value(value_str) + + if pos < length and PyUnicode_READ_CHAR(content, pos) == 59: + pos += 1 + return props cpdef StyleSheet parse_acss(str content): - """ - Parse ACSS content into a StyleSheet. - - Args: - content: ACSS stylesheet string - - Returns: - Parsed StyleSheet object - """ cdef StyleSheet sheet = StyleSheet() - cdef str clean, selector, props_str - cdef int pos, length, selector_start, brace_count, props_start + cdef str clean + cdef str selector + cdef str props_str + cdef Py_ssize_t pos = 0 + cdef Py_ssize_t length + cdef Py_ssize_t selector_start + cdef Py_ssize_t brace_count + cdef Py_ssize_t props_start + cdef Py_ssize_t props_end cdef dict properties - cdef object root_match - - # Remove comments - clean = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) - clean = re.sub(r"//.*$", "", clean, flags=re.MULTILINE) - - # Parse :root variables - root_match = re.search(r":root\s*\{([^}]+)\}", clean) - if root_match: - sheet.variables = _parse_variables(root_match.group(1)) - clean = clean.replace(root_match.group(0), "") - - # Parse rules - pos = 0 + cdef Py_UCS4 ch + + clean = _strip_comments(content) length = len(clean) - + while pos < length: - # Skip whitespace - while pos < length and clean[pos] in " \t\n\r": + while pos < length and _is_ascii_whitespace(PyUnicode_READ_CHAR(clean, pos)): pos += 1 - if pos >= length: break - - # Find selector + selector_start = pos - while pos < length and clean[pos] != "{": + while pos < length and PyUnicode_READ_CHAR(clean, pos) != 123: pos += 1 - if pos >= length: break - - selector = clean[selector_start:pos].strip() + + selector = _strip_ascii(clean[selector_start:pos]) if not selector: pos += 1 continue - - # Find matching } + pos += 1 brace_count = 1 props_start = pos - while pos < length and brace_count > 0: - if clean[pos] == "{": + ch = PyUnicode_READ_CHAR(clean, pos) + if ch == 123: brace_count += 1 - elif clean[pos] == "}": + elif ch == 125: brace_count -= 1 pos += 1 - - props_str = clean[props_start:pos - 1] + + if brace_count == 0: + props_end = pos - 1 + props_str = clean[props_start:props_end] + else: + props_str = clean[props_start:length] + + if selector == ":root": + sheet.variables.update(_parse_variables(props_str)) + continue + properties = _parse_properties(props_str) - if properties: - sheet.rules.append(StyleRule(selector, properties)) - + sheet.add_rule(StyleRule(selector, properties)) + return sheet cpdef StyleSheet parse_acss_file(str path): - """ - Parse an ACSS file. - - Args: - path: Path to .acss file - - Returns: - Parsed StyleSheet object - """ cdef str content with open(path, "r", encoding="utf-8") as f: content = f.read() diff --git a/arepy_ui/markup/parsers/types.py b/arepy_ui/markup/parsers/types.py index 944c0ce..ea0c8f1 100644 --- a/arepy_ui/markup/parsers/types.py +++ b/arepy_ui/markup/parsers/types.py @@ -2,13 +2,14 @@ from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, TypeAlias +from typing import Callable, Protocol, TypeAlias # Type aliases for better readability -AttributeDict: TypeAlias = Dict[str, Any] -HandlerDict: TypeAlias = Dict[str, Callable[..., Any]] -ParseResult: TypeAlias = Tuple[Optional["AUINodeProtocol"], List[str]] -StyleDict: TypeAlias = Dict[str, Any] +AttributeValue: TypeAlias = str | bool +AttributeDict: TypeAlias = dict[str, AttributeValue] +HandlerDict: TypeAlias = dict[str, Callable[..., object]] +ParseResult: TypeAlias = tuple["AUINodeProtocol" | None, list[str]] +StyleDict: TypeAlias = dict[str, object] class AUINodeProtocol(Protocol): @@ -16,26 +17,26 @@ class AUINodeProtocol(Protocol): tag: str attributes: AttributeDict - children: List["AUINodeProtocol"] + children: list["AUINodeProtocol"] text_content: str - parent: Optional["AUINodeProtocol"] + parent: "AUINodeProtocol" | None line_number: int def add_child(self, child: "AUINodeProtocol") -> None: ... def get_class(self) -> str: ... - def get_classes(self) -> List[str]: ... + def get_classes(self) -> list[str]: ... def get_id(self) -> str: ... - def to_dict(self) -> Dict[str, Any]: ... + def to_dict(self) -> dict[str, object]: ... class StyleSheetProtocol(Protocol): """Protocol for stylesheet implementations.""" - variables: Dict[str, str] - rules: List["StyleRuleProtocol"] + variables: dict[str, str] + rules: list["StyleRuleProtocol"] def resolve_class(self, class_name: str) -> StyleDict: ... - def resolve_classes(self, class_names: List[str]) -> StyleDict: ... + def resolve_classes(self, class_names: list[str]) -> StyleDict: ... def resolve_element(self, element_name: str) -> StyleDict: ... diff --git a/docs/about/index.md b/docs/about/index.md index ec7afa1..c1a9855 100644 --- a/docs/about/index.md +++ b/docs/about/index.md @@ -9,7 +9,7 @@ arepy-ui is a modern UI library for building game interfaces with the [Arepy](ht - **Declarative Markup** - HTML-like `.aui` and CSS-like `.acss` files - **Theme Support** - CSS variables with light/dark variants - **Drag & Drop** - Built-in drag and drop system -- **Animations** - Tweening system with easing functions +- **Animations** - Sequenced animator and timers with easing functions - **High Performance** - Cython-accelerated parsing ## Project Links @@ -25,3 +25,7 @@ arepy-ui is released under the [MIT License](license.md). ## Contributing We welcome contributions! See our [Contributing Guide](contributing.md) for details. + +## Improvement Plan + +See the [project roadmap](roadmap.md) for the current documentation alignment, performance, and architecture priorities. diff --git a/docs/about/roadmap.md b/docs/about/roadmap.md new file mode 100644 index 0000000..a6f60a1 --- /dev/null +++ b/docs/about/roadmap.md @@ -0,0 +1,100 @@ +# Roadmap + +This roadmap consolidates the highest-impact improvements discovered during a repository-wide review of arepy-ui. + +## North Star + +- Keep the published documentation aligned with the public API. +- Reduce per-frame CPU work in layout, text measurement, and input traversal. +- Make performance measurable with repeatable `uv`-based benchmarks. +- Replace silent failure paths with explicit contracts and safe diagnostics. + +## Phase 1 — Documentation Alignment + +**Goal:** eliminate examples that do not run against the current API. + +- Make the published docs consistently show that `load_aui()` returns `ParseResult`. +- Standardize on `handlers=` instead of legacy `context=` examples. +- Standardize all frame loops on `ui_manager.update(dt)`. +- Audit public examples against the current exports from `arepy_ui/__init__.py`. +- Mark non-published legacy doc trees as deprecated or remove them to avoid drift. + +**Success criteria** + +- No published page contains `context=` examples for `load_aui()`. +- No published page calls `ui_manager.update()` without `dt`. +- The top-level docs match the runtime behavior in `arepy_ui/manager.py` and `arepy_ui/markup/loader.py`. + +## Phase 2 — Quick Performance Wins + +**Goal:** reduce avoidable CPU work without changing architecture. + +- Add a text measurement cache keyed by `(text, font_name, font_size, spacing)` in `arepy_ui/core/fonts.py`. +- Avoid repeated `split("\n")` and repeated per-line measurements in `arepy_ui/components/text.py`. +- Cache viewport size for the active layout pass so `vw` and `vh` do not call runtime repeatedly in `arepy_ui/core/node.py`. +- Move hot-path imports out of frame/update traversal, especially in `arepy_ui/manager.py`. +- Avoid repeated full-tree cursor scans when hover target has not changed. + +**Success criteria** + +- Fewer runtime calls inside `calculate_layout()`, `render()`, and cursor lookup. +- Stable behavior under existing tests. +- Visible reduction in frame time on large UI trees. + +## Phase 3 — Layout Engine Refactor + +**Goal:** stop recalculating more of the tree than necessary. + +- Introduce subtree-level dirty tracking instead of a single global dirty bit. +- Collapse duplicated work between `calculate_layout()` and `_propagate_position_to_children()`. +- Separate measurement and positioning clearly so auto-sizing does not force redundant recursion. +- Carry frame/layout context through the tree instead of querying runtime from each node. + +**Success criteria** + +- Changing one branch of the tree does not trigger full recomputation of unrelated branches. +- Layout complexity scales closer to the size of the dirty subtree, not the whole UI. + +## Phase 4 — Instrumentation and Benchmarks + +**Goal:** make performance regressions easy to detect. + +- Add benchmark scenes for `50`, `500`, and `2000` nodes. +- Measure separate timings for layout, input, and render traversal. +- Add a `uv` command or script to run repeatable microbenchmarks locally. +- Store baseline numbers in the repository so regressions are visible in PRs. + +**Candidate scenarios** + +- Deep nested layout tree +- Large text-heavy screen +- Scroll-heavy inventory screen +- Multiple overlays/select dropdowns +- Video component with controls enabled + +## Phase 5 — Reliability Cleanup + +**Goal:** reduce hidden failure modes and make the codebase easier to evolve. + +- Replace broad `except Exception: pass` blocks with targeted exceptions and actionable logging. +- Remove duplicated font filter application logic between `arepy_ui/config.py` and `arepy_ui/core/fonts.py`. +- Tighten public API docs around what is stable vs. experimental, especially the video component. +- Add doc checks and focused smoke tests to keep examples aligned over time. + +## Suggested Execution Order + +1. Finish Phase 1 to stop documenting broken usage. +2. Ship Phase 2 quick wins behind existing tests. +3. Add Phase 4 benchmarks before deep refactors. +4. Use benchmark data to drive Phase 3 layout work. +5. Close with Phase 5 hardening and documentation guardrails. + +## First PR Batch + +If you want the fastest path to visible improvement, start with this batch: + +- Text measurement cache +- Viewport-size cache per layout pass +- Remove hot-path imports in manager traversal +- Fix remaining public docs that still show legacy API usage +- Add a small `uv` benchmark entry point \ No newline at end of file diff --git a/docs/components/button.md b/docs/components/button.md deleted file mode 100644 index ad0270e..0000000 --- a/docs/components/button.md +++ /dev/null @@ -1,82 +0,0 @@ -# Button - -Clickable button with hover and press states. - -## Usage - -```python -from arepy_ui import Button, Color, Unit - -# Basic button -button = Button("Click me", on_click=lambda: print("Clicked!")) - -# Styled button -button = Button( - "Save", - on_click=lambda: save_game(), - width=Unit.px(120), - height=Unit.px(40), - bg_color=Color(0, 122, 204), - text_color=Color(255, 255, 255), - border_radius=8.0, - font_size=14.0, -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `text` | `str` | required | Button label | -| `on_click` | `Callable` | required | Click callback | -| `width` | `Unit` | `Unit.px(120)` | Button width | -| `height` | `Unit` | `Unit.px(40)` | Button height | -| `bg_color` | `Color` | `Color(0, 122, 204)` | Background color | -| `text_color` | `Color` | `Color(255, 255, 255)` | Text color | -| `border_radius` | `float` | `8.0` | Corner radius | -| `font_size` | `float` | `12.0` | Text size | -| `style` | `Style` | `None` | Additional styling | - -## Hover Effect - -Buttons automatically lighten on hover. The hover color is calculated from `bg_color`: - -```python -# Hover color is bg_color + 20 for each RGB channel -hover_color = Color( - min(bg_color.r + 20, 255), - min(bg_color.g + 20, 255), - min(bg_color.b + 20, 255), - bg_color.a, -) -``` - -## Examples - -### Icon Button - -```python -# Button with just an icon (using a small image or unicode) -Button("⚙", on_click=open_settings, width=Unit.px(40), height=Unit.px(40)) -``` - -### Full Width Button - -```python -Button( - "Submit", - on_click=submit, - width=Unit.percent(100), - style=Style(margin=Spacing.symmetric(10, 0)), -) -``` - -### Danger Button - -```python -Button( - "Delete", - on_click=delete_item, - bg_color=Color(220, 50, 50), -) -``` diff --git a/docs/components/canvas.md b/docs/components/canvas.md deleted file mode 100644 index ca1fbfa..0000000 --- a/docs/components/canvas.md +++ /dev/null @@ -1,108 +0,0 @@ -# Canvas - -Container for custom drawing using the renderer. - -## Usage - -```python -from arepy_ui import Canvas, Unit, Color - -def draw(renderer, x, y, width, height): - # Custom drawing code - renderer.draw_circle(x + width/2, y + height/2, 50, Color(255, 0, 0)) - -canvas = Canvas( - on_render=draw, - width=Unit.px(200), - height=Unit.px(200), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `on_render` | `Callable` | required | Drawing callback | -| `width` | `Unit` | `Unit.px(100)` | Canvas width | -| `height` | `Unit` | `Unit.px(100)` | Canvas height | -| `style` | `Style` | `None` | Additional styling | - -## Render Callback - -The `on_render` callback receives: - -- `renderer` - The 2D renderer for drawing -- `x` - Computed X position of the canvas -- `y` - Computed Y position of the canvas -- `width` - Computed width of the canvas -- `height` - Computed height of the canvas - -```python -def on_render(renderer, x: float, y: float, width: float, height: float): - # Use renderer methods to draw - pass -``` - -## Examples - -### Mini Map - -```python -def draw_minimap(renderer, x, y, w, h): - # Background - renderer.draw_rectangle(Rect(x, y, w, h), Color(20, 20, 20, 200)) - - # Player dot - px = x + w/2 - py = y + h/2 - renderer.draw_circle(px, py, 3, Color(0, 255, 0)) - - # Enemies - for enemy in enemies: - ex = x + (enemy.x / world_width) * w - ey = y + (enemy.y / world_height) * h - renderer.draw_circle(ex, ey, 2, Color(255, 0, 0)) - -Canvas(on_render=draw_minimap, width=Unit.px(150), height=Unit.px(150)) -``` - -### Graph - -```python -def draw_graph(renderer, x, y, w, h): - # Axes - renderer.draw_line(x, y + h, x + w, y + h, Color(255, 255, 255)) - renderer.draw_line(x, y, x, y + h, Color(255, 255, 255)) - - # Data points - for i, value in enumerate(data): - px = x + (i / len(data)) * w - py = y + h - (value * h) - renderer.draw_circle(px, py, 3, Color(0, 200, 255)) - -Canvas(on_render=draw_graph, width=Unit.px(300), height=Unit.px(150)) -``` - -### Custom Shape - -```python -def draw_hexagon(renderer, x, y, w, h): - cx, cy = x + w/2, y + h/2 - radius = min(w, h) / 2 - 5 - - import math - points = [] - for i in range(6): - angle = math.pi / 3 * i - math.pi / 6 - px = cx + radius * math.cos(angle) - py = cy + radius * math.sin(angle) - points.append((px, py)) - - # Draw lines between points - for i in range(6): - p1 = points[i] - p2 = points[(i + 1) % 6] - renderer.draw_line(p1[0], p1[1], p2[0], p2[1], Color(255, 200, 0)) - -Canvas(on_render=draw_hexagon, width=Unit.px(100), height=Unit.px(100)) -``` diff --git a/docs/components/checkbox.md b/docs/components/checkbox.md deleted file mode 100644 index a61d140..0000000 --- a/docs/components/checkbox.md +++ /dev/null @@ -1,69 +0,0 @@ -# Checkbox - -Toggle component with optional label. - -## Usage - -```python -from arepy_ui import Checkbox - -# Basic checkbox -checkbox = Checkbox( - label="Enable sound", - checked=True, - on_change=lambda checked: set_sound(checked), -) - -# Without label -checkbox = Checkbox( - checked=False, - on_change=lambda c: print(f"Checked: {c}"), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `label` | `str` | `""` | Label text | -| `checked` | `bool` | `False` | Initial state | -| `on_change` | `Callable[[bool], None]` | `None` | Called when toggled | -| `size` | `float` | `20` | Checkbox size | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -checkbox = Checkbox(label="Option") - -# Get current state -is_checked = checkbox.checked - -# Set state programmatically -checkbox.checked = True -``` - -## Examples - -### Settings List - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=10), - children=[ - Checkbox(label="Enable music", checked=True, on_change=set_music), - Checkbox(label="Enable sound effects", checked=True, on_change=set_sfx), - Checkbox(label="Show FPS", checked=False, on_change=set_fps_display), - Checkbox(label="Fullscreen", checked=False, on_change=set_fullscreen), - ], -) -``` - -### Terms Agreement - -```python -agree_checkbox = Checkbox( - label="I agree to the terms and conditions", - on_change=lambda c: submit_button.set_enabled(c), -) -``` diff --git a/docs/components/colorpicker.md b/docs/components/colorpicker.md deleted file mode 100644 index dd7eba1..0000000 --- a/docs/components/colorpicker.md +++ /dev/null @@ -1,223 +0,0 @@ -# ColorPicker - -Interactive color selection component using the HSV (Hue, Saturation, Value) color model. - -## Overview - -The ColorPicker provides a complete color selection experience with: - -- **Saturation/Value gradient** - Square picker for saturation (X-axis) and value (Y-axis) -- **Hue slider** - Vertical bar to select the base hue (0-360°) -- **Alpha slider** - Optional transparency control -- **Preview** - Live preview of the selected color - -## Usage - -=== "Python" - - ```python - from arepy_ui.components import ColorPicker, Color, Unit - - def on_color_change(color: Color): - print(f"Selected: #{color.r:02X}{color.g:02X}{color.b:02X}") - - picker = ColorPicker( - color=Color(255, 0, 0, 255), - width=Unit.px(280), - height=Unit.px(220), - show_alpha=True, - show_preview=True, - on_change=on_color_change, - ) - ``` - -=== "AUI" - - ```html - - ``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `color` | `Color` | `Color(255, 0, 0, 255)` | Initial color (red by default) | -| `width` | `Unit` | `Unit.px(250)` | Total width of the picker | -| `height` | `Unit` | `Unit.px(200)` | Height of the SV gradient area | -| `show_alpha` | `bool` | `True` | Whether to show the alpha slider | -| `show_preview` | `bool` | `True` | Whether to show the color preview | -| `on_change` | `Callable[[Color], None]` | `None` | Callback when color changes | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -picker = ColorPicker(color=Color(100, 150, 200, 255)) - -# Get current color -current_color = picker.color # Color(100, 150, 200, 255) - -# Get hex color string -hex_value = picker.hex_color # "#6496C8" or "#6496C8FF" if alpha < 255 - -# Set color programmatically -picker.color = Color(255, 128, 0, 255) -``` - -## Examples - -### Basic Color Picker - -```python -from arepy_ui import ColorPicker, Color, Unit, Node, Text, Style, FlexDirection - -color_display = Text("Color: #FF0000", font_size=14) - -def update_display(color: Color): - hex_val = f"#{color.r:02X}{color.g:02X}{color.b:02X}" - color_display.text = f"Color: {hex_val}" - -ui = Node( - style=Style( - flex_direction=FlexDirection.COLUMN, - gap=20, - ), - children=[ - ColorPicker( - color=Color(255, 0, 0, 255), - on_change=update_display, - ), - color_display, - ], -) -``` - -### Color Picker with Preview Box - -```python -from arepy_ui.components import ColorPicker, Color, Unit, Node, Style - -preview_box = Node( - style=Style( - width=Unit.px(100), - height=Unit.px(100), - background_color=Color(255, 0, 0, 255), - border_radius=8.0, - ), -) - -def on_change(color: Color): - preview_box.style.background_color = color - -picker = ColorPicker( - color=Color(255, 0, 0, 255), - show_alpha=True, - on_change=on_change, -) -``` - -### Without Alpha Channel - -```python -picker = ColorPicker( - color=Color(0, 150, 255, 255), - width=Unit.px(200), - height=Unit.px(180), - show_alpha=False, # Hide alpha slider - show_preview=True, -) -``` - -### Minimal Picker - -```python -picker = ColorPicker( - color=Color(128, 128, 128, 255), - width=Unit.px(180), - height=Unit.px(150), - show_alpha=False, - show_preview=False, # No preview bar -) -``` - -## Color Model - -The ColorPicker uses the **HSV** (Hue, Saturation, Value) color model internally: - -- **Hue** (0-360°): The base color on the color wheel -- **Saturation** (0-100%): Color intensity (left = gray, right = vivid) -- **Value** (0-100%): Brightness (bottom = black, top = bright) - -This model is more intuitive for color selection than RGB because: - -1. Moving horizontally changes saturation only -2. Moving vertically changes brightness only -3. The hue bar changes the base color independently - -## Performance - -The ColorPicker uses **streaming textures** for efficient gradient rendering: - -- Gradients are generated using Cython-optimized functions when available -- Textures are updated only when the hue changes -- Double-buffered uploads prevent visual tearing - -To enable Cython acceleration: - -```bash -pip install arepy-ui[markup] -python -m arepy_ui.markup.build_ext -``` - -## Styling - -The ColorPicker accepts standard Node styles: - -```python -picker = ColorPicker( - color=Color(255, 0, 0, 255), - style=Style( - margin=Spacing.all(20), - border_radius=8.0, - ), -) -``` - -## AUI Markup - -In `.aui` files, the colorpicker tag maps to the ColorPicker component: - -```html - - Choose a color: - - -``` - -**Handler mapping:** - -```python -def on_theme_change(color): - app.theme_color = color - -handlers = { - "on_theme_change": on_theme_change, -} - -root = load_aui("ui/settings.aui", handlers=handlers) -``` diff --git a/docs/components/image.md b/docs/components/image.md deleted file mode 100644 index f3516a0..0000000 --- a/docs/components/image.md +++ /dev/null @@ -1,88 +0,0 @@ -# Image - -Display textures with support for different fit modes. - -## Usage - -```python -from arepy_ui import Image, ImageFit, Color, Unit - -# Basic image -img = Image("assets/icon.png", width=Unit.px(64), height=Unit.px(64)) - -# With tint color -img = Image("assets/heart.png", tint=Color(255, 0, 0)) - -# With fit mode -img = Image("assets/background.png", fit=ImageFit.COVER) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `source` | `str` | required | Path to image file | -| `width` | `Unit` | `Unit.auto()` | Image width | -| `height` | `Unit` | `Unit.auto()` | Image height | -| `tint` | `Color` | `Color(255, 255, 255)` | Tint color | -| `fit` | `ImageFit` | `ImageFit.FILL` | How image fits container | -| `border_radius` | `float` | `0` | Corner radius | -| `style` | `Style` | `None` | Additional styling | - -## Fit Modes - -```python -from arepy_ui import ImageFit - -# Fill: stretch to fill (may distort) -Image("bg.png", fit=ImageFit.FILL) - -# Contain: fit inside, maintain aspect ratio -Image("bg.png", fit=ImageFit.CONTAIN) - -# Cover: fill container, maintain aspect ratio (may crop) -Image("bg.png", fit=ImageFit.COVER) -``` - -## Examples - -### Avatar - -```python -Image( - "assets/avatar.png", - width=Unit.px(64), - height=Unit.px(64), - border_radius=32, # Circular -) -``` - -### Tinted Icon - -```python -# Red heart -Image("assets/heart.png", tint=Color(255, 0, 0), width=Unit.px(32)) - -# Grayed out (disabled look) -Image("assets/icon.png", tint=Color(128, 128, 128), width=Unit.px(32)) -``` - -### Background - -```python -Node( - style=Style(width=Unit.percent(100), height=Unit.percent(100)), - children=[ - Image( - "assets/background.jpg", - width=Unit.percent(100), - height=Unit.percent(100), - fit=ImageFit.COVER, - ), - # UI content on top... - ], -) -``` - -!!! note "Asset Store Required" - Image loading requires the asset store to be configured. This is done automatically when using `UIManager.from_engine()`. diff --git a/docs/components/index.md b/docs/components/index.md deleted file mode 100644 index 0945cc2..0000000 --- a/docs/components/index.md +++ /dev/null @@ -1,40 +0,0 @@ -# Components - -arepy-ui includes a set of ready-to-use UI components. - -## Available Components - -| Component | Description | -|-----------|-------------| -| [Text](text.md) | Text rendering with custom fonts | -| [Button](button.md) | Clickable button with hover states | -| [TextInput](textinput.md) | Text field with cursor and selection | -| [Checkbox](checkbox.md) | Toggle with label | -| [Slider](slider.md) | Horizontal/vertical value slider | -| [Select](select.md) | Dropdown menu | -| [Tabs](tabs.md) | Tabbed container | -| [Image](image.md) | Texture display with fit modes | -| [ScrollView](scrollview.md) | Scrollable container | -| [ProgressBar](progressbar.md) | Progress indicator | -| [Canvas](canvas.md) | Custom drawing | -| [Video](video.md) | Video playback (experimental) | -| [ColorPicker](colorpicker.md) | HSV color selection | - -## Common Patterns - -All components inherit from `Node`, so they support: - -- **Children** - Add nested nodes -- **Styles** - Apply layout and visual styles -- **Events** - Handle hover, click, etc. - -```python -from arepy_ui import Button, Text, Style, Spacing - -# Components can have children -button = Button("Save", on_click=save) -button.add_child(Icon("save")) # Add icon inside button - -# Components accept style overrides -text = Text("Hello", style=Style(margin=Spacing.all(10))) -``` diff --git a/docs/components/progressbar.md b/docs/components/progressbar.md deleted file mode 100644 index 0974162..0000000 --- a/docs/components/progressbar.md +++ /dev/null @@ -1,100 +0,0 @@ -# ProgressBar - -Visual progress indicator. - -## Usage - -```python -from arepy_ui.components.progressbar import ProgressBar -from arepy_ui import Color, Unit - -# Basic progress bar -bar = ProgressBar( - value=0.75, # 75% - width=Unit.px(200), - height=Unit.px(20), -) - -# Styled progress bar -bar = ProgressBar( - value=0.5, - width=Unit.px(300), - height=Unit.px(25), - fill_color=Color(0, 200, 0), - background_color=Color(50, 50, 50), - border_radius=5.0, -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `value` | `float` | `0` | Progress value (0.0 to 1.0) | -| `width` | `Unit` | `Unit.px(200)` | Bar width | -| `height` | `Unit` | `Unit.px(20)` | Bar height | -| `fill_color` | `Color` | `Color(0, 150, 255)` | Fill color | -| `background_color` | `Color` | `Color(60, 60, 60)` | Background color | -| `border_radius` | `float` | `0` | Corner radius | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -bar = ProgressBar(value=0.5) - -# Update value -bar.value = 0.75 - -# Animate progress (manual) -bar.value = min(bar.value + 0.01, 1.0) -``` - -## Examples - -### Health Bar - -```python -def create_health_bar(current: int, max_hp: int): - return ProgressBar( - value=current / max_hp, - width=Unit.px(150), - height=Unit.px(15), - fill_color=Color(220, 50, 50), - border_radius=3, - ) -``` - -### Loading Screen - -```python -loading_bar = ProgressBar( - value=0, - width=Unit.percent(80), - height=Unit.px(30), - fill_color=Color(100, 200, 100), - border_radius=5, -) - -# In update loop -def update_loading(progress: float): - loading_bar.value = progress -``` - -### XP Bar - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=2), - children=[ - Text(f"Level {player.level}", size=12), - ProgressBar( - value=player.xp / player.xp_to_next_level, - width=Unit.px(200), - height=Unit.px(10), - fill_color=Color(255, 215, 0), # Gold - border_radius=5, - ), - ], -) -``` diff --git a/docs/components/scrollview.md b/docs/components/scrollview.md deleted file mode 100644 index a011ab3..0000000 --- a/docs/components/scrollview.md +++ /dev/null @@ -1,108 +0,0 @@ -# ScrollView - -Container with vertical scrolling and content clipping. - -## Usage - -```python -from arepy_ui import ScrollView, Style, Unit - -scroll = ScrollView( - style=Style(width=Unit.px(300), height=Unit.px(400)), - children=[ - # Content taller than the container - item1, - item2, - item3, - # ... - ], -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `children` | `list[Node]` | `[]` | Content nodes | -| `style` | `Style` | `None` | Container styling | - -## Properties - -```python -scroll = ScrollView(...) - -# Get current scroll position -y = scroll.scroll_y - -# Set scroll position -scroll.scroll_y = 100 - -# Scroll to top -scroll.scroll_y = 0 -``` - -## Scrolling - -Scroll using the mouse wheel when hovering over the ScrollView. The scroll amount is based on wheel delta. - -## Examples - -### Item List - -```python -def create_item_list(items): - return ScrollView( - style=Style( - width=Unit.px(250), - height=Unit.px(400), - background_color=Color(40, 40, 40), - ), - children=[ - Node( - style=Style(padding=Spacing.all(10)), - children=[Text(item.name) for item in items], - ) - ], - ) -``` - -### Chat Log - -```python -chat_scroll = ScrollView( - style=Style( - width=Unit.percent(100), - height=Unit.px(300), - background_color=Color(20, 20, 20), - ), - children=[ - Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=5, padding=Spacing.all(10)), - children=chat_messages, - ) - ], -) - -# Scroll to bottom when new message arrives -def add_message(msg): - chat_messages.append(Text(msg)) - chat_scroll.scroll_y = 999999 # Scroll to bottom -``` - -### Inventory Grid - -```python -ScrollView( - style=Style(width=Unit.px(400), height=Unit.px(300)), - children=[ - Node( - style=Style( - flex_direction=FlexDirection.ROW, - flex_wrap=True, - gap=5, - ), - children=[create_slot(i) for i in range(50)], - ) - ], -) -``` diff --git a/docs/components/select.md b/docs/components/select.md deleted file mode 100644 index ca8b664..0000000 --- a/docs/components/select.md +++ /dev/null @@ -1,89 +0,0 @@ -# Select - -Dropdown menu for selecting from a list of options. - -## Usage - -```python -from arepy_ui import Select, Unit - -select = Select( - options=["Easy", "Normal", "Hard"], - selected_index=1, - on_change=lambda idx, val: print(f"Selected: {val}"), - width=Unit.px(150), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `options` | `list[str]` | required | List of options | -| `selected_index` | `int` | `0` | Initially selected index | -| `on_change` | `Callable[[int, str], None]` | `None` | Called on selection change | -| `width` | `Unit` | `Unit.px(150)` | Dropdown width | -| `height` | `Unit` | `Unit.px(32)` | Dropdown height | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -select = Select(options=["A", "B", "C"]) - -# Get selected index -idx = select.selected_index - -# Get selected value -value = select.options[select.selected_index] - -# Change selection -select.selected_index = 2 -``` - -## Events - -The `on_change` callback receives both the index and value: - -```python -def handle_change(index: int, value: str): - print(f"Index: {index}, Value: {value}") - -Select( - options=["One", "Two", "Three"], - on_change=handle_change, -) -``` - -## Examples - -### Difficulty Selector - -```python -Select( - options=["Easy", "Normal", "Hard", "Nightmare"], - selected_index=1, - on_change=lambda i, v: game.set_difficulty(v), -) -``` - -### Resolution Picker - -```python -Select( - options=["1280x720", "1920x1080", "2560x1440"], - selected_index=1, - on_change=lambda i, v: set_resolution(v), - width=Unit.px(180), -) -``` - -### Language Selector - -```python -Select( - options=["English", "Español", "日本語", "Deutsch"], - selected_index=0, - on_change=lambda i, v: set_language(i), -) -``` diff --git a/docs/components/slider.md b/docs/components/slider.md deleted file mode 100644 index 2d3256d..0000000 --- a/docs/components/slider.md +++ /dev/null @@ -1,97 +0,0 @@ -# Slider - -Horizontal or vertical slider for numeric values. - -## Usage - -```python -from arepy_ui import Slider, SliderOrientation, Unit - -# Basic slider -slider = Slider( - value=50, - min_value=0, - max_value=100, - on_change=lambda v: print(f"Value: {v}"), -) - -# Vertical slider -slider = Slider( - value=0.5, - min_value=0, - max_value=1, - orientation=SliderOrientation.VERTICAL, - height=Unit.px(150), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `value` | `float` | `0` | Initial value | -| `min_value` | `float` | `0` | Minimum value | -| `max_value` | `float` | `100` | Maximum value | -| `on_change` | `Callable[[float], None]` | `None` | Called when value changes | -| `orientation` | `SliderOrientation` | `HORIZONTAL` | Slider direction | -| `width` | `Unit` | `Unit.px(200)` | Slider width | -| `height` | `Unit` | `Unit.px(20)` | Slider height | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -slider = Slider(value=50, min_value=0, max_value=100) - -# Get current value -current = slider.value - -# Set value programmatically -slider.value = 75 -``` - -## Orientation - -```python -from arepy_ui import SliderOrientation - -# Horizontal (default) -Slider(orientation=SliderOrientation.HORIZONTAL, width=Unit.px(200)) - -# Vertical -Slider(orientation=SliderOrientation.VERTICAL, height=Unit.px(150)) -``` - -## Examples - -### Volume Control - -```python -Node( - style=Style(flex_direction=FlexDirection.ROW, align_items=AlignItems.CENTER, gap=10), - children=[ - Text("Volume", size=14), - Slider( - value=audio.volume, - min_value=0, - max_value=100, - on_change=lambda v: audio.set_volume(v), - width=Unit.px(150), - ), - Text(f"{int(audio.volume)}%", size=12), - ], -) -``` - -### Color Picker (RGB) - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=5), - children=[ - Slider(value=255, max_value=255, on_change=lambda v: set_r(v)), - Slider(value=128, max_value=255, on_change=lambda v: set_g(v)), - Slider(value=0, max_value=255, on_change=lambda v: set_b(v)), - ], -) -``` diff --git a/docs/components/tabs.md b/docs/components/tabs.md deleted file mode 100644 index 156033c..0000000 --- a/docs/components/tabs.md +++ /dev/null @@ -1,92 +0,0 @@ -# Tabs - -Tabbed container for switching between content panels. - -## Usage - -```python -from arepy_ui import Tabs, Node, Text - -tabs = Tabs( - labels=["Inventory", "Stats", "Settings"], - contents=[ - inventory_panel, - stats_panel, - settings_panel, - ], - on_tab_change=lambda idx: print(f"Tab: {idx}"), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `labels` | `list[str]` | required | Tab labels | -| `contents` | `list[Node]` | required | Content panels | -| `selected_index` | `int` | `0` | Initially selected tab | -| `on_tab_change` | `Callable[[int], None]` | `None` | Called on tab change | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -tabs = Tabs(labels=["A", "B", "C"], contents=[...]) - -# Get selected index -current = tabs.selected_index - -# Change tab programmatically -tabs.selected_index = 1 -``` - -## Examples - -### Character Menu - -```python -Tabs( - labels=["Inventory", "Equipment", "Skills", "Quests"], - contents=[ - create_inventory_panel(), - create_equipment_panel(), - create_skills_panel(), - create_quests_panel(), - ], -) -``` - -### Settings Page - -```python -Tabs( - labels=["Video", "Audio", "Controls", "Gameplay"], - contents=[ - Node(children=[ - Checkbox(label="Fullscreen", on_change=set_fullscreen), - Select(options=["Low", "Medium", "High"], on_change=set_quality), - ]), - Node(children=[ - Slider(value=100, on_change=set_master_volume), - Slider(value=80, on_change=set_music_volume), - ]), - Node(children=[Text("Key bindings...")]), - Node(children=[ - Checkbox(label="Show tutorials", checked=True), - ]), - ], -) -``` - -### Simple Two-Tab Layout - -```python -tabs = Tabs( - labels=["Tab 1", "Tab 2"], - contents=[ - Text("Content for tab 1"), - Text("Content for tab 2"), - ], - on_tab_change=lambda i: print(f"Switched to tab {i}"), -) -``` diff --git a/docs/components/text.md b/docs/components/text.md deleted file mode 100644 index 0806a0d..0000000 --- a/docs/components/text.md +++ /dev/null @@ -1,76 +0,0 @@ -# Text - -Renders text with customizable font, size, and color. - -## Usage - -```python -from arepy_ui import Text, Color - -# Basic text -text = Text("Hello World", size=16) - -# With color -text = Text("Red text", size=16, color=Color(255, 0, 0)) - -# With custom font -text = Text("Custom", size=20, font="my-font") - -# Multiline -text = Text("Line 1\nLine 2\nLine 3", size=14) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `content` | `str` | required | The text to display | -| `size` | `float` | `16` | Font size in pixels | -| `color` | `Color` | `Color(255, 255, 255)` | Text color | -| `font` | `str` | `None` | Font name (uses default if None) | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -text = Text("Hello") - -# Update text content -text.text = "New text" - -# Update color -text.color = Color(0, 255, 0) - -# Update size (via font_size attribute) -text.font_size = 24 -text._update_size() -``` - -## Styling - -```python -Text( - "Styled text", - size=18, - style=Style( - margin=Spacing.all(10), - padding=Spacing.symmetric(5, 10), - ), -) -``` - -## Custom Fonts - -First load the font, then use it by name: - -```python -from arepy_ui import load_font, Text - -# Load font once at startup -load_font("pixel", "assets/fonts/pixel.ttf", base_size=32) - -# Use in text -Text("Pixel text", size=16, font_name="pixel") -``` - -See [Fonts](../features/fonts.md) for more details. diff --git a/docs/components/textinput.md b/docs/components/textinput.md deleted file mode 100644 index 3dff30d..0000000 --- a/docs/components/textinput.md +++ /dev/null @@ -1,107 +0,0 @@ -# TextInput - -Text input field with cursor, selection, and placeholder support. - -## Usage - -```python -from arepy_ui import TextInput, Unit - -# Basic input -input = TextInput( - placeholder="Enter your name...", - on_change=lambda text: print(f"Input: {text}"), -) - -# With initial value -input = TextInput( - value="Default text", - on_change=handle_change, - width=Unit.px(250), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `value` | `str` | `""` | Initial text value | -| `placeholder` | `str` | `""` | Placeholder text | -| `on_change` | `Callable[[str], None]` | `None` | Called when text changes | -| `on_submit` | `Callable[[str], None]` | `None` | Called on Enter key | -| `width` | `Unit` | `Unit.px(200)` | Input width | -| `height` | `Unit` | `Unit.px(32)` | Input height | -| `font_size` | `float` | `14.0` | Text size | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -input = TextInput() - -# Get current value -current = input.value - -# Set value programmatically -input.value = "New text" - -# Focus the input -input.focus() - -# Blur (unfocus) the input -input.blur() -``` - -## Events - -### on_change - -Called whenever the text content changes: - -```python -def handle_change(text: str): - print(f"Current text: {text}") - -TextInput(on_change=handle_change) -``` - -### on_submit - -Called when the user presses Enter: - -```python -def handle_submit(text: str): - print(f"Submitted: {text}") - # Clear the input - input.value = "" - -input = TextInput(on_submit=handle_submit) -``` - -## Examples - -### Search Box - -```python -TextInput( - placeholder="Search...", - on_submit=lambda q: search(q), - width=Unit.px(300), - style=Style(border_radius=20), -) -``` - -### Form Input - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=5), - children=[ - Text("Username", size=12), - TextInput( - placeholder="Enter username", - on_change=lambda v: set_username(v), - ), - ], -) -``` diff --git a/docs/components/video.md b/docs/components/video.md deleted file mode 100644 index b243c18..0000000 --- a/docs/components/video.md +++ /dev/null @@ -1,190 +0,0 @@ -# Video - -!!! warning "Experimental" - This component is not stable and may change in future versions. - -Video player component for displaying video files. - -## Requirements - -Video playback requires the `full` extras: - -```bash -pip install arepy-ui[full] -``` - -Or with uv: - -```bash -uv add arepy-ui[full] -``` - -This installs `av` (PyAV) and `numpy` for video decoding. - -## Usage - -```python -from arepy_ui.components.video import Video, VideoState, ControlsConfig -from arepy_ui import Unit - -# Basic video -video = Video( - source="assets/intro.mp4", - width=Unit.px(640), - height=Unit.px(360), -) - -# Autoplay with loop -video = Video( - source="assets/background.mp4", - width=Unit.percent(100), - height=Unit.percent(100), - autoplay=True, - loop=True, -) - -# With custom controls -video = Video( - source="assets/cutscene.mp4", - controls=ControlsConfig( - show_play_button=True, - show_progress_bar=True, - show_time=True, - ), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `source` | `str` | required | Path to video file | -| `width` | `Unit` | `Unit.px(320)` | Video width | -| `height` | `Unit` | `Unit.px(240)` | Video height | -| `autoplay` | `bool` | `False` | Start playing automatically | -| `loop` | `bool` | `False` | Loop when finished | -| `muted` | `bool` | `False` | Mute audio | -| `controls` | `ControlsConfig` | `None` | Controls configuration | -| `style` | `Style` | `None` | Additional styling | - -## ControlsConfig - -```python -ControlsConfig( - show_play_button=True, # Show play/pause button - show_progress_bar=True, # Show seek bar - show_time=True, # Show current/total time - show_volume=True, # Show volume slider -) -``` - -## VideoState - -```python -from arepy_ui.components.video import VideoState - -VideoState.STOPPED # Not playing -VideoState.PLAYING # Currently playing -VideoState.PAUSED # Paused -VideoState.ENDED # Finished playing -``` - -## Properties & Methods - -```python -video = Video(source="video.mp4") - -# Playback control -video.play() -video.pause() -video.stop() -video.seek(10.0) # Seek to 10 seconds - -# State -state = video.state # VideoState -current_time = video.current_time # Seconds -duration = video.duration # Total seconds - -# Audio -video.volume = 0.5 # 0.0 to 1.0 -video.muted = True -``` - -## Examples - -### Background Video - -```python -Node( - style=Style(width=Unit.percent(100), height=Unit.percent(100)), - children=[ - Video( - source="assets/menu_bg.mp4", - width=Unit.percent(100), - height=Unit.percent(100), - autoplay=True, - loop=True, - muted=True, - ), - # UI on top of video - Node( - style=Style( - position=PositionType.ABSOLUTE, - top=Unit.px(0), - left=Unit.px(0), - ), - children=[...], - ), - ], -) -``` - -### Cutscene Player - -```python -cutscene = Video( - source="assets/cutscenes/intro.mp4", - width=Unit.percent(100), - height=Unit.percent(100), - on_ended=lambda: transition_to_gameplay(), -) - -# Skip button -Button( - "Skip", - on_click=lambda: (cutscene.stop(), transition_to_gameplay()), - style=Style( - position=PositionType.ABSOLUTE, - bottom=Unit.px(20), - right=Unit.px(20), - ), -) -``` - -### Video with Controls - -```python -Video( - source="assets/tutorial.mp4", - width=Unit.px(800), - height=Unit.px(450), - controls=ControlsConfig( - show_play_button=True, - show_progress_bar=True, - show_time=True, - show_volume=True, - ), -) -``` - -## Supported Formats - -Depends on PyAV/FFmpeg. Common formats: - -- MP4 (H.264) -- WebM (VP8/VP9) -- AVI -- MOV - -!!! note "Performance" - Video decoding can be CPU-intensive. For best performance, use hardware-accelerated codecs (H.264) and reasonable resolutions. diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index ee14a9f..0000000 --- a/docs/examples.md +++ /dev/null @@ -1,60 +0,0 @@ -# Examples - -Example projects demonstrating arepy-ui features. - -## Running Examples - -```bash -cd arepy-ui -uv run examples/demo_components.py -``` - -## demo_components.py - -Showcases all UI components: buttons, inputs, sliders, checkboxes, tabs, and more. - -```bash -uv run examples/demo_components.py -``` - - - -## demo_animations.py - -Demonstrates the animation system with various easing functions. - -```bash -uv run examples/demo_animations.py -``` - - - -## demo_drag.py - -Drag and drop example with draggable items and drop zones. - -```bash -uv run examples/demo_drag.py -``` - - - -## demo_inventory.py - -Game-style inventory UI with drag and drop item management. - -```bash -uv run examples/demo_inventory.py -``` - - - -## demo_arepy.py - -Integration example showing arepy-ui with the arepy game engine. - -```bash -uv run examples/demo_arepy.py -``` - - diff --git a/docs/features/animations.md b/docs/features/animations.md deleted file mode 100644 index c16acee..0000000 --- a/docs/features/animations.md +++ /dev/null @@ -1,166 +0,0 @@ -# Animations - -Animate UI properties with the built-in tweening system. - -## Basic Animation - -```python -from arepy_ui import Animation, Easing - -anim = Animation( - target=my_node, - property="style.background_color.r", - from_value=0, - to_value=255, - duration=0.5, - easing=Easing.EASE_OUT, -) - -ui_manager.animator.add(anim) -``` - -## Animation Class - -```python -Animation( - target=node, # Target object - property="path.to.prop", # Property path (dot notation) - from_value=0, # Start value - to_value=100, # End value - duration=1.0, # Duration in seconds - easing=Easing.LINEAR, # Easing function - on_complete=callback, # Called when done -) -``` - -## Easing Functions - -```python -from arepy_ui import Easing - -Easing.LINEAR # Constant speed -Easing.EASE_IN # Slow start -Easing.EASE_OUT # Slow end -Easing.EASE_IN_OUT # Slow start and end -Easing.EASE_IN_QUAD # Quadratic ease in -Easing.EASE_OUT_QUAD # Quadratic ease out -Easing.EASE_IN_OUT_QUAD -Easing.EASE_OUT_BOUNCE # Bouncy end -Easing.EASE_OUT_ELASTIC # Elastic end -``` - -## Animator - -The UIManager has a built-in animator: - -```python -# Add animation -ui_manager.animator.add(anim) - -# Remove animation -ui_manager.animator.remove(anim) - -# Clear all animations -ui_manager.animator.clear() -``` - -## Examples - -### Fade In - -```python -Animation( - target=panel, - property="style.opacity", - from_value=0, - to_value=1, - duration=0.3, - easing=Easing.EASE_OUT, -) -``` - -### Slide In - -```python -Animation( - target=panel, - property="style.left", - from_value=-200, - to_value=0, - duration=0.4, - easing=Easing.EASE_OUT_QUAD, -) -``` - -### Color Pulse - -```python -def pulse_red(): - ui_manager.animator.add(Animation( - target=button, - property="style.background_color.r", - from_value=100, - to_value=255, - duration=0.2, - on_complete=pulse_back, - )) - -def pulse_back(): - ui_manager.animator.add(Animation( - target=button, - property="style.background_color.r", - from_value=255, - to_value=100, - duration=0.2, - )) -``` - -### Scale Effect - -```python -# Grow on hover -def on_hover_enter(): - ui_manager.animator.add(Animation( - target=card, - property="style.scale", - from_value=1.0, - to_value=1.1, - duration=0.15, - easing=Easing.EASE_OUT, - )) - -def on_hover_exit(): - ui_manager.animator.add(Animation( - target=card, - property="style.scale", - from_value=1.1, - to_value=1.0, - duration=0.15, - )) -``` - -### Chained Animations - -```python -def animate_sequence(): - # First animation - anim1 = Animation( - target=box, - property="computed_x", - from_value=0, - to_value=100, - duration=0.5, - on_complete=lambda: ui_manager.animator.add(anim2), - ) - - # Second animation (after first completes) - anim2 = Animation( - target=box, - property="computed_y", - from_value=0, - to_value=100, - duration=0.5, - ) - - ui_manager.animator.add(anim1) -``` diff --git a/docs/features/debugger.md b/docs/features/debugger.md deleted file mode 100644 index 1f7a82d..0000000 --- a/docs/features/debugger.md +++ /dev/null @@ -1,276 +0,0 @@ -# UI Debugger - -The UIDebugger is a visual debugging tool that helps you inspect and understand your UI layout in real-time. - - -![UIDebugger Overview](../assets/debugger-overview.png) -*The UIDebugger showing component bounds and hover information* - -## Overview - -The debugger provides: - -- **Component bounds** - Visual borders around each component -- **Hover inspection** - Detailed info panel when hovering over components -- **Component tree** - Hierarchical view of your UI structure -- **Padding visualization** - See padding areas highlighted -- **Keyboard shortcuts** - Quick toggles for different views - -## Quick Start - -```python -from arepy_ui.debug import UIDebugger - -# Create debugger instance -ui_debugger = UIDebugger() - -def update(dt: float): - ui_manager.update(dt) - - # Toggle debugger with F3 - if input.is_key_pressed(Key.F3): - ui_debugger.toggle() - -def render(): - ui_manager.render() - - # Render debug overlay on top - ui_debugger.render(ui_manager.root) -``` - -## Keyboard Shortcuts - -| Key | Action | Description | -|-----|--------|-------------| -| `F3` | Toggle Debug | Enable/disable the entire debugger | -| `F4` | Toggle Bounds | Show/hide component boundary boxes | -| `F5` | Toggle Padding | Show/hide padding visualization | -| `F6` | Toggle Tree | Show/hide component tree panel | - - -![Debugger Shortcuts](../assets/debugger-shortcuts.gif) -*Using keyboard shortcuts to toggle debugger features* - -## Toolbar - -When enabled, the debugger displays a toolbar at the top of the screen: - -``` -[F3] Debug [F4] Bounds [F5] Padding [F6] Tree Hovering: Button #submit -``` - -- Active features are highlighted in color -- Inactive features are dimmed -- Current hovered component is shown on the right - - -![Debugger Toolbar](../assets/debugger-toolbar.png) -*The debugger toolbar showing active features* - -## Component Bounds - -When **Bounds** is enabled (F4), each component gets a colored border: - -- Different component types have different colors -- Hovered components are highlighted with a yellow glow -- Nested components show their hierarchy visually - - -![Bounds Visualization](../assets/debugger-bounds.png) -*Component bounds with different colors per type* - -### Component Colors - -| Component | Color | -|-----------|-------| -| Node | Gray | -| Text | Blue | -| Button | Green | -| TextInput | Cyan | -| Slider | Orange | -| Checkbox | Purple | -| Image | Pink | -| ScrollView | Teal | -| ColorPicker | Gold | - -## Hover Info Panel - -When you hover over a component, a detailed info panel appears showing: - - -![Info Panel](../assets/debugger-info-panel.png) -*Detailed component information on hover* - -### Layout Section - -``` -Position: (100, 200) -Size: 250 × 45 -``` - -Shows the computed position and dimensions in pixels. - -### Style Section - -``` -width: 100% -height: 45px -flex: row -gap: 10 -padding: 8 16 -``` - -Shows the applied style properties. - -### Props Section - -Component-specific properties: - -| Component | Properties Shown | -|-----------|-----------------| -| `Text` | text content, size | -| `Button` | label | -| `TextInput` | value, placeholder | -| `Slider` | value, range | -| `Checkbox` | checked state | -| `Image` | source path | -| `Video` | state, duration | -| `ColorPicker` | current color (RGBA) | -| `Select` | options count, selected index | - -### Tree Section - -``` -Parent: Node -Children: 3 -``` - -Shows the component's position in the hierarchy. - -## Component Tree - -When **Tree** is enabled (F6), a panel appears on the right showing the full component hierarchy: - - -![Component Tree](../assets/debugger-tree.png) -*The component tree panel* - -- Components are indented by depth -- Each component shows its type and ID -- Colored markers indicate component type -- Hovered component is highlighted in yellow - -``` -◆ Node #root - ├── Text #title - ├── Node #content - │ ├── Button #submit - │ └── Button #cancel - └── Text #footer -``` - -## Padding Visualization - -When **Padding** is enabled (F5), padding areas are highlighted in green: - - -![Padding Visualization](../assets/debugger-padding.png) -*Padding areas shown in green overlay* - -This helps you understand: -- Where padding is applied -- The actual size of padding on each side -- How padding affects layout - -## Complete Example - -```python -from arepy import ArepyEngine, Input, Key, SystemPipeline -from arepy_ui import UIManager, UIConfig, Node, Button, Text, Style -from arepy_ui.debug import UIDebugger - -ui_manager: UIManager = None -ui_debugger: UIDebugger = None - -def setup(game: ArepyEngine): - global ui_manager, ui_debugger - - ui_manager = UIManager.from_engine(game, config=UIConfig()) - ui_debugger = UIDebugger() - - ui_manager.set_root( - Node( - id="root", - style=Style(padding=Spacing.all(20), gap=10), - children=[ - Text("Debug Demo", id="title", size=24), - Button("Click me", id="btn"), - ], - ) - ) - game.add_resource(ui_manager) - game.add_resource(ui_debugger) - -def update(dt: float, input: Input): - ui_manager.update(dt) - - # Debugger keyboard shortcuts - if input.is_key_pressed(Key.F3): - ui_debugger.toggle() - if input.is_key_pressed(Key.F4): - ui_debugger.toggle_bounds() - if input.is_key_pressed(Key.F5): - ui_debugger.toggle_padding() - if input.is_key_pressed(Key.F6): - ui_debugger.toggle_tree() - -def render(): - ui_manager.render() - ui_debugger.render(ui_manager.root) - -if __name__ == "__main__": - game = ArepyEngine(title="Debugger Demo", width=800, height=600) - world = game.create_world("main") - world.add_startup_system(setup) - world.add_system(SystemPipeline.UPDATE, update) - world.add_system(SystemPipeline.RENDER, render) - game.set_current_world("main") - game.run() -``` - -## API Reference - -### UIDebugger - -| Method | Description | -|--------|-------------| -| `toggle()` | Toggle the entire debugger on/off | -| `toggle_bounds()` | Toggle bounds visualization | -| `toggle_padding()` | Toggle padding visualization | -| `toggle_tree()` | Toggle component tree panel | -| `render(root)` | Render debug overlay for the UI tree | - -### Properties - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `enabled` | `bool` | `False` | Whether debugger is active | -| `show_bounds` | `bool` | `True` | Show component boundaries | -| `show_padding` | `bool` | `False` | Show padding areas | -| `show_info` | `bool` | `True` | Show hover info panel | -| `show_tree` | `bool` | `False` | Show component tree | -| `hovered_node` | `Node` | `None` | Currently hovered component | - -## Tips - -!!! tip "Development Only" - Remove or disable the debugger in production builds for better performance. - -!!! tip "Use Component IDs" - Give your components meaningful IDs to make them easier to identify in the debugger: - ```python - Button("Submit", id="submit-btn") - ``` - -!!! tip "Inspect Layout Issues" - Use the debugger to understand why layouts aren't working as expected. The hover panel shows computed vs. styled values. diff --git a/docs/features/drag-drop.md b/docs/features/drag-drop.md deleted file mode 100644 index 35f8d7c..0000000 --- a/docs/features/drag-drop.md +++ /dev/null @@ -1,174 +0,0 @@ -# Drag & Drop - -Built-in drag and drop system for creating interactive UIs. - -## Basic Usage - -```python -from arepy_ui import Draggable, DropZone, Text - -# Create a draggable item -item = Draggable( - drag_data={"id": "sword", "damage": 10}, - content=Text("Sword"), -) - -# Create a drop zone -def on_drop(data): - print(f"Dropped: {data}") - -zone = DropZone( - on_drop=on_drop, - children=[Text("Drop here")], -) -``` - -## Draggable - -Makes a node draggable. - -```python -Draggable( - drag_data={"key": "value"}, # Data passed to drop zone - content=node, # Visual content (single node) - style=Style(...), # Styling -) -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `drag_data` | `Any` | `None` | Data to pass when dropped | -| `content` | `Node` | `None` | Content node | -| `style` | `Style` | `None` | Styling | - -## DropZone - -Receives dropped items. - -```python -DropZone( - on_drop=callback, # Called when item dropped - on_hover=callback, # Called when dragging over - children=[...], # Visual content -) -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `on_drop` | `Callable` | `None` | Drop callback | -| `on_hover` | `Callable` | `None` | Hover callback | -| `children` | `list[Node]` | `[]` | Content nodes | -| `data` | `Any` | `None` | Arbitrary data (e.g., slot ID) | - -## Callbacks - -### on_drop - -Called when an item is dropped on the zone. It receives the `Draggable` instance and its data. - -```python -def on_drop(draggable, data): - # draggable: The Draggable component instance - # data: The drag_data from the Draggable - print(f"Received: {data}") - return True # Return True to accept the drop -``` - -### on_hover - -Called while dragging over the zone: - -```python -def on_hover(data, is_over: bool): - if is_over: - zone.style.border_color = Color(0, 255, 0) - else: - zone.style.border_color = Color(100, 100, 100) -``` - -## State Helpers - -```python -from arepy_ui import is_dragging, get_drag_state - -# Check if currently dragging -if is_dragging(): - print("Dragging something") - -# Get current drag state -state = get_drag_state() -if state: - print(f"Dragging: {state.data}") -``` - -## Examples - -### Inventory Slots - -```python -def create_slot(slot_id: int, item=None): - def on_drop(data): - inventory.move_item(data["item_id"], slot_id) - - zone = DropZone( - on_drop=on_drop, - style=Style( - width=Unit.px(50), - height=Unit.px(50), - background_color=Color(60, 60, 60), - border_radius=5, - ), - ) - - if item: - zone.add_child( - Draggable( - drag_data={"item_id": item.id, "from_slot": slot_id}, - content=Image(item.icon, width=Unit.px(40)), - ) - ) - - return zone -``` - -### Card Game - -```python -# Hand cards -hand = Node( - style=Style(flex_direction=FlexDirection.ROW, gap=10), - children=[ - Draggable( - drag_data={"card": card}, - content=create_card_visual(card), - ) - for card in player.hand - ], -) - -# Play area -play_area = DropZone( - on_drop=lambda data: play_card(data["card"]), - style=Style(width=Unit.px(400), height=Unit.px(200)), - children=[Text("Play cards here")], -) -``` - -### Sortable List - -```python -def create_sortable_item(item, index): - return DropZone( - on_drop=lambda data: reorder(data["index"], index), - children=[ - Draggable( - drag_data={"index": index, "item": item}, - content=Text(item.name), - ) - ], - ) -``` diff --git a/docs/features/fonts.md b/docs/features/fonts.md deleted file mode 100644 index 964fc9e..0000000 --- a/docs/features/fonts.md +++ /dev/null @@ -1,114 +0,0 @@ -# Custom Fonts - -Load and use custom TrueType fonts. - -## Loading Fonts - -```python -from arepy_ui import load_font - -# Load a font -load_font("pixel", "assets/fonts/pixel.ttf", base_size=32) - -# Load and set as default -load_font("main", "assets/fonts/main.ttf", base_size=32, set_as_default=True) -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `name` | `str` | required | Unique font name | -| `path` | `str` | required | Path to .ttf/.otf file | -| `base_size` | `int` | `32` | Base size for quality | -| `set_as_default` | `bool` | `False` | Use as default font | - -## Using Fonts - -```python -from arepy_ui import Text - -# Use by name -Text("Hello", font_size=16, font_name="pixel") - -# Default font (if set) -Text("Hello", font_size=16) # Uses default font -``` - -## Font Manager - -For more control, use the FontManager directly: - -```python -from arepy_ui import FontManager, get_font_manager - -# Get the singleton -fm = get_font_manager() - -# Load font -fm.load_font("title", "fonts/title.ttf", base_size=48) - -# Get font info -font_info = fm.get_font_info("title") -print(f"Base size: {font_info.base_size}") - -# Get raw font object -font = fm.get_font("title") -``` - -## Text Measurement - -```python -from arepy_ui import measure_text, measure_text_ex - -# Simple measurement -width = measure_text("Hello World", font_size=16) - -# Detailed measurement -metrics = measure_text_ex("Hello World", font_size=16, font_name="pixel") -print(f"Width: {metrics.width}, Height: {metrics.height}") -``` - -## Drawing Text Directly - -```python -from arepy_ui import draw_text, draw_text_centered - -# Draw at position -draw_text("Hello", x=100, y=100, size=16, color=Color(255, 255, 255)) - -# Draw centered at position -draw_text_centered("Title", x=400, y=50, size=24, color=Color(255, 255, 255)) -``` - -## Examples - -### Multiple Fonts - -```python -# Load fonts at startup -def setup(game): - load_font("title", "fonts/title.ttf", base_size=48) - load_font("body", "fonts/body.ttf", base_size=24, set_as_default=True) - load_font("mono", "fonts/mono.ttf", base_size=16) - -# Use in UI -Node(children=[ - Text("GAME TITLE", font_size=48, font_name="title"), - Text("Regular text with body font", font_size=16), # Uses default - Text("Code: print('hello')", font_size=14, font_name="mono"), -]) -``` - -### Pixel Font for Retro Look - -```python -load_font("pixel", "fonts/pixel.ttf", base_size=16) - -# Use at base size or multiples for crisp pixels -Text("SCORE: 1000", font_size=16, font_name="pixel") -Text("LEVEL 1", font_size=32, font_name="pixel") # 2x size -``` - -!!! tip "Base Size" - For best quality, set `base_size` to the largest size you'll use. The font will scale down cleanly but may look blurry when scaled up significantly. diff --git a/docs/features/markup.md b/docs/features/markup.md deleted file mode 100644 index 4ed5c57..0000000 --- a/docs/features/markup.md +++ /dev/null @@ -1,837 +0,0 @@ -# AUI Markup System - -Build UIs using declarative markup with HTML-like syntax and CSS-like styling. - -## Overview - -The AUI (Arepy UI) markup system lets you design interfaces in `.aui` files (structure) and `.acss` files (styles). This separation makes UI development faster and enables features like: - -- **Theme variants** with CSS variables -- **Hot reloading** during development -- **Designer-friendly** syntax -- **Cython-accelerated** parsing - -=== "Python" - - ```python - from arepy_ui import UIManager, Node, Text, Button, Style - - root = Node( - style=Style(flex_direction=FlexDirection.COLUMN), - children=[ - Text("My Game", size=48), - Button("Start", on_click=start_game), - ], - ) - ui_manager.set_root(root) - ``` - -=== "AUI" - - ```html - - My Game - - - ``` - - ```python - from arepy_ui.markup import load_aui - - handlers = {"start_game": start_game} - result = load_aui("menu.aui", handlers=handlers) - ui_manager.set_root(result.root) - ``` - ---- - -## AUI Markup Syntax - -### Basic Structure - -```html - - Space Shooter - - - - - - - -``` - -### Available Tags - -| Tag | Component | Description | -|-----|-----------|-------------| -| `` | `Node` | Generic container | -| `` | `Node` | Horizontal flex container (`flex-direction: row`) | -| `` | `Node` | Vertical flex container (`flex-direction: column`) | -| `` | `Text` | Text display | -| ` -``` - -### API Reference - -| Function | Description | -|----------|-------------| -| `load_globals(path)` | Load global styles from a `.acss` file | -| `load_globals_string(content)` | Load global styles from a string | -| `set_theme(variant)` | Activate a theme variant (`"light"`, `"dark"`, or `None`) | -| `get_theme()` | Get the currently active theme variant | -| `clear_globals()` | Clear all global stylesheets | - ---- - -## Error Handling - -The markup system provides detailed error reporting with line numbers, columns, and context. - -### ParseResult - -Loading AUI files returns a `ParseResult` object: - -```python -from arepy_ui.markup import load_aui - -result = load_aui("menu.aui", handlers=handlers) - -if result.success: - ui_manager.set_root(result.root) -else: - for error in result.errors: - print(error) -``` - -### Error Levels - -| Level | Description | -|-------|-------------| -| `ERROR` | Critical error, parsing failed | -| `WARNING` | Non-critical issue, parsing continued | - -### MarkupError Structure - -```python -from arepy_ui.markup.errors import MarkupError, ErrorLevel - -# Each error contains: -error.level # ErrorLevel.ERROR or ErrorLevel.WARNING -error.message # Description of the issue -error.line # Line number (1-based) -error.column # Column number (1-based) -error.tag # Related tag name (optional) -error.attribute # Related attribute name (optional) - -# String representation -print(error) # "[ERROR] (line 15:8) - - -``` - ---- - -## Complete Example - -**menu.aui** -```html - - Space Shooter - - - - - - - - - - v1.0.0 - - -``` - -**menu.acss** -```css -:root { - --primary: #6c5ce7; - --danger: #d63031; - --bg: #0a0a15; - --text: #dfe6e9; -} - -.main-menu { - width: 100%; - height: 100%; - background: var(--bg); - flex-direction: column; - align-items: center; - justify-content: center; - gap: 40px; -} - -.title { - font-size: 64px; - color: var(--text); -} - -.menu-buttons { - gap: 15px; -} - -.btn { - width: 250px; - height: 55px; - background: var(--primary); - color: var(--text); - border-radius: 8px; - font-size: 20px; -} - -.btn-danger { - background: var(--danger); -} - -.footer { - position: absolute; - bottom: 20px; - right: 20px; -} - -.version { - font-size: 14px; - color: #636e72; -} -``` - -**main.py** -```python -from arepy import ArepyEngine, SystemPipeline, Renderer2D -from arepy_ui import UIManager, UIConfig -from arepy_ui.markup import load_globals, load_aui, set_theme, get_theme - -def new_game(): - print("Starting new game...") - -def continue_game(): - print("Continuing...") - -def options(): - print("Opening options...") - -def quit_game(): - game.quit() - -handlers = { - "new_game": new_game, - "continue": continue_game, - "options": options, - "quit": quit_game, -} - -def setup(game: ArepyEngine): - # Load global styles first - load_globals("ui/globals.acss") - - ui_manager = UIManager.from_engine(game, config=UIConfig()) - - # Load UI from markup - result = load_aui("ui/menu.aui", handlers=handlers) - - if not result.success: - for error in result.errors: - print(f"Markup error: {error}") - return - - ui_manager.set_root(result.root) - # Make the manager available to systems via resource injection - game.add_resource(ui_manager) - -def update(ui_manager: UIManager, renderer: Renderer2D): - ui_manager.update(renderer.get_delta_time()) - -def render(ui_manager: UIManager): - ui_manager.render() - -if __name__ == "__main__": - game = ArepyEngine(title="Space Shooter", width=800, height=600) - world = game.create_world("main") - world.add_system(SystemPipeline.UPDATE, update) - world.add_system(SystemPipeline.RENDER_UI, render) - game.set_current_world("main") - game.run() -``` - ---- - -## Registering Custom Components - -You can register your own components to use them in AUI markup files. - -### Creating a Custom Component - -First, create a component class that extends `Node`: - -```python -from arepy_ui.core.node import Node -from arepy_ui.core.style import Style -from arepy_ui.core.types import Color - -class HealthBar(Node): - """A custom health bar component.""" - - def __init__( - self, - value: float = 100, - max_value: float = 100, - bar_color: Color = Color(0, 255, 0, 255), - bg_color: Color = Color(50, 50, 50, 255), - style: Style = None, - **kwargs - ): - super().__init__(style=style, **kwargs) - self.value = value - self.max_value = max_value - self.bar_color = bar_color - self.bg_color = bg_color - - @property - def percentage(self) -> float: - return (self.value / self.max_value) * 100 - - def render(self): - # Custom rendering logic - super().render() - # Draw background, then filled portion... -``` - -### Registering the Component - -Use `register_component` to make it available in AUI markup: - -```python -from arepy_ui import register_component - -register_component( - HealthBar, - name="HealthBar", # Display name - tags=["healthbar", "health-bar"], # AUI tags that map to this component - color=(255, 100, 100), # Debug color (RGB) - category="custom", # Category: core, input, layout, media, custom -) -``` - -### Using in AUI Markup - -Now you can use your component in `.aui` files: - -```html - - - - - -``` - -### Complete Example - -```python -from arepy_ui import register_component, Node -from arepy_ui.core.style import Style -from arepy_ui.core.types import Color, Unit - -class StarRating(Node): - """A star rating component.""" - - def __init__( - self, - rating: int = 0, - max_stars: int = 5, - on_change=None, - style: Style = None, - **kwargs - ): - super().__init__(style=style, **kwargs) - self._rating = rating - self.max_stars = max_stars - self.on_change = on_change - - @property - def rating(self) -> int: - return self._rating - - @rating.setter - def rating(self, value: int): - self._rating = max(0, min(value, self.max_stars)) - if self.on_change: - self.on_change(self._rating) - - -# Register before loading any AUI that uses it -register_component( - StarRating, - tags=["star-rating", "starrating", "rating"], - color=(255, 215, 0), # Gold color for debugger - category="input", -) -``` - -**Usage in AUI:** - -```html - - Rate this game: - - -``` - -**Python handler:** - -```python -def on_rating_change(value): - print(f"New rating: {value} stars") - -handlers = {"on_rating_change": on_rating_change} -result = load_aui("review.aui", handlers=handlers) -``` - -### ComponentMeta Properties - -When registering a component, you can set these properties: - -| Property | Type | Description | -|----------|------|-------------| -| `component_class` | `Type` | The component class | -| `name` | `str` | Display name (defaults to class name) | -| `tags` | `List[str]` | AUI markup tags that map to this component | -| `color` | `tuple[int,int,int]` | RGB color for debugger visualization | -| `category` | `str` | Category: `core`, `input`, `layout`, `media`, `custom` | - -### Registry API - -| Function | Description | -|----------|-------------| -| `register_component(cls, ...)` | Register a custom component | -| `get_registry()` | Get the global component registry | -| `registry.get(name)` | Get component metadata by name | -| `registry.get_by_tag(tag)` | Get component metadata by markup tag | -| `registry.get_class(name)` | Get component class by name | -| `registry.is_valid_tag(tag)` | Check if a tag is registered | -| `registry.get_valid_tags()` | Get all valid markup tags | - -### Tips - -!!! tip "Register Early" - Register custom components **before** calling `load_aui()`. Components must be registered before the parser encounters their tags. - -!!! tip "Multiple Tags" - Use multiple tags for flexibility: `["my-widget", "mywidget", "mw"]` - -!!! tip "Debug Colors" - Choose distinctive colors for your components to make them easy to identify in the debugger (F3). - ---- - -## Performance - -The markup parser is implemented in **Cython** for maximum performance. To enable Cython acceleration: - -```bash -pip install arepy-ui[markup] -python -m arepy_ui.markup.build_ext -``` - -If Cython is not installed, the pure Python fallback is used automatically. - ---- - -## API Reference - -### Loading Functions - -| Function | Description | -|----------|-------------| -| `load_aui(path, stylesheet=None, handlers=None)` | Load UI from `.aui` file | -| `load_aui_string(content, stylesheet=None, handlers=None)` | Load UI from string | -| `parse_acss(content)` | Parse ACSS content to StyleSheet | -| `parse_aui(content)` | Parse AUI content | - -### Global Styles - -| Function | Description | -|----------|-------------| -| `load_globals(path)` | Load global stylesheet file | -| `load_globals_string(content)` | Load global styles from string | -| `set_theme(variant)` | Activate theme variant | -| `get_theme()` | Get current theme | -| `clear_globals()` | Clear all global styles | - -### Component Registration - -| Function | Description | -|----------|-------------| -| `register_component(cls, name, tags, color, category)` | Register a custom component | -| `get_registry()` | Get the global component registry | - -### ParseResult - -| Property | Type | Description | -|----------|------|-------------| -| `root` | `Node` | Parsed root node | -| `errors` | `List[MarkupError]` | List of errors/warnings | -| `success` | `bool` | `True` if no errors | -| `has_warnings` | `bool` | `True` if any warnings | diff --git a/docs/features/modals.md b/docs/features/modals.md deleted file mode 100644 index 890ea63..0000000 --- a/docs/features/modals.md +++ /dev/null @@ -1,168 +0,0 @@ -# Modals - -Display overlays and dialog boxes on top of the main UI. - -## Basic Usage - -```python -# Create modal content -modal = Node( - style=Style( - width=Unit.px(300), - height=Unit.px(200), - background_color=Color(50, 50, 50), - border_radius=10, - padding=Spacing.all(20), - ), - children=[ - Text("Are you sure?", size=18), - Button("Yes", on_click=confirm), - Button("No", on_click=lambda: ui_manager.close_modal()), - ], -) - -# Show modal -ui_manager.show_modal(modal) -``` - -## show_modal() - -```python -ui_manager.show_modal( - modal, # Node to display - backdrop=True, # Show dark backdrop - close_on_backdrop=True, # Close when clicking backdrop -) -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `modal` | `Node` | required | Content to display | -| `backdrop` | `bool` | `True` | Show dark overlay | -| `close_on_backdrop` | `bool` | `True` | Click outside to close | - -## Closing Modals - -```python -# Close specific modal -ui_manager.close_modal(modal) - -# Close topmost modal -ui_manager.close_modal() - -# Close all modals -ui_manager.close_all_modals() - -# Check if modal is open -if ui_manager.has_modal: - print("Modal is open") -``` - -## Examples - -### Confirmation Dialog - -```python -def show_confirm(message: str, on_confirm): - modal = Node( - style=Style( - width=Unit.px(300), - padding=Spacing.all(20), - background_color=Color(45, 45, 45), - border_radius=8, - flex_direction=FlexDirection.COLUMN, - gap=15, - ), - children=[ - Text(message, size=16), - Node( - style=Style( - flex_direction=FlexDirection.ROW, - justify_content=JustifyContent.FLEX_END, - gap=10, - ), - children=[ - Button("Cancel", on_click=lambda: ui_manager.close_modal()), - Button("Confirm", on_click=lambda: (on_confirm(), ui_manager.close_modal())), - ], - ), - ], - ) - ui_manager.show_modal(modal) - -# Usage -show_confirm("Delete this item?", on_confirm=delete_item) -``` - -### Pause Menu - -```python -def show_pause_menu(): - menu = Node( - style=Style( - width=Unit.px(250), - padding=Spacing.all(30), - background_color=Color(30, 30, 30, 240), - border_radius=10, - flex_direction=FlexDirection.COLUMN, - gap=10, - align_items=AlignItems.STRETCH, - ), - children=[ - Text("PAUSED", size=24), - Button("Resume", on_click=resume_game), - Button("Settings", on_click=show_settings), - Button("Quit", on_click=quit_game), - ], - ) - ui_manager.show_modal(menu, close_on_backdrop=False) -``` - -### Loading Screen - -```python -loading_bar = ProgressBar(value=0, width=Unit.px(250)) - -loading_modal = Node( - style=Style( - width=Unit.px(300), - padding=Spacing.all(30), - background_color=Color(20, 20, 20), - border_radius=8, - flex_direction=FlexDirection.COLUMN, - gap=15, - align_items=AlignItems.CENTER, - ), - children=[ - Text("Loading...", size=18), - loading_bar, - ], -) - -# Show non-dismissable loading modal -ui_manager.show_modal(loading_modal, close_on_backdrop=False) - -# Update progress -loading_bar.value = 0.5 - -# Close when done -ui_manager.close_modal(loading_modal) -``` - -### Stacked Modals - -Modals can be stacked. The topmost receives input. - -```python -# First modal -ui_manager.show_modal(settings_modal) - -# Second modal on top -ui_manager.show_modal(confirm_modal) - -# close_modal() closes the topmost -ui_manager.close_modal() # Closes confirm_modal -ui_manager.close_modal() # Closes settings_modal -``` diff --git a/docs/features/resize.md b/docs/features/resize.md deleted file mode 100644 index dfd6a67..0000000 --- a/docs/features/resize.md +++ /dev/null @@ -1,135 +0,0 @@ -# Window Resize - -Handle window resizing with different strategies. - -## Resize Modes - -```python -from arepy_ui import UIConfig, ResizeMode - -config = UIConfig(resize_mode=ResizeMode.RESPONSIVE) -ui_manager = UIManager.from_engine(game, config=config) -``` - -### RESPONSIVE (Default) - -Layout recalculates to fit new window size. Best for desktop applications. - -```python -UIConfig(resize_mode=ResizeMode.RESPONSIVE) -``` - -### FIXED - -Layout stays at reference resolution. UI does not scale or reflow. - -```python -UIConfig( - resize_mode=ResizeMode.FIXED, - reference_width=1280, - reference_height=720, -) -``` - -### SCALE_FIT - -Layout scales to fit window, maintaining aspect ratio. May have letterboxing. - -```python -UIConfig( - resize_mode=ResizeMode.SCALE_FIT, - reference_width=1280, - reference_height=720, -) -``` - -### SCALE_FILL - -Layout scales to fill window, maintaining aspect ratio. May crop edges. - -```python -UIConfig( - resize_mode=ResizeMode.SCALE_FILL, - reference_width=1280, - reference_height=720, -) -``` - -## Resize Callback - -React to window size changes: - -```python -def on_resize(width: int, height: int): - print(f"Window resized to {width}x{height}") - -config = UIConfig( - resize_mode=ResizeMode.RESPONSIVE, - on_resize=on_resize, -) -``` - -## Layout Callbacks - -Execute code before/after layout recalculation: - -```python -config = UIConfig( - on_before_layout=lambda: print("Recalculating..."), - on_after_layout=lambda: print("Layout complete"), -) -``` - -## Debouncing - -Delay layout recalculation during rapid resizing: - -```python -config = UIConfig( - layout_debounce_ms=100, # Wait 100ms after last resize -) -``` - -## Helpers - -```python -# Get reference resolution -ref_w, ref_h = ui_manager.get_reference_size() - -# Get current window size -curr_w, curr_h = ui_manager.get_current_size() - -# Get scale transform (for SCALE_* modes) -transform = ui_manager.get_scale_transform() -``` - -## Examples - -### Responsive Game UI - -```python -config = UIConfig( - resize_mode=ResizeMode.RESPONSIVE, - on_resize=lambda w, h: update_ui_layout(w, h), -) -``` - -### Fixed Resolution Game - -```python -config = UIConfig( - resize_mode=ResizeMode.SCALE_FIT, - reference_width=1920, - reference_height=1080, -) -``` - -### Mobile-Style Scaling - -```python -config = UIConfig( - resize_mode=ResizeMode.SCALE_FILL, - reference_width=720, - reference_height=1280, -) -``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md deleted file mode 100644 index 8a0e400..0000000 --- a/docs/getting-started/installation.md +++ /dev/null @@ -1,26 +0,0 @@ -# Installation - -## From PyPI - -```bash -pip install arepy-ui -``` - -## With uv - -```bash -uv add arepy-ui -``` - -## Development - -```bash -git clone https://github.com/Scr44gr/arepy-ui.git -cd arepy-ui -pip install -e . -``` - -## Requirements - -- Python 3.10+ -- [arepy](https://github.com/Scr44gr/arepy) 0.4.0+ diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md deleted file mode 100644 index 163dcc6..0000000 --- a/docs/getting-started/quickstart.md +++ /dev/null @@ -1,107 +0,0 @@ -# Quick Start - -This guide will help you create your first UI with arepy-ui. - -## Basic Setup - -```python -from arepy import ArepyEngine, SystemPipeline -from arepy_ui import UIManager, UIConfig, Node, Text, Button, Style, Color, Unit - -ui_manager: UIManager = None - -def create_ui() -> Node: - """Create the UI tree.""" - return Node( - style=Style( - width=Unit.percent(100), - height=Unit.percent(100), - background_color=Color(30, 30, 30), - ), - children=[ - Text("Hello, arepy-ui!", size=24, color=Color(255, 255, 255)), - Button("Click me", on_click=lambda: print("Clicked!")), - ], - ) - -def setup(game: ArepyEngine): - """Initialize the UI manager.""" - global ui_manager - ui_manager = UIManager.from_engine(game, config=UIConfig()) - ui_manager.set_root(create_ui()) - game.add_resource(ui_manager) - -def update(dt: float): - """Update UI state.""" - ui_manager.update(dt) - -def render(): - """Render the UI.""" - ui_manager.render() - -if __name__ == "__main__": - game = ArepyEngine(title="UI Example", width=800, height=600) - world = game.create_world("main") - world.add_startup_system(setup) - world.add_system(SystemPipeline.UPDATE, update) - world.add_system(SystemPipeline.RENDER, render) - game.set_current_world("main") - game.run() -``` - -## Understanding the Code - -### 1. UIManager - -The `UIManager` is the central controller for your UI. Use `from_engine()` to create it: - -```python -ui_manager = UIManager.from_engine(game, config=UIConfig()) -``` - -This automatically configures the runtime with the engine's renderer, input, and display. - -### 2. Nodes - -Everything in arepy-ui is a `Node`. Nodes form a tree structure: - -```python -root = Node( - style=Style(...), - children=[ - child1, - child2, - ], -) -``` - -### 3. Styles - -Styles define how nodes look and are laid out: - -```python -Style( - width=Unit.px(200), - height=Unit.px(100), - background_color=Color(255, 0, 0), - padding=Spacing.all(10), -) -``` - -### 4. Game Loop - -The UI needs to be updated and rendered each frame: - -```python -def update(dt: float): - ui_manager.update(dt) - -def render(): - ui_manager.render() -``` - -## Next Steps - -- Learn about [Layout](../layout/flexbox.md) -- Explore [Components](../components/index.md) -- Check out the [Examples](../examples.md) diff --git a/docs/index.md b/docs/index.md index e0cd14c..83c6196 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ UI library for [Arepy](https://github.com/Scr44gr/arepy) game engine. Build game - :material-file-document: **Declarative Markup** - HTML-like `.aui` and CSS-like `.acss` files - :material-palette: **Theme Support** - CSS variables with light/dark variants - :material-cursor-move: **Drag & Drop** - Built-in drag and drop support -- :material-animation: **Animations** - Tweening system with easing functions +- :material-animation: **Animations** - Sequenced animator and timers with easing functions - :material-text: **Custom Fonts** - TTF/OTF font support - :material-monitor: **Responsive** - Multiple resize modes @@ -107,26 +107,31 @@ UI library for [Arepy](https://github.com/Scr44gr/arepy) game engine. Build game handlers = {"greet": lambda: print("Clicked!")} result = load_aui("ui.aui", handlers=handlers) - ui_manager.set_root(result.root) + + if result.success and result.root is not None: + ui_manager.set_root(result.root) + else: + for error in result.errors: + print(error) ``` ## Components | Component | Description | |-----------|-------------| -| [`Text`](components/text.md) | Text rendering with custom fonts | -| [`Button`](components/button.md) | Clickable button with hover states | -| [`TextInput`](components/textinput.md) | Text field with cursor and selection | -| [`Checkbox`](components/checkbox.md) | Toggle with label | -| [`Slider`](components/slider.md) | Horizontal/vertical value slider | -| [`Select`](components/select.md) | Dropdown menu | -| [`Tabs`](components/tabs.md) | Tabbed container | -| [`Image`](components/image.md) | Texture display with fit modes | -| [`ScrollView`](components/scrollview.md) | Scrollable container | -| [`ProgressBar`](components/progressbar.md) | Progress indicator | -| [`Canvas`](components/canvas.md) | Custom drawing | -| [`Video`](components/video.md) | Video playback | -| [`ColorPicker`](components/colorpicker.md) | HSV color selection | +| [`Text`](reference/components/text.md) | Text rendering with custom fonts | +| [`Button`](reference/components/button.md) | Clickable button with hover states | +| [`TextInput`](reference/components/textinput.md) | Text field with cursor and selection | +| [`Checkbox`](reference/components/checkbox.md) | Toggle with label | +| [`Slider`](reference/components/slider.md) | Horizontal or vertical value slider | +| [`Select`](reference/components/select.md) | Dropdown menu | +| [`Tabs`](reference/components/tabs.md) | Tabbed container | +| [`Image`](reference/components/image.md) | Texture display with fit modes | +| [`ScrollView`](reference/components/scrollview.md) | Scrollable container | +| [`ProgressBar`](reference/components/progressbar.md) | Progress indicator | +| [`Canvas`](reference/components/canvas.md) | Custom drawing | +| [`Video`](reference/components/video.md) | Video playback | +| [`ColorPicker`](reference/components/colorpicker.md) | HSV color selection | ## Layout System @@ -168,18 +173,12 @@ from arepy_ui.markup import set_theme set_theme("light") ``` - -

- Theme switching -

+Theme variants can be switched at runtime with `set_theme(...)` after loading global styles. ## UI Debugger Built-in visual debugger for inspecting layouts (press F3): - -![UI Debugger](assets/debugger-overview.png) - | Key | Action | |-----|--------| | `F3` | Toggle debugger | @@ -187,27 +186,16 @@ Built-in visual debugger for inspecting layouts (press F3): | `F5` | Toggle padding visualization | | `F6` | Toggle component tree | -[:octicons-arrow-right-24: Debugger Documentation](features/debugger.md) +[:octicons-arrow-right-24: Debugger Documentation](resources/tools/debugger.md) ## Examples - - - - - - - -
-Components
-Components -
-Drag & Drop
-Drag & Drop -
-Inventory
-Inventory -
+Examples available in the repository: + +- `uv run examples/demo_components.py` +- `uv run examples/demo_drag.py` +- `uv run examples/colorpicker_demo.py` +- `uv run examples/demo_video.py` ```bash uv run examples/demo_components.py @@ -225,7 +213,7 @@ uv run examples/colorpicker_demo.py Install arepy-ui with pip - [:octicons-arrow-right-24: Installation](getting-started/installation.md) + [:octicons-arrow-right-24: Installation](learn/getting-started.md) - :material-rocket-launch:{ .lg .middle } **Quick Start** @@ -233,7 +221,7 @@ uv run examples/colorpicker_demo.py Build your first UI in 5 minutes - [:octicons-arrow-right-24: Quick Start](getting-started/quickstart.md) + [:octicons-arrow-right-24: Quick Start](learn/getting-started.md) - :material-puzzle:{ .lg .middle } **Components** @@ -241,7 +229,7 @@ uv run examples/colorpicker_demo.py Explore available UI components - [:octicons-arrow-right-24: Components](components/index.md) + [:octicons-arrow-right-24: Components](reference/components/index.md) - :material-file-document:{ .lg .middle } **AUI Markup** @@ -249,6 +237,6 @@ uv run examples/colorpicker_demo.py Declarative UI with markup files - [:octicons-arrow-right-24: AUI Markup](features/markup.md) + [:octicons-arrow-right-24: AUI Markup](reference/markup/index.md) diff --git a/docs/internal-insights.md b/docs/internal-insights.md deleted file mode 100644 index 7a556c1..0000000 --- a/docs/internal-insights.md +++ /dev/null @@ -1,344 +0,0 @@ -# Internal Architecture Insights - -> This document provides internal insights about arepy-ui's architecture, design decisions, and implementation details. Use this for README inspiration and contributor onboarding. - ---- - -## Core Philosophy - -### "Everything is a Node" - -arepy-ui follows a unified component model where every UI element inherits from `Node`. This provides: - -- **Consistent API**: All components share the same base properties (`style`, `children`, `id`) -- **Composability**: Any component can contain any other component -- **Predictable layout**: Single flexbox engine handles all positioning - -```python -# Button is a Node, Text is a Node, containers are Nodes -button = Button("Click", children=[Icon("save")]) # Nest anything -``` - -### Flexbox-First Layout - -Instead of inventing a new layout system, arepy-ui implements **CSS Flexbox** semantics: - -- Familiar to web developers -- Well-documented behavior -- Powerful responsive layouts -- Battle-tested algorithm (from `stretch` Rust crate concepts) - ---- - -## Architecture Highlights - -### UIManager - The Central Controller - -``` -┌──────────────────────────────────────────────┐ -│ UIManager │ -├──────────────────────────────────────────────┤ -│ ┌─────────────┐ ┌─────────────┐ │ -│ │ Runtime │ │ Layout │ │ -│ │ (renderer, │ │ Engine │ │ -│ │ input) │ │ (flexbox) │ │ -│ └─────────────┘ └─────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────┐ │ -│ │ Node Tree │ │ -│ │ ┌───────┐ │ │ -│ │ │ Root │ │ │ -│ │ └───┬───┘ │ │ -│ │ ├── Child 1 │ │ -│ │ ├── Child 2 │ │ -│ │ └── ... │ │ -│ └─────────────────────────────────────────┘ │ -└──────────────────────────────────────────────┘ -``` - -**Responsibilities:** -- Owns the node tree -- Coordinates layout computation -- Routes input events to nodes -- Manages render order and z-index - -### Computed Layout System - -Layout happens in two phases: - -1. **Style Phase**: User-defined styles are collected -2. **Compute Phase**: Flexbox algorithm calculates `computed_x`, `computed_y`, `computed_width`, `computed_height` - -```python -node.style.width = Unit.percent(50) # User intent -# After layout: -node.computed_width # Actual pixels: 400.0 -``` - -### Event Propagation - -``` -Mouse Event → UIManager → Hit Testing → Node.handle_event() - ↓ - Bubble up to parent (optional) -``` - -Events propagate **top-down** for hit testing, then **bottom-up** for handling (like DOM events). - ---- - -## Markup System Architecture - -### Parser Pipeline - -``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ .aui file │────▶│ AUI Parser │────▶│ AST Tree │ -└──────────────┘ └──────────────┘ └──────────────┘ - │ -┌──────────────┐ ┌──────────────┐ ▼ -│ .acss file │────▶│ CSS Parser │────▶┌──────────────┐ -└──────────────┘ └──────────────┘ │ Builder │ - │ (combines) │ - └──────┬───────┘ - ▼ - ┌──────────────┐ - │ Node Tree │ - └──────────────┘ -``` - -### Cython Acceleration - -Critical path functions are implemented in Cython: - -``` -arepy_ui/markup/ -├── parsers/ -│ ├── _aui_parser.pyx # Cython AUI parser -│ ├── _css_parser.pyx # Cython CSS parser -│ └── *.py # Pure Python fallbacks -``` - -**Fallback Strategy:** -```python -try: - from ._aui_parser import parse_aui # Try Cython -except ImportError: - from .aui_parser import parse_aui # Fallback to Python -``` - -### Global Style Registry (Singleton) - -```python -GlobalStyleRegistry -├── _stylesheets[] # List of loaded stylesheets -├── _variables # ThemeVariables instance -│ ├── _base{} # :root variables -│ ├── _variants{} # :root.light, :root.dark -│ └── _active_variant # Currently active theme -└── _cache{} # Resolved style cache -``` - -**Variable Resolution:** -``` -var(--accent) → Check active variant → Fallback to base → Return value -``` - ---- - -## Component Design Patterns - -### Stateful Components - -Components like `TextInput`, `Slider`, `ColorPicker` maintain internal state: - -```python -class Slider(Node): - def __init__(self, ...): - self._value = value - self._dragging = False - - @property - def value(self): - return self._value - - @value.setter - def value(self, v): - self._value = clamp(v, self.min_value, self.max_value) - self._notify_change() -``` - -### Streaming Textures (Video, ColorPicker) - -For dynamic textures, components use PBO streaming: - -```python -# Create streaming texture once -self._texture_id = renderer.create_streaming_texture(width, height, 4) - -# Update pixels each frame (double-buffered) -renderer.update_streaming_texture(self._texture_id, pixel_bytes) -``` - -### Drag State Machine - -``` -IDLE → (mouse down in handle) → DRAGGING → (mouse up) → IDLE - │ │ - └── Update value ◄──────────┘ -``` - ---- - -## Performance Insights - -### Layout Caching - -Layout is only recomputed when: -- Node tree structure changes (`add_child`, `remove_child`) -- Style properties change -- Window resize - -```python -# Internal dirty flag -if self._layout_dirty: - self._compute_layout() - self._layout_dirty = False -``` - -### Text Rendering Optimization - -Text is pre-rendered to texture: - -``` -Text "Hello" → Render to texture → Cache → Blit each frame -``` - -Font atlas caching for repeated characters. - -### Event Throttling - -Input events are batched per frame: -- Mouse position sampled once per `update()` -- Click events debounced -- Scroll events coalesced - ---- - -## Extension Points - -### Custom Components - -Extend `Node` to create custom components: - -```python -class MyWidget(Node): - def __init__(self, **kwargs): - super().__init__(**kwargs) - - def update(self, dt: float): - super().update(dt) - # Custom logic - - def render(self): - super().render() - # Custom rendering -``` - -### Custom Markup Tags - -Register new tags in the builder: - -```python -TAG_HANDLERS["my-widget"] = lambda attrs, children: MyWidget(**attrs) -``` - -### Theming - -Create design tokens in globals.acss: - -```css -:root { - --brand-primary: #6c5ce7; - --brand-secondary: #a29bfe; -} - -:root.corporate { - --brand-primary: #0066cc; - --brand-secondary: #3399ff; -} -``` - ---- - -## Testing Architecture - -### Mock Runtime - -Tests use `MockRuntime` to simulate engine without graphics: - -```python -mock_runtime = MockRuntime(width=800, height=600) -ui_manager = UIManager(runtime=mock_runtime) -``` - -### Property-Based Testing - -Critical algorithms use hypothesis: - -```python -@given(width=st.integers(1, 1000), children=st.integers(0, 20)) -def test_layout_never_overflows(width, children): - ... -``` - -### Coverage Targets - -| Module | Target | Current | -|--------|--------|---------| -| `core/` | 90% | 95% | -| `components/` | 85% | 88% | -| `markup/` | 80% | 82% | -| Overall | 80% | 81% | - ---- - -## Design Decisions - -### Why Flexbox? - -- **Familiar**: Most developers know CSS flexbox -- **Powerful**: Handles 95% of UI layouts -- **Responsive**: Works at any resolution -- **Tested**: Algorithm is well-understood - -### Why Not Immediate Mode? - -Retained mode (node tree) allows: -- Automatic layout -- State management -- Animation system -- Declarative markup - -### Why Cython? - -- **10-50x** faster parsing than pure Python -- **Zero-copy** integration with C libraries -- **Optional**: Pure Python fallback always works - -### Why Streaming Textures? - -For Video and ColorPicker: -- PBO uploads don't block CPU -- Double buffering prevents tearing -- GPU-accelerated scaling - ---- - -## Future Directions - -- [ ] **Web export**: Compile to WASM for browser -- [ ] **Visual editor**: Drag-and-drop UI builder -- [ ] **Hot reload**: Live preview during development -- [ ] **Accessibility**: Screen reader support -- [ ] **Localization**: Built-in i18n system diff --git a/docs/layout/flexbox.md b/docs/layout/flexbox.md deleted file mode 100644 index 9ff703d..0000000 --- a/docs/layout/flexbox.md +++ /dev/null @@ -1,139 +0,0 @@ -# Flexbox Layout - -arepy-ui uses a flexbox-based layout system similar to CSS flexbox. - -## Flex Direction - -Controls the main axis direction. - -```python -from arepy_ui import Style, FlexDirection - -# Horizontal row (default) -Style(flex_direction=FlexDirection.ROW) - -# Vertical column -Style(flex_direction=FlexDirection.COLUMN) - -# Reversed -Style(flex_direction=FlexDirection.ROW_REVERSE) -Style(flex_direction=FlexDirection.COLUMN_REVERSE) -``` - -## Justify Content - -Aligns children along the **main axis**. - -```python -from arepy_ui import JustifyContent - -Style(justify_content=JustifyContent.FLEX_START) # Start (default) -Style(justify_content=JustifyContent.FLEX_END) # End -Style(justify_content=JustifyContent.CENTER) # Center -Style(justify_content=JustifyContent.SPACE_BETWEEN) # Space between items -Style(justify_content=JustifyContent.SPACE_AROUND) # Space around items -Style(justify_content=JustifyContent.SPACE_EVENLY) # Equal space -``` - -## Align Items - -Aligns children along the **cross axis**. - -```python -from arepy_ui import AlignItems - -Style(align_items=AlignItems.FLEX_START) # Start -Style(align_items=AlignItems.FLEX_END) # End -Style(align_items=AlignItems.CENTER) # Center -Style(align_items=AlignItems.STRETCH) # Stretch to fill (default) -``` - -## Gap - -Space between children. - -```python -Style(gap=10) # 10px gap between all children -``` - -## Flex Grow / Shrink - -Control how children grow or shrink. - -```python -# Child grows to fill available space -child.style.flex_grow = 1 - -# Child shrinks if needed -child.style.flex_shrink = 1 -``` - -## Examples - -### Centered Content - -```python -Node( - style=Style( - width=Unit.percent(100), - height=Unit.percent(100), - justify_content=JustifyContent.CENTER, - align_items=AlignItems.CENTER, - ), - children=[Text("Centered!")], -) -``` - -### Horizontal Menu - -```python -Node( - style=Style( - flex_direction=FlexDirection.ROW, - justify_content=JustifyContent.SPACE_BETWEEN, - padding=Spacing.symmetric(0, 20), - ), - children=[ - Button("Home", ...), - Button("Play", ...), - Button("Settings", ...), - ], -) -``` - -### Sidebar Layout - -```python -Node( - style=Style( - flex_direction=FlexDirection.ROW, - width=Unit.percent(100), - height=Unit.percent(100), - ), - children=[ - # Sidebar (fixed width) - Node( - style=Style(width=Unit.px(200), background_color=Color(40, 40, 40)), - children=[...], - ), - # Main content (fills remaining space) - Node( - style=Style(flex_grow=1), - children=[...], - ), - ], -) -``` - -### Card Grid - -```python -Node( - style=Style( - flex_direction=FlexDirection.ROW, - flex_wrap=True, # Wrap to next line - gap=10, - ), - children=[create_card(item) for item in items], -) -``` diff --git a/docs/layout/positioning.md b/docs/layout/positioning.md deleted file mode 100644 index 3bde2d5..0000000 --- a/docs/layout/positioning.md +++ /dev/null @@ -1,113 +0,0 @@ -# Positioning - -Control how nodes are positioned in the layout. - -## Position Types - -### Relative (Default) - -Node participates in normal flow. - -```python -from arepy_ui import PositionType - -Style(position=PositionType.RELATIVE) -``` - -### Absolute - -Node is removed from flow and positioned relative to its parent. - -```python -Style( - position=PositionType.ABSOLUTE, - top=Unit.px(10), - left=Unit.px(10), -) -``` - -## Position Properties - -```python -Style( - position=PositionType.ABSOLUTE, - top=Unit.px(10), # Distance from top - right=Unit.px(10), # Distance from right - bottom=Unit.px(10), # Distance from bottom - left=Unit.px(10), # Distance from left -) -``` - -## Examples - -### Corner Badge - -```python -Node( - style=Style(width=Unit.px(100), height=Unit.px(100)), - children=[ - # Badge in top-right corner - Node( - style=Style( - position=PositionType.ABSOLUTE, - top=Unit.px(-5), - right=Unit.px(-5), - width=Unit.px(20), - height=Unit.px(20), - background_color=Color(255, 0, 0), - border_radius=10, - ), - ), - ], -) -``` - -### Overlay - -```python -Node( - style=Style(width=Unit.percent(100), height=Unit.percent(100)), - children=[ - # Full-screen overlay - Node( - style=Style( - position=PositionType.ABSOLUTE, - top=Unit.px(0), - left=Unit.px(0), - right=Unit.px(0), - bottom=Unit.px(0), - background_color=Color(0, 0, 0, 150), - ), - ), - ], -) -``` - -### Fixed HUD Element - -```python -# Health bar in top-left -Node( - style=Style( - position=PositionType.ABSOLUTE, - top=Unit.px(20), - left=Unit.px(20), - ), - children=[ProgressBar(value=0.8, ...)], -) -``` - -### Tooltip - -```python -Node( - style=Style( - position=PositionType.ABSOLUTE, - top=Unit.px(mouse_y + 10), - left=Unit.px(mouse_x + 10), - padding=Spacing.all(5), - background_color=Color(30, 30, 30, 230), - ), - children=[Text("Tooltip text", size=12)], -) -``` diff --git a/docs/layout/spacing.md b/docs/layout/spacing.md deleted file mode 100644 index 7cbf546..0000000 --- a/docs/layout/spacing.md +++ /dev/null @@ -1,88 +0,0 @@ -# Spacing - -Spacing controls padding (inner space) and margin (outer space). - -## Spacing Class - -```python -from arepy_ui import Spacing - -# All sides equal -Spacing.all(10) # 10px on all sides - -# Symmetric (vertical, horizontal) -Spacing.symmetric(10, 20) # 10px top/bottom, 20px left/right - -# Individual sides (top, right, bottom, left) -Spacing(10, 20, 10, 20) - -# Named properties -spacing = Spacing(top=10, right=20, bottom=10, left=20) -``` - -## Padding - -Inner space between the node's border and its content. - -```python -Style(padding=Spacing.all(10)) -Style(padding=Spacing.symmetric(5, 15)) -Style(padding=Spacing(10, 20, 10, 20)) -``` - -## Margin - -Outer space between the node and its siblings. - -```python -Style(margin=Spacing.all(5)) -Style(margin=Spacing.symmetric(10, 0)) # Vertical only -Style(margin=Spacing(0, 0, 10, 0)) # Bottom only -``` - -## Examples - -### Padded Container - -```python -Node( - style=Style( - padding=Spacing.all(20), - background_color=Color(40, 40, 40), - ), - children=[...], -) -``` - -### Spaced Items - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN), - children=[ - Text("Item 1", style=Style(margin=Spacing(0, 0, 10, 0))), - Text("Item 2", style=Style(margin=Spacing(0, 0, 10, 0))), - Text("Item 3"), - ], -) -``` - -### Centered with Margin - -```python -Node( - style=Style( - width=Unit.px(300), - margin=Spacing.symmetric(0, auto), # Center horizontally - ), -) -``` - -### Button with Padding - -```python -Button( - "Click", - style=Style(padding=Spacing.symmetric(10, 20)), -) -``` diff --git a/docs/layout/units.md b/docs/layout/units.md deleted file mode 100644 index 66939fe..0000000 --- a/docs/layout/units.md +++ /dev/null @@ -1,100 +0,0 @@ -# Units - -Units define sizes and positions in the layout system. - -## Available Units - -### Pixels - -Fixed size in pixels. - -```python -from arepy_ui import Unit - -Unit.px(100) # 100 pixels -Unit.px(50.5) # Decimal values supported -``` - -### Percent - -Percentage of parent size. - -```python -Unit.percent(50) # 50% of parent -Unit.percent(100) # Full parent size -``` - -### Viewport - -Percentage of screen/window size. - -```python -Unit.vw(100) # 100% of window width -Unit.vh(50) # 50% of window height -``` - -### Auto - -Automatic sizing based on content or available space. - -```python -Unit.auto() # Size to content or fill available space -``` - -## Usage - -```python -from arepy_ui import Style, Unit - -Style( - width=Unit.px(200), - height=Unit.percent(100), -) -``` - -## Examples - -### Fixed Size Box - -```python -Node( - style=Style( - width=Unit.px(300), - height=Unit.px(200), - ), -) -``` - -### Full Screen - -```python -Node( - style=Style( - width=Unit.percent(100), - height=Unit.percent(100), - ), -) -``` - -### Responsive Width - -```python -Node( - style=Style( - width=Unit.percent(80), # 80% of parent - max_width=Unit.px(600), # But max 600px - ), -) -``` - -### Auto Height - -```python -Node( - style=Style( - width=Unit.px(200), - height=Unit.auto(), # Height based on content - ), - children=[...], -) -``` diff --git a/docs/learn/first-ui.md b/docs/learn/first-ui.md index 6cb83d7..6fc3c00 100644 --- a/docs/learn/first-ui.md +++ b/docs/learn/first-ui.md @@ -9,9 +9,6 @@ A simple main menu with: - Start and Quit buttons - A settings panel - -![Final Menu](../../assets/examples/first-ui-result.png) - ## Step 1: Setup First, create a new Python file: @@ -99,7 +96,7 @@ def setup(game: ArepyEngine): game.add_resource(ui_manager) ``` -Now you should see "My Awesome Game" centered on screen! 🎉 +Now you should see "My Awesome Game" centered on screen. ## Step 4: Add Buttons @@ -172,7 +169,7 @@ Button( Here's everything together: ```python -from arepy import ArepyEngine +from arepy import ArepyEngine, SystemPipeline from arepy_ui import UIManager, UIConfig, Node, Text, Button, Style, Color, Unit, FlexDirection, JustifyContent, AlignItems ui_manager: UIManager = None diff --git a/docs/learn/getting-started.md b/docs/learn/getting-started.md index 303854d..e7db288 100644 --- a/docs/learn/getting-started.md +++ b/docs/learn/getting-started.md @@ -16,7 +16,7 @@ uv add arepy-ui ## Requirements -- Python 3.10+ +- Python 3.11+ - [ArepyEngine](https://github.com/arepyui/arepy) ## Quick Start @@ -70,12 +70,9 @@ game.run() Run it: ```bash -python main.py +uv run main.py ``` - -![Hello World](../assets/examples/hello-world.png) - ## What's Next?
@@ -153,12 +150,15 @@ def setup(game: ArepyEngine): ui_manager = UIManager.from_engine(game, config=UIConfig()) # Load from files - root = load_aui("menu.aui", context={ + result = load_aui("menu.aui", handlers={ "say_hello": say_hello, }) - - ui_manager.set_root(root) + + if result.success and result.root is not None: + ui_manager.set_root(result.root) game.add_resource(ui_manager) ``` +If parsing fails, inspect `result.errors` before replacing the current UI tree. + [:octicons-arrow-right-24: Learn more about AUI Markup](../reference/markup/aui.md) diff --git a/docs/learn/index.md b/docs/learn/index.md index e683deb..6f6aaed 100644 --- a/docs/learn/index.md +++ b/docs/learn/index.md @@ -12,7 +12,7 @@ Welcome to the arepy-ui learning path! This section will teach you everything yo Get arepy-ui installed in your project in under 2 minutes. - [:octicons-arrow-right-24: Install now](installation.md) + [:octicons-arrow-right-24: Install now](getting-started.md#installation) - :material-rocket-launch:{ .lg .middle } **Quick Start** @@ -20,7 +20,7 @@ Welcome to the arepy-ui learning path! This section will teach you everything yo Create your first UI in 5 minutes with a simple example. - [:octicons-arrow-right-24: Quick Start](quickstart.md) + [:octicons-arrow-right-24: Quick Start](getting-started.md#quick-start) - :material-school:{ .lg .middle } **Your First UI** @@ -45,8 +45,8 @@ graph LR ### 1. Getting Started Start here if you're new to arepy-ui: -- [Installation](installation.md) - Install the library -- [Quick Start](quickstart.md) - Your first "Hello World" +- [Installation](getting-started.md#installation) - Install the library +- [Quick Start](getting-started.md#quick-start) - Your first "Hello World" - [Your First UI](first-ui.md) - Build a complete interface ### 2. Core Concepts diff --git a/docs/learn/installation.md b/docs/learn/installation.md deleted file mode 100644 index 8a0e400..0000000 --- a/docs/learn/installation.md +++ /dev/null @@ -1,26 +0,0 @@ -# Installation - -## From PyPI - -```bash -pip install arepy-ui -``` - -## With uv - -```bash -uv add arepy-ui -``` - -## Development - -```bash -git clone https://github.com/Scr44gr/arepy-ui.git -cd arepy-ui -pip install -e . -``` - -## Requirements - -- Python 3.10+ -- [arepy](https://github.com/Scr44gr/arepy) 0.4.0+ diff --git a/docs/learn/quickstart.md b/docs/learn/quickstart.md deleted file mode 100644 index 163dcc6..0000000 --- a/docs/learn/quickstart.md +++ /dev/null @@ -1,107 +0,0 @@ -# Quick Start - -This guide will help you create your first UI with arepy-ui. - -## Basic Setup - -```python -from arepy import ArepyEngine, SystemPipeline -from arepy_ui import UIManager, UIConfig, Node, Text, Button, Style, Color, Unit - -ui_manager: UIManager = None - -def create_ui() -> Node: - """Create the UI tree.""" - return Node( - style=Style( - width=Unit.percent(100), - height=Unit.percent(100), - background_color=Color(30, 30, 30), - ), - children=[ - Text("Hello, arepy-ui!", size=24, color=Color(255, 255, 255)), - Button("Click me", on_click=lambda: print("Clicked!")), - ], - ) - -def setup(game: ArepyEngine): - """Initialize the UI manager.""" - global ui_manager - ui_manager = UIManager.from_engine(game, config=UIConfig()) - ui_manager.set_root(create_ui()) - game.add_resource(ui_manager) - -def update(dt: float): - """Update UI state.""" - ui_manager.update(dt) - -def render(): - """Render the UI.""" - ui_manager.render() - -if __name__ == "__main__": - game = ArepyEngine(title="UI Example", width=800, height=600) - world = game.create_world("main") - world.add_startup_system(setup) - world.add_system(SystemPipeline.UPDATE, update) - world.add_system(SystemPipeline.RENDER, render) - game.set_current_world("main") - game.run() -``` - -## Understanding the Code - -### 1. UIManager - -The `UIManager` is the central controller for your UI. Use `from_engine()` to create it: - -```python -ui_manager = UIManager.from_engine(game, config=UIConfig()) -``` - -This automatically configures the runtime with the engine's renderer, input, and display. - -### 2. Nodes - -Everything in arepy-ui is a `Node`. Nodes form a tree structure: - -```python -root = Node( - style=Style(...), - children=[ - child1, - child2, - ], -) -``` - -### 3. Styles - -Styles define how nodes look and are laid out: - -```python -Style( - width=Unit.px(200), - height=Unit.px(100), - background_color=Color(255, 0, 0), - padding=Spacing.all(10), -) -``` - -### 4. Game Loop - -The UI needs to be updated and rendered each frame: - -```python -def update(dt: float): - ui_manager.update(dt) - -def render(): - ui_manager.render() -``` - -## Next Steps - -- Learn about [Layout](../layout/flexbox.md) -- Explore [Components](../components/index.md) -- Check out the [Examples](../examples.md) diff --git a/docs/learn/tutorials/game-menu.md b/docs/learn/tutorials/game-menu.md index 15f6c03..5d6b0ab 100644 --- a/docs/learn/tutorials/game-menu.md +++ b/docs/learn/tutorials/game-menu.md @@ -2,9 +2,6 @@ Learn how to build a professional-looking game menu with arepy-ui. - -![Game Menu](../../assets/examples/game-menu-final.png) - ## What We'll Build - Title screen with game logo @@ -36,6 +33,12 @@ def show_options(): global current_panel current_panel = "options" ui_manager.set_root(create_options_menu()) + +def update(game: ArepyEngine): + ui_manager.update(game.get_delta_time()) + +def draw(): + ui_manager.render() ``` ## Step 2: Define Styles @@ -173,14 +176,22 @@ def on_volume_change(value): print(f"Volume: {value}%") ``` -## Step 5: Complete Code +## Step 5: Wire It Into the Engine -See the full example in `examples/menu_tutorial.py` or run: +Once you have `create_main_menu()` and `create_options_menu()`, hook the manager into your Arepy world: -```bash -uv run examples/demo_menu.py +```python +game = ArepyEngine(title="Space Shooter", width=1280, height=720) +world = game.create_world("main") +world.add_startup_system(setup) +world.add_system(SystemPipeline.UPDATE, update) +world.add_system(SystemPipeline.RENDER, draw) +game.set_current_world("main") +game.run() ``` +This tutorial is intentionally assembled from small pieces. Use it as a pattern for your own project rather than as a reference to a missing demo file. + ## Using AUI Markup You can also build this menu with markup files: @@ -252,6 +263,22 @@ You can also build this menu with markup files: } ``` +**main.py** +```python +from arepy_ui.markup import load_aui + +handlers = { + "start_game": start_game, + "show_options": show_options, + "quit_game": quit_game, +} + +def show_main_menu(): + result = load_aui("menu.aui", handlers=handlers) + if result.success and result.root is not None: + ui_manager.set_root(result.root) +``` + ## Tips !!! tip "Organize Styles" diff --git a/docs/learn/tutorials/inventory.md b/docs/learn/tutorials/inventory.md index 5c01d06..cfd5d19 100644 --- a/docs/learn/tutorials/inventory.md +++ b/docs/learn/tutorials/inventory.md @@ -2,9 +2,6 @@ Build a complete inventory system with drag and drop! - -![Inventory System](../../assets/examples/inventory-demo.gif) - ## What We'll Build - A grid-based inventory @@ -42,7 +39,7 @@ ITEMS = { Create a reusable slot: ```python -from arepy_ui import Node, Text, Image, Style, Color, Unit, FlexDirection, JustifyContent, AlignItems +from arepy_ui import Node, Text, Image, Style, Color, Unit, FlexDirection, JustifyContent, AlignItems, PositionType from arepy_ui.components import DropZone, Draggable SLOT_SIZE = 64 @@ -99,7 +96,7 @@ def InventorySlot( size=10, color=Color(255, 255, 255), style=Style( - position="absolute", + position=PositionType.ABSOLUTE, right=Unit.px(2), bottom=Unit.px(2), ), diff --git a/docs/learn/tutorials/theming.md b/docs/learn/tutorials/theming.md index ae8e2fe..3f0fd75 100644 --- a/docs/learn/tutorials/theming.md +++ b/docs/learn/tutorials/theming.md @@ -2,9 +2,6 @@ Learn how to add light/dark mode to your game UI! - -![Theme Switching](../../assets/theme-switch.gif) - ## Overview arepy-ui supports CSS variables with theme variants, making it easy to switch between color schemes at runtime. diff --git a/docs/reference/api/animations.md b/docs/reference/api/animations.md index c16acee..d04423e 100644 --- a/docs/reference/api/animations.md +++ b/docs/reference/api/animations.md @@ -1,166 +1,108 @@ -# Animations +# Animations and Transitions -Animate UI properties with the built-in tweening system. +This page documents the motion and timing primitives currently available in the library. -## Basic Animation +## What Exists -```python -from arepy_ui import Animation, Easing - -anim = Animation( - target=my_node, - property="style.background_color.r", - from_value=0, - to_value=255, - duration=0.5, - easing=Easing.EASE_OUT, -) - -ui_manager.animator.add(anim) -``` - -## Animation Class +- `Animation` and `Animator` for chained waits, tweens, and callbacks. +- `Timer` and `Timers` for `after(...)` and `every(...)` scheduling. +- `Timeline`, `KeyFrame`, and `PropertyAnimation` for keyframed sequences. +- `FadeTransition` and `CircleReveal` for full-screen transition effects. +- `SequenceRunner` for orchestrating timelines and transitions. -```python -Animation( - target=node, # Target object - property="path.to.prop", # Property path (dot notation) - from_value=0, # Start value - to_value=100, # End value - duration=1.0, # Duration in seconds - easing=Easing.LINEAR, # Easing function - on_complete=callback, # Called when done -) -``` +`Timeline` and `SequenceRunner` now sit on the same scheduler model as `Animator` and `Timers`, so callback timing, restart behavior, and chained motion use one consistent runtime path. -## Easing Functions +## Sequenced Animator ```python from arepy_ui import Easing -Easing.LINEAR # Constant speed -Easing.EASE_IN # Slow start -Easing.EASE_OUT # Slow end -Easing.EASE_IN_OUT # Slow start and end -Easing.EASE_IN_QUAD # Quadratic ease in -Easing.EASE_OUT_QUAD # Quadratic ease out -Easing.EASE_IN_OUT_QUAD -Easing.EASE_OUT_BOUNCE # Bouncy end -Easing.EASE_OUT_ELASTIC # Elastic end +ui_manager.animator.create().wait(0.08).to( + panel.style, + "opacity", + 1.0, + 0.25, + Easing.EASE_OUT_QUAD, +).call( + lambda: print("Fade complete") +).start() ``` -## Animator +`Animation` instances are usually created via `Animator.create()` so they can be started and tracked by the scheduler. -The UIManager has a built-in animator: +## Timers ```python -# Add animation -ui_manager.animator.add(anim) - -# Remove animation -ui_manager.animator.remove(anim) +from arepy_ui import Timers -# Clear all animations -ui_manager.animator.clear() +timers = Timers() +timers.after(0.5, lambda: print("Once")) +timers.every(1.0, lambda: print("Tick")) +timers.update(dt) ``` -## Examples - -### Fade In +## Keyframed Timeline ```python -Animation( - target=panel, - property="style.opacity", - from_value=0, - to_value=1, - duration=0.3, - easing=Easing.EASE_OUT, +from arepy_ui import Easing, Timeline + +timeline = Timeline(auto_start=False) +timeline.add_animation( + panel.style, + "opacity", + [ + (0.0, 0.0, None), + (0.15, 1.0, Easing.EASE_OUT_CUBIC), + ], ) +timeline.add_event(0.15, lambda: print("Fade complete")) +timeline.start() +timeline.update(dt) ``` -### Slide In +## Full-Screen Fade ```python -Animation( - target=panel, - property="style.left", - from_value=-200, - to_value=0, - duration=0.4, - easing=Easing.EASE_OUT_QUAD, -) +from arepy_ui import FadeTransition + +fade = FadeTransition(duration=0.5, fade_in=False) +fade.update(dt) +fade.render() ``` -### Color Pulse +## Sequence Runner ```python -def pulse_red(): - ui_manager.animator.add(Animation( - target=button, - property="style.background_color.r", - from_value=100, - to_value=255, - duration=0.2, - on_complete=pulse_back, - )) - -def pulse_back(): - ui_manager.animator.add(Animation( - target=button, - property="style.background_color.r", - from_value=255, - to_value=100, - duration=0.2, - )) +from arepy_ui import FadeTransition, SequenceRunner, Timeline + +runner = SequenceRunner() +runner.add(Timeline(auto_start=False), delay=0.1) +runner.add(FadeTransition(duration=0.35, fade_in=False)) +runner.start() +runner.update(dt) +runner.render() ``` -### Scale Effect +## Reference -```python -# Grow on hover -def on_hover_enter(): - ui_manager.animator.add(Animation( - target=card, - property="style.scale", - from_value=1.0, - to_value=1.1, - duration=0.15, - easing=Easing.EASE_OUT, - )) - -def on_hover_exit(): - ui_manager.animator.add(Animation( - target=card, - property="style.scale", - from_value=1.1, - to_value=1.0, - duration=0.15, - )) -``` +::: arepy_ui.core.animation.Animation -### Chained Animations +::: arepy_ui.core.animation.Animator -```python -def animate_sequence(): - # First animation - anim1 = Animation( - target=box, - property="computed_x", - from_value=0, - to_value=100, - duration=0.5, - on_complete=lambda: ui_manager.animator.add(anim2), - ) - - # Second animation (after first completes) - anim2 = Animation( - target=box, - property="computed_y", - from_value=0, - to_value=100, - duration=0.5, - ) - - ui_manager.animator.add(anim1) -``` +::: arepy_ui.core.timers.Timer + +::: arepy_ui.core.timers.Timers + +::: arepy_ui.core.transitions.KeyFrame + +::: arepy_ui.core.transitions.PropertyAnimation + +::: arepy_ui.core.transitions.Timeline + +::: arepy_ui.core.transitions.CircleReveal + +::: arepy_ui.core.transitions.FadeTransition + +::: arepy_ui.core.transitions.SequenceRunner + +::: arepy_ui.core.transitions.TransitionState diff --git a/docs/reference/api/debugger.md b/docs/reference/api/debugger.md index 2c6c25c..bd904ca 100644 --- a/docs/reference/api/debugger.md +++ b/docs/reference/api/debugger.md @@ -1,278 +1,38 @@ # UI Debugger -The UIDebugger is a visual debugging tool that helps you inspect and understand your UI layout in real-time. +`UIDebugger` is the visual inspector used by `UIManager`. It draws bounds, hover details, padding overlays, and a tree view over the active UI. - -![UIDebugger Overview](../assets/debugger-overview.png) -*The UIDebugger showing component bounds and hover information* +## Recommended Usage -## Overview - -The debugger provides: - -- **Component bounds** - Visual borders around each component -- **Hover inspection** - Detailed info panel when hovering over components -- **Component tree** - Hierarchical view of your UI structure -- **Padding visualization** - See padding areas highlighted -- **Keyboard shortcuts** - Quick toggles for different views - -## Quick Start - -```python -from arepy_ui.debug import UIDebugger -from arepy import Renderer2D, Input, Key - -# Create debugger instance -ui_debugger = UIDebugger() - -# The parameters 'renderer' and 'input' will be injected by arepy engine at runtime. -def update(renderer: Renderer2D, input: Input): - dt = renderer.get_delta_time() - ui_manager.update(dt) - - # Toggle debugger with F3 - if input.is_key_pressed(Key.F3): - ui_debugger.toggle() - -def render(): - ui_manager.render() - # Render debug overlay on top - ui_debugger.render(ui_manager.root) -``` - -## Keyboard Shortcuts - -| Key | Action | Description | -|-----|--------|-------------| -| `F3` | Toggle Debug | Enable/disable the entire debugger | -| `F4` | Toggle Bounds | Show/hide component boundary boxes | -| `F5` | Toggle Padding | Show/hide padding visualization | -| `F6` | Toggle Tree | Show/hide component tree panel | - - -![Debugger Shortcuts](../assets/debugger-shortcuts.gif) -*Using keyboard shortcuts to toggle debugger features* - -## Toolbar - -When enabled, the debugger displays a toolbar at the top of the screen: - -``` -[F3] Debug [F4] Bounds [F5] Padding [F6] Tree Hovering: Button #submit -``` - -- Active features are highlighted in color -- Inactive features are dimmed -- Current hovered component is shown on the right - - -![Debugger Toolbar](../assets/debugger-toolbar.png) -*The debugger toolbar showing active features* - -## Component Bounds - -When **Bounds** is enabled (F4), each component gets a colored border: - -- Different component types have different colors -- Hovered components are highlighted with a yellow glow -- Nested components show their hierarchy visually - - -![Bounds Visualization](../assets/debugger-bounds.png) -*Component bounds with different colors per type* - -### Component Colors - -| Component | Color | -|-----------|-------| -| Node | Gray | -| Text | Blue | -| Button | Green | -| TextInput | Cyan | -| Slider | Orange | -| Checkbox | Purple | -| Image | Pink | -| ScrollView | Teal | -| ColorPicker | Gold | - -## Hover Info Panel - -When you hover over a component, a detailed info panel appears showing: - - -![Info Panel](../assets/debugger-info-panel.png) -*Detailed component information on hover* - -### Layout Section - -``` -Position: (100, 200) -Size: 250 × 45 -``` - -Shows the computed position and dimensions in pixels. - -### Style Section - -``` -width: 100% -height: 45px -flex: row -gap: 10 -padding: 8 16 -``` - -Shows the applied style properties. - -### Props Section - -Component-specific properties: - -| Component | Properties Shown | -|-----------|-----------------| -| `Text` | text content, size | -| `Button` | label | -| `TextInput` | value, placeholder | -| `Slider` | value, range | -| `Checkbox` | checked state | -| `Image` | source path | -| `Video` | state, duration | -| `ColorPicker` | current color (RGBA) | -| `Select` | options count, selected index | - -### Tree Section - -``` -Parent: Node -Children: 3 -``` - -Shows the component's position in the hierarchy. - -## Component Tree - -When **Tree** is enabled (F6), a panel appears on the right showing the full component hierarchy: - - -![Component Tree](../assets/debugger-tree.png) -*The component tree panel* - -- Components are indented by depth -- Each component shows its type and ID -- Colored markers indicate component type -- Hovered component is highlighted in yellow - -``` -◆ Node #root - ├── Text #title - ├── Node #content - │ ├── Button #submit - │ └── Button #cancel - └── Text #footer -``` - -## Padding Visualization - -When **Padding** is enabled (F5), padding areas are highlighted in green: - - -![Padding Visualization](../assets/debugger-padding.png) -*Padding areas shown in green overlay* - -This helps you understand: -- Where padding is applied -- The actual size of padding on each side -- How padding affects layout - -## Complete Example +Use the debugger through `UIManager` instead of wiring a second overlay manually. ```python -from arepy import ArepyEngine, Input, Key, SystemPipeline -from arepy_ui import UIManager, UIConfig, Node, Button, Text, Style -from arepy_ui.debug import UIDebugger +from arepy.engine.input import Key +from arepy_ui import UIConfig, UIManager -ui_manager: UIManager = None -ui_debugger: UIDebugger = None +ui_manager = UIManager.from_engine( + game, + config=UIConfig( + debug_enabled=False, + debug_toggle_key=Key.F3, + debug_bounds_key=Key.F4, + debug_padding_key=Key.F5, + debug_tree_key=Key.F6, + ), +) -def setup(game: ArepyEngine): - global ui_manager, ui_debugger - - ui_manager = UIManager.from_engine(game, config=UIConfig()) - ui_debugger = UIDebugger() - - ui_manager.set_root( - Node( - id="root", - style=Style(padding=Spacing.all(20), gap=10), - children=[ - Text("Debug Demo", id="title", size=24), - Button("Click me", id="btn"), - ], - ) - ) - game.add_resource(ui_manager) - game.add_resource(ui_debugger) - -def update(dt: float, input: Input): - ui_manager.update(dt) - - # Debugger keyboard shortcuts - if input.is_key_pressed(Key.F3): - ui_debugger.toggle() - if input.is_key_pressed(Key.F4): - ui_debugger.toggle_bounds() - if input.is_key_pressed(Key.F5): - ui_debugger.toggle_padding() - if input.is_key_pressed(Key.F6): - ui_debugger.toggle_tree() - -def render(): - ui_manager.render() - ui_debugger.render(ui_manager.root) - -if __name__ == "__main__": - game = ArepyEngine(title="Debugger Demo", width=800, height=600) - world = game.create_world("main") - world.add_startup_system(setup) - world.add_system(SystemPipeline.UPDATE, update) - world.add_system(SystemPipeline.RENDER, render) - game.set_current_world("main") - game.run() +debugger = ui_manager.get_debugger() +ui_manager.enable_debug_overlay(True) +ui_manager.set_debug_hotkeys(toggle=Key.F2, bounds=Key.F3, padding=Key.F4, tree=Key.F5) ``` -## API Reference - -### UIDebugger - -| Method | Description | -|--------|-------------| -| `toggle()` | Toggle the entire debugger on/off | -| `toggle_bounds()` | Toggle bounds visualization | -| `toggle_padding()` | Toggle padding visualization | -| `toggle_tree()` | Toggle component tree panel | -| `render(root)` | Render debug overlay for the UI tree | - -### Properties - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `enabled` | `bool` | `False` | Whether debugger is active | -| `show_bounds` | `bool` | `True` | Show component boundaries | -| `show_padding` | `bool` | `False` | Show padding areas | -| `show_info` | `bool` | `True` | Show hover info panel | -| `show_tree` | `bool` | `False` | Show component tree | -| `hovered_node` | `Node` | `None` | Currently hovered component | - -## Tips +## Current Behavior -!!! tip "Development Only" - Remove or disable the debugger in production builds for better performance. +- Bounds are computed from the visual frame, so overlays follow `ScrollView` offsets correctly. +- Hover inspection uses the clipped visible area, which avoids selecting content outside the viewport. +- Toolbar labels reflect configured hotkeys instead of assuming fixed keys. +- The overlay text stays ASCII-safe. -!!! tip "Use Component IDs" - Give your components meaningful IDs to make them easier to identify in the debugger: - ```python - Button("Submit", id="submit-btn") - ``` +## Reference -!!! tip "Inspect Layout Issues" - Use the debugger to understand why layouts aren't working as expected. The hover panel shows computed vs. styled values. +::: arepy_ui.debug.UIDebugger diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md index a9dc30a..14c57a1 100644 --- a/docs/reference/api/index.md +++ b/docs/reference/api/index.md @@ -1,191 +1,80 @@ # API Reference -Core classes and functions for arepy-ui. +This section is the source-aligned reference for the API pages currently maintained in the docs. -
+## API Documents -- :material-cogs:{ .lg .middle } **UIManager** +- [docs/reference/api/index.md](index.md) - entry point and navigation notes for the API reference. +- [docs/reference/api/uimanager.md](uimanager.md) - `UIManager`, `UIConfig`, and overlay helpers. +- [docs/reference/api/debugger.md](debugger.md) - `UIDebugger` and debug-overlay behavior. +- [docs/reference/api/animations.md](animations.md) - animation, timers, timeline, and transition primitives. - --- +## Source of Truth - The main UI controller class. +These pages are backed by the library source through `mkdocstrings`, so signatures and docstrings come from the code instead of hand-maintained tables. - [:octicons-arrow-right-24: UIManager](uimanager.md) - -- :material-bug:{ .lg .middle } **UIDebugger** - - --- - - Visual debugging tools. - - [:octicons-arrow-right-24: Debugger](debugger.md) - -- :material-animation:{ .lg .middle } **Animations** - - --- - - Transitions and keyframe animations. - - [:octicons-arrow-right-24: Animations](animations.md) - -
- -## Quick Reference - -### Main Classes - -| Class | Description | -|-------|-------------| -| `UIManager` | Main UI controller | -| `Node` | Base container element | -| `Style` | Layout and appearance | -| `Color` | RGBA color | -| `Unit` | Size units (px, %) | -| `Spacing` | Padding/margin | - -### Import Patterns +## Common Imports ```python -# Core imports from arepy_ui import ( - UIManager, - Node, - Style, - Color, - Unit, -) - -# Components -from arepy_ui import ( - Text, - Button, - TextInput, - Checkbox, - Slider, - Select, - Image, - Video, - Canvas, - ProgressBar, - ScrollView, - Tabs, -) - -# Types -from arepy_ui.core.types import ( - FlexDirection, - JustifyContent, AlignItems, - AlignSelf, - FlexWrap, - Overflow, -) - -# Styling -from arepy_ui.core.style import Spacing - -# Markup -from arepy_ui.markup import ( - load_aui, - load_acss, - load_globals, - set_theme, - get_theme, - register_component, -) - -# Animations -from arepy_ui.core.animation import ( Animation, - Transition, + Animator, + Button, + Color, Easing, - Keyframe, + FadeTransition, + FlexDirection, + JustifyContent, + KeyFrame, + Node, + ScrollView, + Spacing, + Style, + Timer, + Timers, + Text, + Timeline, + UIConfig, + UIManager, + Unit, ) -# Drag & Drop -from arepy_ui.components import ( - Draggable, - DropZone, -) +from arepy_ui.markup import load_aui, load_aui_string, load_globals, get_theme, set_theme ``` -### Lifecycle +## Minimal Lifecycle ```python -from arepy import ArepyEngine, SystemPipeline -from arepy_ui import UIManager, UIConfig +from arepy import ArepyEngine, Display, Input, Renderer2D, SystemPipeline +from arepy_ui import UIConfig, UIManager -ui_manager: UIManager = None +ui_manager: UIManager | None = None -def setup(game: ArepyEngine): +def setup(game: ArepyEngine) -> None: global ui_manager ui_manager = UIManager.from_engine(game, config=UIConfig()) ui_manager.set_root(create_ui()) - game.add_resource(ui_manager) -def update(): - ui_manager.update() +def ui_update_system(renderer: Renderer2D, input: Input, display: Display) -> None: + assert ui_manager is not None + ui_manager.update(renderer.get_delta_time()) -def draw(): +def ui_render_system(renderer: Renderer2D) -> None: + assert ui_manager is not None ui_manager.render() -game = ArepyEngine(title="My Game", width=800, height=600) +game = ArepyEngine(title="My UI", width=1280, height=720) +game.on_startup = lambda: setup(game) world = game.create_world("main") -world.add_startup_system(setup) -world.add_system(SystemPipeline.UPDATE, update) -world.add_system(SystemPipeline.RENDER, draw) +world.add_system(SystemPipeline.UPDATE, ui_update_system) +world.add_system(SystemPipeline.RENDER_UI, ui_render_system) game.set_current_world("main") game.run() ``` -### Error Handling - -```python -from arepy_ui.markup import load_aui - -try: - root = load_aui("menu.aui") -except FileNotFoundError: - print("AUI file not found") -except SyntaxError as e: - print(f"AUI parse error: {e}") -``` - -## Type Enums +## Related Sections -### FlexDirection - -```python -FlexDirection.ROW # Left to right -FlexDirection.COLUMN # Top to bottom -FlexDirection.ROW_REVERSE # Right to left -FlexDirection.COLUMN_REVERSE # Bottom to top -``` - -### JustifyContent - -```python -JustifyContent.FLEX_START # Start -JustifyContent.FLEX_END # End -JustifyContent.CENTER # Center -JustifyContent.SPACE_BETWEEN # Even, no edge gap -JustifyContent.SPACE_AROUND # Even with edge gap -JustifyContent.SPACE_EVENLY # Equal everywhere -``` - -### AlignItems - -```python -AlignItems.FLEX_START # Top/Left -AlignItems.FLEX_END # Bottom/Right -AlignItems.CENTER # Center -AlignItems.STRETCH # Fill -``` - -### Overflow - -```python -Overflow.VISIBLE # Show overflow -Overflow.HIDDEN # Clip overflow -Overflow.SCROLL # Scrollable -``` +- [reference/components/index.md](../components/index.md) +- [reference/styling/index.md](../styling/index.md) +- [reference/markup/index.md](../markup/index.md) diff --git a/docs/reference/api/uimanager.md b/docs/reference/api/uimanager.md index 90fd72f..374c522 100644 --- a/docs/reference/api/uimanager.md +++ b/docs/reference/api/uimanager.md @@ -1,228 +1,39 @@ # UIManager API -The main class for managing UI state and rendering. +`UIManager` is the runtime entry point for layout, input, rendering, overlays, modals, tooltips, font scaling, and the integrated debugger. -## Overview - -`UIManager` is the central controller for arepy-ui. It handles the UI tree, rendering, input events, and updates. - -## Import - -```python -from arepy_ui import UIManager, UIConfig -``` - -## Creating UIManager +## Typical Usage ```python from arepy import ArepyEngine -from arepy_ui import UIManager, UIConfig - -def setup(game: ArepyEngine): - # Create from engine (recommended) - ui_manager = UIManager.from_engine(game, config=UIConfig()) -``` - -## Methods - -### set_root - -Set the root node of the UI tree. - -```python -ui_manager.set_root(root_node) -``` - -| Parameter | Type | Description | -|-----------|------|-------------| -| `root` | `Node` | The root node of the UI tree | - -### update - -Process input and update UI state. Call once per frame. - -```python -def update(): - ui_manager.update() -``` - -### render - -Render the UI tree. Call in your draw function. - -```python -def draw(): - ui_manager.render() -``` - -### root - -Access the root node of the UI tree. - -```python -# Get the root node -root = ui_manager.root - -# Set a new root -ui_manager.set_root(new_root) -``` - -**Type:** `Optional[Node]` - -### find_by_id +from arepy_ui import UIConfig, UIManager -Find a node by its ID (must be called on the root node). - -```python -if ui_manager.root: - node = ui_manager.root.find_by_id("my-node-id") - if node: - print(f"Found: {node}") -``` - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | `str` | Node identifier | - -**Returns:** `Optional[Node]` - -### Modals - -Show and manage modal dialogs: - -```python -# Show a modal -modal = Node(style=Style(width=Unit.px(300), height=Unit.px(200))) -ui_manager.show_modal(modal, backdrop=True, close_on_backdrop=True) - -# Close the topmost modal -ui_manager.close_modal() - -# Close all modals -ui_manager.close_all_modals() - -# Check if a modal is open -if ui_manager.has_modal: - print("Modal is open") -``` - -## Properties - -| Property | Type | Description | -|----------|------|-------------| -| `root` | `Optional[Node]` | Root node of the UI tree | -| `screen_width` | `int` | Current screen width | -| `screen_height` | `int` | Current screen height | -| `is_dirty` | `bool` | Whether layout needs recalculation | -| `is_input_captured` | `bool` | Whether UI has captured input | -| `has_modal` | `bool` | Whether any modal is currently open | -| `config` | `UIConfig` | UI configuration settings | -| `animator` | `Animator` | Animation controller | - -## Complete Example - -```python -from arepy import ArepyEngine, SystemPipeline -from arepy_ui import UIManager, UIConfig, Node, Text, Button, Style, Color, Unit - -ui_manager: UIManager = None - -def setup(game: ArepyEngine): - global ui_manager - ui_manager = UIManager.from_engine(game, config=UIConfig()) - create_menu() - game.add_resource(ui_manager) - -def create_menu(): - root = Node( - id="root", - style=Style( - width=Unit.percent(100), - height=Unit.percent(100), - justify_content=JustifyContent.CENTER, - align_items=AlignItems.CENTER, - background_color=Color(30, 30, 40), - ), - children=[ - Text("Hello!", size=32, color=Color(255, 255, 255)), - Button("Click me", on_click=on_click), - ], - ) - ui_manager.set_root(root) - -def on_click(): - # Find and update a node - if ui_manager.root: - root = ui_manager.root.find_by_id("root") - print("Button clicked!") - -def update(): - ui_manager.update() - -def draw(): - ui_manager.render() - -# Run -game = ArepyEngine(title="Demo", width=800, height=600) +game = ArepyEngine(title="UI Demo", width=1280, height=720) world = game.create_world("main") -world.add_startup_system(setup) -world.add_system(SystemPipeline.UPDATE, update) -world.add_system(SystemPipeline.RENDER, draw) -game.set_current_world("main") -game.run() -``` - -## Hot Reload Pattern - -Rebuild UI when needed: - -```python -def refresh_ui(): - """Rebuild the UI from current state.""" - ui_manager.set_root(create_ui_from_state()) -def on_setting_change(value): - settings.volume = value - refresh_ui() # Rebuild with new state +ui_manager = UIManager.install( + world, + config=UIConfig(), + root=create_ui(), +) ``` -## With Markup - -```python -from arepy_ui.markup import load_aui, load_globals -from arepy_ui import UIManager, UIConfig +## Notes -def setup(game: ArepyEngine): - global ui_manager - - # Load global styles - load_globals("assets/ui/globals.acss") - - # Create manager - ui_manager = UIManager.from_engine(game, config=UIConfig()) - - # Load from markup - root = load_aui("menu.aui", context={ - "start_game": start_game, - "open_settings": open_settings, - }) - ui_manager.set_root(root) - game.add_resource(ui_manager) -``` +- `install()` is the recommended entry point when your UI lives inside an arepy `World`. +- `from_world()` configures runtime services from the world's shared resources when you want manual control. +- `from_engine()` remains available for non-world or legacy setup paths. +- `find_by_id()` belongs to `Node`, so use `ui_manager.root.find_by_id(...)` when the root exists. +- Debug overlay management is built in through `get_debugger()`, `enable_debug_overlay()`, `toggle_debug_overlay()`, and the `UIConfig` debug keys. -## Tips +## Reference -!!! tip "Global Instance" - Keep UIManager as a global or class variable for easy access. +::: arepy_ui.manager.UIManager -!!! tip "Single Root" - Call `set_root()` with your complete UI tree. Don't add nodes individually. +## Related Types -!!! tip "State Management" - Rebuild the UI when state changes rather than mutating nodes directly. +::: arepy_ui.config.UIConfig -## See Also +::: arepy_ui.manager.register_overlay -- [Getting Started](../../learn/getting-started.md) - Setup guide -- [UIDebugger](debugger.md) - Debug tools -- [Nodes](../components/node.md) - Building UI trees +::: arepy_ui.manager.clear_overlays diff --git a/docs/reference/components/button.md b/docs/reference/components/button.md index 606d186..ce76b2f 100644 --- a/docs/reference/components/button.md +++ b/docs/reference/components/button.md @@ -1,134 +1,5 @@ # Button -Clickable button with hover and press states. +Clickable text button component. -## Usage - -```python -from arepy_ui import Button, Color, Unit - -# Basic button -button = Button("Click me", on_click=lambda: print("Clicked!")) - -# Styled button -button = Button( - "Save", - on_click=lambda: save_game(), - width=Unit.px(120), - height=Unit.px(40), - bg_color=Color(0, 122, 204), - text_color=Color(255, 255, 255), - border_radius=8.0, - font_size=14.0, -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `text` | `str` | required | Button label | -| `on_click` | `Callable` | required | Click callback | -| `width` | `Unit` | `Unit.px(120)` | Button width | -| `height` | `Unit` | `Unit.px(40)` | Button height | -| `bg_color` | `Color` | `Color(0, 122, 204)` | Background color | -| `text_color` | `Color` | `Color(255, 255, 255)` | Text color | -| `border_radius` | `float` | `8.0` | Corner radius | -| `font_size` | `float` | `12.0` | Text size | -| `hover_color` | `Color` | Auto-calculated | Color when hovered (defaults to bg_color + 20) | -| `pressed_color` | `Color` | Auto-calculated | Color when pressed (defaults to bg_color - 30) | -| `pressed_scale` | `float` | `0.98` | Scale factor when pressed | -| `style` | `Style` | `None` | Additional styling | - -## Interactive States - -Buttons have visual feedback for hover and pressed states: - -### Hover Effect - -Buttons automatically lighten on hover. The hover color is calculated from `bg_color` unless explicitly set: - -```python -# Default hover color is bg_color + 20 for each RGB channel -hover_color = Color( - min(bg_color.r + 20, 255), - min(bg_color.g + 20, 255), - min(bg_color.b + 20, 255), - bg_color.a, -) -``` - -### Pressed Effect - -When the button is clicked (mouse down), it darkens to provide feedback: - -```python -# Default pressed color is bg_color - 30 for each RGB channel -pressed_color = Color( - max(bg_color.r - 30, 0), - max(bg_color.g - 30, 0), - max(bg_color.b - 30, 0), - bg_color.a, -) -``` - -### Custom Interactive Colors - -You can override the default colors in Python or ACSS: - -```python -Button( - "Custom States", - on_click=my_handler, - bg_color=Color(0, 122, 204), - hover_color=Color(50, 150, 220), # Custom hover - pressed_color=Color(0, 80, 150), # Custom pressed -) -``` - -**ACSS Styling with pseudo-selectors:** - -```css -.my-button { - background: #007acc; -} - -.my-button:hover { - background: #3296dc; - color: #000000; /* Can change any property */ -} - -.my-button:active { - background: #005096; -} -``` - -## Examples - -### Icon Button - -```python -# Button with just an icon (using a small image or unicode) -Button("⚙", on_click=open_settings, width=Unit.px(40), height=Unit.px(40)) -``` - -### Full Width Button - -```python -Button( - "Submit", - on_click=submit, - width=Unit.percent(100), - style=Style(margin=Spacing.symmetric(10, 0)), -) -``` - -### Danger Button - -```python -Button( - "Delete", - on_click=delete_item, - bg_color=Color(220, 50, 50), -) -``` +::: arepy_ui.components.button.Button diff --git a/docs/reference/components/canvas.md b/docs/reference/components/canvas.md index ca1fbfa..0ba8102 100644 --- a/docs/reference/components/canvas.md +++ b/docs/reference/components/canvas.md @@ -1,108 +1,5 @@ # Canvas -Container for custom drawing using the renderer. +Custom drawing surface component. -## Usage - -```python -from arepy_ui import Canvas, Unit, Color - -def draw(renderer, x, y, width, height): - # Custom drawing code - renderer.draw_circle(x + width/2, y + height/2, 50, Color(255, 0, 0)) - -canvas = Canvas( - on_render=draw, - width=Unit.px(200), - height=Unit.px(200), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `on_render` | `Callable` | required | Drawing callback | -| `width` | `Unit` | `Unit.px(100)` | Canvas width | -| `height` | `Unit` | `Unit.px(100)` | Canvas height | -| `style` | `Style` | `None` | Additional styling | - -## Render Callback - -The `on_render` callback receives: - -- `renderer` - The 2D renderer for drawing -- `x` - Computed X position of the canvas -- `y` - Computed Y position of the canvas -- `width` - Computed width of the canvas -- `height` - Computed height of the canvas - -```python -def on_render(renderer, x: float, y: float, width: float, height: float): - # Use renderer methods to draw - pass -``` - -## Examples - -### Mini Map - -```python -def draw_minimap(renderer, x, y, w, h): - # Background - renderer.draw_rectangle(Rect(x, y, w, h), Color(20, 20, 20, 200)) - - # Player dot - px = x + w/2 - py = y + h/2 - renderer.draw_circle(px, py, 3, Color(0, 255, 0)) - - # Enemies - for enemy in enemies: - ex = x + (enemy.x / world_width) * w - ey = y + (enemy.y / world_height) * h - renderer.draw_circle(ex, ey, 2, Color(255, 0, 0)) - -Canvas(on_render=draw_minimap, width=Unit.px(150), height=Unit.px(150)) -``` - -### Graph - -```python -def draw_graph(renderer, x, y, w, h): - # Axes - renderer.draw_line(x, y + h, x + w, y + h, Color(255, 255, 255)) - renderer.draw_line(x, y, x, y + h, Color(255, 255, 255)) - - # Data points - for i, value in enumerate(data): - px = x + (i / len(data)) * w - py = y + h - (value * h) - renderer.draw_circle(px, py, 3, Color(0, 200, 255)) - -Canvas(on_render=draw_graph, width=Unit.px(300), height=Unit.px(150)) -``` - -### Custom Shape - -```python -def draw_hexagon(renderer, x, y, w, h): - cx, cy = x + w/2, y + h/2 - radius = min(w, h) / 2 - 5 - - import math - points = [] - for i in range(6): - angle = math.pi / 3 * i - math.pi / 6 - px = cx + radius * math.cos(angle) - py = cy + radius * math.sin(angle) - points.append((px, py)) - - # Draw lines between points - for i in range(6): - p1 = points[i] - p2 = points[(i + 1) % 6] - renderer.draw_line(p1[0], p1[1], p2[0], p2[1], Color(255, 200, 0)) - -Canvas(on_render=draw_hexagon, width=Unit.px(100), height=Unit.px(100)) -``` +::: arepy_ui.components.canvas.Canvas diff --git a/docs/reference/components/checkbox.md b/docs/reference/components/checkbox.md index 343746c..3736294 100644 --- a/docs/reference/components/checkbox.md +++ b/docs/reference/components/checkbox.md @@ -1,102 +1,5 @@ # Checkbox -Toggle component with optional label. +Boolean toggle component with optional label text. -## Usage - -```python -from arepy_ui import Checkbox - -# Basic checkbox -checkbox = Checkbox( - label="Enable sound", - checked=True, - on_change=lambda checked: set_sound(checked), -) - -# Without label -checkbox = Checkbox( - checked=False, - on_change=lambda c: print(f"Checked: {c}"), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `label` | `str` | `""` | Label text | -| `checked` | `bool` | `False` | Initial state | -| `on_change` | `Callable[[bool], None]` | `None` | Called when toggled | -| `size` | `float` | `22` | Checkbox size | -| `color` | `Color` | `Color(0, 150, 255)` | Check color when checked | -| `hover_color` | `Color` | Auto-calculated | Color when hovered (color + 30) | -| `pressed_color` | `Color` | Auto-calculated | Color when pressed (color - 40) | -| `unchecked_color` | `Color` | `Color(50, 52, 60)` | Background when unchecked | -| `style` | `Style` | `None` | Additional styling | - -## Interactive States - -Checkbox has visual feedback for hover and pressed states: - -```python -Checkbox( - checked=True, - color=Color(0, 200, 100), # Custom check color - hover_color=Color(50, 230, 130), # Custom hover - pressed_color=Color(0, 150, 70), # Custom pressed -) -``` - -**ACSS Styling with pseudo-selectors:** - -```css -.my-checkbox { - background: #00c864; -} - -.my-checkbox:hover { - background: #32e682; -} - -.my-checkbox:active { - background: #009646; -} -``` - -## Properties - -```python -checkbox = Checkbox(label="Option") - -# Get current state -is_checked = checkbox.checked - -# Set state programmatically -checkbox.checked = True -``` - -## Examples - -### Settings List - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=10), - children=[ - Checkbox(label="Enable music", checked=True, on_change=set_music), - Checkbox(label="Enable sound effects", checked=True, on_change=set_sfx), - Checkbox(label="Show FPS", checked=False, on_change=set_fps_display), - Checkbox(label="Fullscreen", checked=False, on_change=set_fullscreen), - ], -) -``` - -### Terms Agreement - -```python -agree_checkbox = Checkbox( - label="I agree to the terms and conditions", - on_change=lambda c: submit_button.set_enabled(c), -) -``` +::: arepy_ui.components.checkbox.Checkbox diff --git a/docs/reference/components/colorpicker.md b/docs/reference/components/colorpicker.md index 0f2d351..b842e5d 100644 --- a/docs/reference/components/colorpicker.md +++ b/docs/reference/components/colorpicker.md @@ -1,224 +1,19 @@ # ColorPicker -Interactive color selection component using the HSV (Hue, Saturation, Value) color model. +HSV color picker component. -## Overview +::: arepy_ui.components.colorpicker.ColorPicker -The ColorPicker provides a complete color selection experience with: - -- **Saturation/Value gradient** - Square picker for saturation (X-axis) and value (Y-axis) -- **Hue slider** - Vertical bar to select the base hue (0-360°) -- **Alpha slider** - Optional transparency control -- **Preview** - Live preview of the selected color - -## Usage - -=== "Python" - - ```python - from arepy_ui.components import ColorPicker, Color, Unit - - def on_color_change(color: Color): - print(f"Selected: #{color.r:02X}{color.g:02X}{color.b:02X}") - - picker = ColorPicker( - color=Color(255, 0, 0, 255), - width=Unit.px(280), - height=Unit.px(220), - show_alpha=True, - show_preview=True, - on_change=on_color_change, - ) - ``` - -=== "AUI" - - ```html - - ``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `color` | `Color` | `Color(255, 0, 0, 255)` | Initial color (red by default) | -| `width` | `Unit` | `Unit.px(250)` | Total width of the picker | -| `height` | `Unit` | `Unit.px(200)` | Height of the SV gradient area | -| `show_alpha` | `bool` | `True` | Whether to show the alpha slider | -| `show_preview` | `bool` | `True` | Whether to show the color preview | -| `on_change` | `Callable[[Color], None]` | `None` | Callback when color changes | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -picker = ColorPicker(color=Color(100, 150, 200, 255)) - -# Get current color -current_color = picker.color # Color(100, 150, 200, 255) - -# Get hex color string -hex_value = picker.hex_color # "#6496C8" or "#6496C8FF" if alpha < 255 - -# Set color programmatically -picker.color = Color(255, 128, 0, 255) -``` - -## Examples - -### Basic Color Picker - -```python -from arepy_ui.components import ColorPicker, Color, Unit, Node, Text, Style -from arepy_ui.core.types import FlexDirection - -color_display = Text("Color: #FF0000", size=14) - -def update_display(color: Color): - hex_val = f"#{color.r:02X}{color.g:02X}{color.b:02X}" - color_display.text = f"Color: {hex_val}" - -ui = Node( - style=Style( - flex_direction=FlexDirection.COLUMN, - gap=20, - ), - children=[ - ColorPicker( - color=Color(255, 0, 0, 255), - on_change=update_display, - ), - color_display, - ], -) -``` - -### Color Picker with Preview Box - -```python -from arepy_ui.components import ColorPicker, Color, Unit, Node, Style - -preview_box = Node( - style=Style( - width=Unit.px(100), - height=Unit.px(100), - background_color=Color(255, 0, 0, 255), - border_radius=8.0, - ), -) - -def on_change(color: Color): - preview_box.style.background_color = color - -picker = ColorPicker( - color=Color(255, 0, 0, 255), - show_alpha=True, - on_change=on_change, -) -``` - -### Without Alpha Channel - -```python -picker = ColorPicker( - color=Color(0, 150, 255, 255), - width=Unit.px(200), - height=Unit.px(180), - show_alpha=False, # Hide alpha slider - show_preview=True, -) -``` - -### Minimal Picker - -```python -picker = ColorPicker( - color=Color(128, 128, 128, 255), - width=Unit.px(180), - height=Unit.px(150), - show_alpha=False, - show_preview=False, # No preview bar -) -``` - -## Color Model - -The ColorPicker uses the **HSV** (Hue, Saturation, Value) color model internally: - -- **Hue** (0-360°): The base color on the color wheel -- **Saturation** (0-100%): Color intensity (left = gray, right = vivid) -- **Value** (0-100%): Brightness (bottom = black, top = bright) - -This model is more intuitive for color selection than RGB because: - -1. Moving horizontally changes saturation only -2. Moving vertically changes brightness only -3. The hue bar changes the base color independently - -## Performance - -The ColorPicker uses **streaming textures** for efficient gradient rendering: - -- Gradients are generated using Cython-optimized functions when available -- Textures are updated only when the hue changes -- Double-buffered uploads prevent visual tearing - -To enable Cython acceleration: - -```bash -pip install arepy-ui[markup] -python -m arepy_ui.markup.build_ext -``` - -## Styling - -The ColorPicker accepts standard Node styles: - -```python -picker = ColorPicker( - color=Color(255, 0, 0, 255), - style=Style( - margin=Spacing.all(20), - border_radius=8.0, - ), -) -``` - -## AUI Markup - -In `.aui` files, the colorpicker tag maps to the ColorPicker component: - -```html - - Choose a color: - - -``` - -**Handler mapping:** +Example with markup: ```python -def on_theme_change(color): - app.theme_color = color +from arepy_ui.markup import load_aui handlers = { "on_theme_change": on_theme_change, } -root = load_aui("ui/settings.aui", handlers=handlers) +result = load_aui("ui/settings.aui", handlers=handlers) +if result.success and result.root is not None: + ui_manager.set_root(result.root) ``` diff --git a/docs/reference/components/draggable.md b/docs/reference/components/draggable.md index 54eea0b..b319f0e 100644 --- a/docs/reference/components/draggable.md +++ b/docs/reference/components/draggable.md @@ -1,159 +1,9 @@ # Draggable -Make elements draggable for drag & drop interactions. +Drag source component for drag-and-drop interactions. -## Overview +::: arepy_ui.components.drag.Draggable -The `Draggable` component wraps content and makes it draggable. Works with `DropZone` to create drag & drop systems. +::: arepy_ui.components.drag.get_drag_state -## Import - -```python -from arepy_ui.components import Draggable -``` - -## Basic Usage - -```python -from arepy_ui.components import Draggable -from arepy_ui import Node, Text, Style - -draggable_item = Draggable( - data={"id": "item_1", "name": "Sword"}, - children=[ - Node( - style=Style( - width=Unit.px(64), - height=Unit.px(64), - background_color=Color(80, 80, 100), - ), - children=[Text("🗡️")], - ), - ], -) -``` - -## Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `data` | `dict` | `{}` | Data passed to DropZone on drop | -| `children` | `list[Node]` | `[]` | Content to make draggable | -| `style` | `Style` | `None` | Container style | -| `disabled` | `bool` | `False` | Disable dragging | -| `on_drag_start` | `Callable` | `None` | Called when drag starts | -| `on_drag_end` | `Callable` | `None` | Called when drag ends | - -## With DropZone - -```python -from arepy_ui.components import Draggable, DropZone - -# Draggable item -item = Draggable( - data={"slot": 0, "item": "sword"}, - children=[ItemIcon("sword")], -) - -# Drop target -slot = DropZone( - on_drop=lambda data: print(f"Dropped: {data}"), - style=Style( - width=Unit.px(64), - height=Unit.px(64), - background_color=Color(40, 40, 50), - ), -) -``` - -## Drag Events - -```python -def on_start(): - print("Started dragging!") - -def on_end(): - print("Stopped dragging!") - -draggable = Draggable( - data={"id": 1}, - on_drag_start=on_start, - on_drag_end=on_end, - children=[...], -) -``` - -## Styling While Dragging - -The dragged element follows the cursor. Apply styles for visual feedback: - -```python -# Visual clone appears while dragging -draggable = Draggable( - data=item_data, - children=[ - Node( - style=Style( - width=Unit.px(64), - height=Unit.px(64), - background_color=Color(100, 100, 120), - border_radius=8.0, - opacity=0.8, # Semi-transparent while dragging - ), - children=[...], - ), - ], -) -``` - -## Complete Example - -```python -# Inventory slot with draggable item -def InventorySlot(slot_index: int, item: Optional[Item]) -> Node: - def handle_drop(data): - source = data["slot"] - inventory.move_item(source, slot_index) - - slot_style = Style( - width=Unit.px(64), - height=Unit.px(64), - background_color=Color(40, 40, 50), - border_radius=4.0, - ) - - if item is None: - return DropZone(on_drop=handle_drop, style=slot_style) - - return DropZone( - on_drop=handle_drop, - style=slot_style, - children=[ - Draggable( - data={"slot": slot_index, "item": item}, - children=[ItemIcon(item.icon)], - ), - ], - ) -``` - -## AUI Markup - -```html - - ⚔️ - -``` - -## Tips - -!!! tip "Pass Sufficient Data" - Include all information needed by the DropZone in the `data` prop. - -!!! tip "Visual Feedback" - Change appearance on drag start/end for better UX. - -## See Also - -- [DropZone](dropzone.md) - Drop target component -- [Inventory Tutorial](../../learn/tutorials/inventory.md) - Complete drag & drop example +::: arepy_ui.components.drag.is_dragging diff --git a/docs/reference/components/dropzone.md b/docs/reference/components/dropzone.md index a7d0c86..f5e6792 100644 --- a/docs/reference/components/dropzone.md +++ b/docs/reference/components/dropzone.md @@ -1,196 +1,5 @@ # DropZone -A drop target for draggable elements. +Drop target component for drag-and-drop interactions. -## Overview - -`DropZone` defines areas where `Draggable` elements can be dropped. It receives the data from the dropped item via callback. - -## Import - -```python -from arepy_ui.components import DropZone -``` - -## Basic Usage - -```python -from arepy_ui.components import DropZone -from arepy_ui import Style, Color, Unit - -def handle_drop(data): - print(f"Item dropped: {data}") - -drop_area = DropZone( - on_drop=handle_drop, - style=Style( - width=Unit.px(200), - height=Unit.px(200), - background_color=Color(50, 50, 60), - border_width=2.0, - border_color=Color(100, 100, 120), - ), -) -``` - -## Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `on_drop` | `Callable[[dict], None]` | Required | Called when item is dropped | -| `style` | `Style` | `None` | Container style | -| `children` | `list[Node]` | `[]` | Content inside the zone | -| `on_hover_start` | `Callable` | `None` | Called when dragged item enters | -| `on_hover_end` | `Callable` | `None` | Called when dragged item leaves | -| `accept` | `list[str]` | `None` | Filter accepted item types | - -## Drop Handler - -The `on_drop` callback receives the data dict from the dropped `Draggable`: - -```python -def on_drop(data: dict): - item_id = data.get("id") - item_type = data.get("type") - source_slot = data.get("slot") - - print(f"Received {item_type} from slot {source_slot}") -``` - -## Filtering Accepted Items - -Only accept certain types: - -```python -# Only accept weapons -weapon_slot = DropZone( - on_drop=equip_weapon, - accept=["weapon"], - style=slot_style, -) - -# Draggable must have matching type -sword = Draggable( - data={"type": "weapon", "name": "sword"}, - children=[...], -) - -potion = Draggable( - data={"type": "consumable", "name": "potion"}, # Won't be accepted - children=[...], -) -``` - -## Hover Feedback - -Show visual feedback when hovering: - -```python -is_hovered = False - -def on_hover_enter(): - global is_hovered - is_hovered = True - refresh_ui() - -def on_hover_leave(): - global is_hovered - is_hovered = False - refresh_ui() - -drop_zone = DropZone( - on_drop=handle_drop, - on_hover_start=on_hover_enter, - on_hover_end=on_hover_leave, - style=Style( - background_color=Color(80, 80, 100) if is_hovered else Color(50, 50, 60), - border_color=Color(150, 150, 200) if is_hovered else Color(80, 80, 100), - ), -) -``` - -## With Content - -DropZones can contain other elements: - -```python -# Slot with existing item -DropZone( - on_drop=handle_swap, - style=slot_style, - children=[ - # Existing item (also draggable for swapping) - Draggable( - data={"slot": 0, "item": current_item}, - children=[ItemIcon(current_item.icon)], - ), - ], -) -``` - -## Complete Example - -```python -def create_equipment_panel(): - slots = { - "head": equipped.get("head"), - "chest": equipped.get("chest"), - "weapon": equipped.get("weapon"), - } - - def equip_item(slot_name: str): - def handler(data): - item = data["item"] - if item.slot == slot_name: # Validate slot type - equipped[slot_name] = item - refresh_ui() - return handler - - return Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=8), - children=[ - EquipmentSlot("head", slots["head"], equip_item("head")), - EquipmentSlot("chest", slots["chest"], equip_item("chest")), - EquipmentSlot("weapon", slots["weapon"], equip_item("weapon")), - ], - ) - -def EquipmentSlot(slot_type: str, item: Optional[Item], on_drop) -> Node: - return DropZone( - on_drop=on_drop, - accept=[slot_type], # Only accept matching type - style=Style( - width=Unit.px(80), - height=Unit.px(80), - background_color=Color(40, 40, 55), - border_radius=8.0, - ), - children=[ - ItemIcon(item.icon) if item else Text("🔲", size=24), - ], - ) -``` - -## AUI Markup - -```html - - - Drop here - - -``` - -## Tips - -!!! tip "Always Validate" - Validate dropped items in your handler, not just with `accept`. - -!!! tip "Visual States" - Use hover callbacks to show when a drop is valid. - -## See Also - -- [Draggable](draggable.md) - Draggable element component -- [Inventory Tutorial](../../learn/tutorials/inventory.md) - Complete example -- [Drag & Drop Feature](../../features/drag-drop.md) - Full guide +::: arepy_ui.components.drag.DropZone diff --git a/docs/reference/components/image.md b/docs/reference/components/image.md index f3516a0..64d0484 100644 --- a/docs/reference/components/image.md +++ b/docs/reference/components/image.md @@ -1,88 +1,7 @@ # Image -Display textures with support for different fit modes. +Image component with fit modes and tint support. -## Usage +::: arepy_ui.components.image.Image -```python -from arepy_ui import Image, ImageFit, Color, Unit - -# Basic image -img = Image("assets/icon.png", width=Unit.px(64), height=Unit.px(64)) - -# With tint color -img = Image("assets/heart.png", tint=Color(255, 0, 0)) - -# With fit mode -img = Image("assets/background.png", fit=ImageFit.COVER) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `source` | `str` | required | Path to image file | -| `width` | `Unit` | `Unit.auto()` | Image width | -| `height` | `Unit` | `Unit.auto()` | Image height | -| `tint` | `Color` | `Color(255, 255, 255)` | Tint color | -| `fit` | `ImageFit` | `ImageFit.FILL` | How image fits container | -| `border_radius` | `float` | `0` | Corner radius | -| `style` | `Style` | `None` | Additional styling | - -## Fit Modes - -```python -from arepy_ui import ImageFit - -# Fill: stretch to fill (may distort) -Image("bg.png", fit=ImageFit.FILL) - -# Contain: fit inside, maintain aspect ratio -Image("bg.png", fit=ImageFit.CONTAIN) - -# Cover: fill container, maintain aspect ratio (may crop) -Image("bg.png", fit=ImageFit.COVER) -``` - -## Examples - -### Avatar - -```python -Image( - "assets/avatar.png", - width=Unit.px(64), - height=Unit.px(64), - border_radius=32, # Circular -) -``` - -### Tinted Icon - -```python -# Red heart -Image("assets/heart.png", tint=Color(255, 0, 0), width=Unit.px(32)) - -# Grayed out (disabled look) -Image("assets/icon.png", tint=Color(128, 128, 128), width=Unit.px(32)) -``` - -### Background - -```python -Node( - style=Style(width=Unit.percent(100), height=Unit.percent(100)), - children=[ - Image( - "assets/background.jpg", - width=Unit.percent(100), - height=Unit.percent(100), - fit=ImageFit.COVER, - ), - # UI content on top... - ], -) -``` - -!!! note "Asset Store Required" - Image loading requires the asset store to be configured. This is done automatically when using `UIManager.from_engine()`. +::: arepy_ui.components.image.ImageFit diff --git a/docs/reference/components/index.md b/docs/reference/components/index.md index c46f697..44d17c3 100644 --- a/docs/reference/components/index.md +++ b/docs/reference/components/index.md @@ -20,19 +20,10 @@ arepy-ui includes a set of ready-to-use UI components. | [Video](video.md) | Video playback (experimental) | | [ColorPicker](colorpicker.md) | HSV color selection | -## Common Patterns +Each reference page is generated from the implementation to keep signatures, attributes, and docstrings aligned with the code. -All components inherit from `Node`, so they support: +## Shared Base Type -- **Children** - Add nested nodes -- **Styles** - Apply layout and visual styles -- **Events** - Handle hover, click, etc. +All components inherit from [Node](node.md), so layout, styling, and event-related behavior live there. -```python -# Components can have children -button = Button("Save", on_click=save) -button.add_child(Icon("save")) # Add icon inside button - -# Components accept style overrides -text = Text("Hello", style=Style(margin=Spacing.all(10))) -``` +For usage patterns and runnable examples, use the guides in `learn/` and `resources/examples/`. diff --git a/docs/reference/components/node.md b/docs/reference/components/node.md index 17ce4e6..1050cc9 100644 --- a/docs/reference/components/node.md +++ b/docs/reference/components/node.md @@ -1,155 +1,5 @@ # Node -The fundamental building block of arepy-ui. +Base layout and container primitive used by all components. -## Overview - -`Node` is the core container element in arepy-ui. Every UI element is either a Node or contains Nodes. Think of it as a `
` in HTML. - -## Import - -```python -from arepy_ui import Node -``` - -## Basic Usage - -```python -from arepy_ui import Node, Style, Color, Unit - -# Simple node -node = Node( - style=Style( - width=Unit.px(200), - height=Unit.px(100), - background_color=Color(50, 50, 60), - ), -) - -# Node with children -container = Node( - style=Style( - width=Unit.percent(100), - height=Unit.percent(100), - ), - children=[ - Text("Hello!"), - Button("Click me"), - ], -) -``` - -## Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `id` | `str` | `None` | Unique identifier for the node | -| `style` | `Style` | `Style()` | Layout and appearance properties | -| `children` | `list[Node]` | `[]` | Child nodes | -| `class_name` | `str` | `None` | CSS class for markup integration | -| `on_click` | `Callable` | `None` | Click event handler | -| `on_hover` | `Callable` | `None` | Hover event handler | - -## Style Properties - -See [Style Properties](../styling/properties.md) for all available options. - -Common properties: - -```python -Style( - # Size - width=Unit.px(200), - height=Unit.percent(100), - - # Layout - flex_direction=FlexDirection.ROW, - justify_content=JustifyContent.CENTER, - align_items=AlignItems.CENTER, - gap=10, - - # Appearance - background_color=Color(30, 30, 40), - border_radius=8.0, - border_width=1.0, - border_color=Color(60, 60, 70), - - # Spacing - padding=Spacing.all(16), - margin=Spacing(top=Unit.px(10)), -) -``` - -## Nesting - -Nodes can be deeply nested: - -```python -# Header - Content - Footer layout -Node( - style=Style( - flex_direction=FlexDirection.COLUMN, - height=Unit.percent(100), - ), - children=[ - # Header - Node( - style=Style(height=Unit.px(60)), - children=[Text("Header")], - ), - # Content (grows to fill space) - Node( - style=Style(flex=1), - children=[Text("Main content")], - ), - # Footer - Node( - style=Style(height=Unit.px(40)), - children=[Text("Footer")], - ), - ], -) -``` - -## Finding Nodes - -Use `id` to find nodes in the tree: - -```python -root = Node( - id="root", - children=[ - Node(id="sidebar"), - Node(id="content"), - ], -) - -# Find by ID (from root node) -sidebar = ui_manager.root.find_by_id("sidebar") if ui_manager.root else None -``` - -## AUI Markup - -In AUI markup, generic containers are: - -```html -... -... -... -``` - -The `` and `` elements are shortcuts for Node with `flex_direction` preset. - -## Tips - -!!! tip "Use Semantic IDs" - Give nodes meaningful IDs for easier debugging and lookup. - -!!! tip "Avoid Deep Nesting" - Keep your hierarchy shallow when possible for better performance. - -## See Also - -- [Understanding Nodes](../../learn/concepts/nodes.md) - Concept explanation -- [Style Properties](../styling/properties.md) - All style options -- [Flexbox Layout](../styling/flexbox.md) - Layout guide +::: arepy_ui.core.node.Node diff --git a/docs/reference/components/progressbar.md b/docs/reference/components/progressbar.md index 0974162..37bd113 100644 --- a/docs/reference/components/progressbar.md +++ b/docs/reference/components/progressbar.md @@ -1,100 +1,5 @@ # ProgressBar -Visual progress indicator. +Progress display component. -## Usage - -```python -from arepy_ui.components.progressbar import ProgressBar -from arepy_ui import Color, Unit - -# Basic progress bar -bar = ProgressBar( - value=0.75, # 75% - width=Unit.px(200), - height=Unit.px(20), -) - -# Styled progress bar -bar = ProgressBar( - value=0.5, - width=Unit.px(300), - height=Unit.px(25), - fill_color=Color(0, 200, 0), - background_color=Color(50, 50, 50), - border_radius=5.0, -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `value` | `float` | `0` | Progress value (0.0 to 1.0) | -| `width` | `Unit` | `Unit.px(200)` | Bar width | -| `height` | `Unit` | `Unit.px(20)` | Bar height | -| `fill_color` | `Color` | `Color(0, 150, 255)` | Fill color | -| `background_color` | `Color` | `Color(60, 60, 60)` | Background color | -| `border_radius` | `float` | `0` | Corner radius | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -bar = ProgressBar(value=0.5) - -# Update value -bar.value = 0.75 - -# Animate progress (manual) -bar.value = min(bar.value + 0.01, 1.0) -``` - -## Examples - -### Health Bar - -```python -def create_health_bar(current: int, max_hp: int): - return ProgressBar( - value=current / max_hp, - width=Unit.px(150), - height=Unit.px(15), - fill_color=Color(220, 50, 50), - border_radius=3, - ) -``` - -### Loading Screen - -```python -loading_bar = ProgressBar( - value=0, - width=Unit.percent(80), - height=Unit.px(30), - fill_color=Color(100, 200, 100), - border_radius=5, -) - -# In update loop -def update_loading(progress: float): - loading_bar.value = progress -``` - -### XP Bar - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=2), - children=[ - Text(f"Level {player.level}", size=12), - ProgressBar( - value=player.xp / player.xp_to_next_level, - width=Unit.px(200), - height=Unit.px(10), - fill_color=Color(255, 215, 0), # Gold - border_radius=5, - ), - ], -) -``` +::: arepy_ui.components.progressbar.ProgressBar diff --git a/docs/reference/components/scrollview.md b/docs/reference/components/scrollview.md index a011ab3..585ccf6 100644 --- a/docs/reference/components/scrollview.md +++ b/docs/reference/components/scrollview.md @@ -1,108 +1,5 @@ # ScrollView -Container with vertical scrolling and content clipping. +Scrollable viewport container. -## Usage - -```python -from arepy_ui import ScrollView, Style, Unit - -scroll = ScrollView( - style=Style(width=Unit.px(300), height=Unit.px(400)), - children=[ - # Content taller than the container - item1, - item2, - item3, - # ... - ], -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `children` | `list[Node]` | `[]` | Content nodes | -| `style` | `Style` | `None` | Container styling | - -## Properties - -```python -scroll = ScrollView(...) - -# Get current scroll position -y = scroll.scroll_y - -# Set scroll position -scroll.scroll_y = 100 - -# Scroll to top -scroll.scroll_y = 0 -``` - -## Scrolling - -Scroll using the mouse wheel when hovering over the ScrollView. The scroll amount is based on wheel delta. - -## Examples - -### Item List - -```python -def create_item_list(items): - return ScrollView( - style=Style( - width=Unit.px(250), - height=Unit.px(400), - background_color=Color(40, 40, 40), - ), - children=[ - Node( - style=Style(padding=Spacing.all(10)), - children=[Text(item.name) for item in items], - ) - ], - ) -``` - -### Chat Log - -```python -chat_scroll = ScrollView( - style=Style( - width=Unit.percent(100), - height=Unit.px(300), - background_color=Color(20, 20, 20), - ), - children=[ - Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=5, padding=Spacing.all(10)), - children=chat_messages, - ) - ], -) - -# Scroll to bottom when new message arrives -def add_message(msg): - chat_messages.append(Text(msg)) - chat_scroll.scroll_y = 999999 # Scroll to bottom -``` - -### Inventory Grid - -```python -ScrollView( - style=Style(width=Unit.px(400), height=Unit.px(300)), - children=[ - Node( - style=Style( - flex_direction=FlexDirection.ROW, - flex_wrap=True, - gap=5, - ), - children=[create_slot(i) for i in range(50)], - ) - ], -) -``` +::: arepy_ui.components.scroll.ScrollView diff --git a/docs/reference/components/select.md b/docs/reference/components/select.md index 1f43cb6..8b2adf9 100644 --- a/docs/reference/components/select.md +++ b/docs/reference/components/select.md @@ -1,121 +1,5 @@ # Select -Dropdown menu for selecting from a list of options. +Dropdown selection component. -## Usage - -```python -from arepy_ui import Select, Unit - -select = Select( - options=["Easy", "Normal", "Hard"], - selected_index=1, - on_change=lambda idx, val: print(f"Selected: {val}"), - width=Unit.px(150), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `options` | `list[str]` | required | List of options | -| `selected_index` | `int` | `0` | Initially selected index | -| `on_change` | `Callable[[int, str], None]` | `None` | Called on selection change | -| `width` | `Unit` | `Unit.px(150)` | Dropdown width | -| `height` | `Unit` | `Unit.px(40)` | Dropdown height | -| `bg_color` | `Color` | `Color(50, 52, 60)` | Background color | -| `hover_color` | `Color` | Auto-calculated | Color when hovered (bg + 10) | -| `pressed_color` | `Color` | Auto-calculated | Color when pressed (bg - 15) | -| `style` | `Style` | `None` | Additional styling | - -## Interactive States - -Select has visual feedback for hover and pressed states: - -```python -Select( - options=["Option 1", "Option 2"], - bg_color=Color(40, 42, 50), - hover_color=Color(60, 62, 70), - pressed_color=Color(30, 32, 40), -) -``` - -**ACSS Styling with pseudo-selectors:** - -```css -.my-select { - background: #282a32; -} - -.my-select:hover { - background: #3c3e46; -} - -.my-select:active { - background: #1e2028; -} -``` - -## Properties - -```python -select = Select(options=["A", "B", "C"]) - -# Get selected index -idx = select.selected_index - -# Get selected value -value = select.options[select.selected_index] - -# Change selection -select.selected_index = 2 -``` - -## Events - -The `on_change` callback receives both the index and value: - -```python -def handle_change(index: int, value: str): - print(f"Index: {index}, Value: {value}") - -Select( - options=["One", "Two", "Three"], - on_change=handle_change, -) -``` - -## Examples - -### Difficulty Selector - -```python -Select( - options=["Easy", "Normal", "Hard", "Nightmare"], - selected_index=1, - on_change=lambda i, v: game.set_difficulty(v), -) -``` - -### Resolution Picker - -```python -Select( - options=["1280x720", "1920x1080", "2560x1440"], - selected_index=1, - on_change=lambda i, v: set_resolution(v), - width=Unit.px(180), -) -``` - -### Language Selector - -```python -Select( - options=["English", "Español", "日本語", "Deutsch"], - selected_index=0, - on_change=lambda i, v: set_language(i), -) -``` +::: arepy_ui.components.select.Select diff --git a/docs/reference/components/slider.md b/docs/reference/components/slider.md index 2d3256d..81e4a78 100644 --- a/docs/reference/components/slider.md +++ b/docs/reference/components/slider.md @@ -1,97 +1,7 @@ # Slider -Horizontal or vertical slider for numeric values. +Slider component with orientation helpers. -## Usage +::: arepy_ui.components.slider.Slider -```python -from arepy_ui import Slider, SliderOrientation, Unit - -# Basic slider -slider = Slider( - value=50, - min_value=0, - max_value=100, - on_change=lambda v: print(f"Value: {v}"), -) - -# Vertical slider -slider = Slider( - value=0.5, - min_value=0, - max_value=1, - orientation=SliderOrientation.VERTICAL, - height=Unit.px(150), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `value` | `float` | `0` | Initial value | -| `min_value` | `float` | `0` | Minimum value | -| `max_value` | `float` | `100` | Maximum value | -| `on_change` | `Callable[[float], None]` | `None` | Called when value changes | -| `orientation` | `SliderOrientation` | `HORIZONTAL` | Slider direction | -| `width` | `Unit` | `Unit.px(200)` | Slider width | -| `height` | `Unit` | `Unit.px(20)` | Slider height | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -slider = Slider(value=50, min_value=0, max_value=100) - -# Get current value -current = slider.value - -# Set value programmatically -slider.value = 75 -``` - -## Orientation - -```python -from arepy_ui import SliderOrientation - -# Horizontal (default) -Slider(orientation=SliderOrientation.HORIZONTAL, width=Unit.px(200)) - -# Vertical -Slider(orientation=SliderOrientation.VERTICAL, height=Unit.px(150)) -``` - -## Examples - -### Volume Control - -```python -Node( - style=Style(flex_direction=FlexDirection.ROW, align_items=AlignItems.CENTER, gap=10), - children=[ - Text("Volume", size=14), - Slider( - value=audio.volume, - min_value=0, - max_value=100, - on_change=lambda v: audio.set_volume(v), - width=Unit.px(150), - ), - Text(f"{int(audio.volume)}%", size=12), - ], -) -``` - -### Color Picker (RGB) - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=5), - children=[ - Slider(value=255, max_value=255, on_change=lambda v: set_r(v)), - Slider(value=128, max_value=255, on_change=lambda v: set_g(v)), - Slider(value=0, max_value=255, on_change=lambda v: set_b(v)), - ], -) -``` +::: arepy_ui.components.slider.SliderOrientation diff --git a/docs/reference/components/tabs.md b/docs/reference/components/tabs.md index 156033c..06c687c 100644 --- a/docs/reference/components/tabs.md +++ b/docs/reference/components/tabs.md @@ -1,92 +1,5 @@ # Tabs -Tabbed container for switching between content panels. +Tabbed container component. -## Usage - -```python -from arepy_ui import Tabs, Node, Text - -tabs = Tabs( - labels=["Inventory", "Stats", "Settings"], - contents=[ - inventory_panel, - stats_panel, - settings_panel, - ], - on_tab_change=lambda idx: print(f"Tab: {idx}"), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `labels` | `list[str]` | required | Tab labels | -| `contents` | `list[Node]` | required | Content panels | -| `selected_index` | `int` | `0` | Initially selected tab | -| `on_tab_change` | `Callable[[int], None]` | `None` | Called on tab change | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -tabs = Tabs(labels=["A", "B", "C"], contents=[...]) - -# Get selected index -current = tabs.selected_index - -# Change tab programmatically -tabs.selected_index = 1 -``` - -## Examples - -### Character Menu - -```python -Tabs( - labels=["Inventory", "Equipment", "Skills", "Quests"], - contents=[ - create_inventory_panel(), - create_equipment_panel(), - create_skills_panel(), - create_quests_panel(), - ], -) -``` - -### Settings Page - -```python -Tabs( - labels=["Video", "Audio", "Controls", "Gameplay"], - contents=[ - Node(children=[ - Checkbox(label="Fullscreen", on_change=set_fullscreen), - Select(options=["Low", "Medium", "High"], on_change=set_quality), - ]), - Node(children=[ - Slider(value=100, on_change=set_master_volume), - Slider(value=80, on_change=set_music_volume), - ]), - Node(children=[Text("Key bindings...")]), - Node(children=[ - Checkbox(label="Show tutorials", checked=True), - ]), - ], -) -``` - -### Simple Two-Tab Layout - -```python -tabs = Tabs( - labels=["Tab 1", "Tab 2"], - contents=[ - Text("Content for tab 1"), - Text("Content for tab 2"), - ], - on_tab_change=lambda i: print(f"Switched to tab {i}"), -) -``` +::: arepy_ui.components.tabs.Tabs diff --git a/docs/reference/components/text.md b/docs/reference/components/text.md index f887a78..70f8590 100644 --- a/docs/reference/components/text.md +++ b/docs/reference/components/text.md @@ -1,76 +1,5 @@ # Text -Renders text with customizable font, size, and color. +Text rendering component with color, font, and sizing support. -## Usage - -```python -from arepy_ui import Text, Color - -# Basic text -text = Text("Hello World", size=16) - -# With color -text = Text("Red text", size=16, color=Color(255, 0, 0)) - -# With custom font -text = Text("Custom", size=20, font_name="my-font") - -# Multiline -text = Text("Line 1\nLine 2\nLine 3", size=14) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `text` | `str` | required | The text to display | -| `size` | `float` | `16` | Font size in pixels | -| `color` | `Color` | `Color(255, 255, 255)` | Text color | -| `font_name` | `str` | `None` | Font name (uses default if None) | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -text = Text("Hello") - -# Update text content -text.text = "New text" - -# Update color -text.color = Color(0, 255, 0) - -# Update size (via font_size attribute) -text.font_size = 24 -text._update_size() -``` - -## Styling - -```python -Text( - "Styled text", - size=18, - style=Style( - margin=Spacing.all(10), - padding=Spacing.symmetric(5, 10), - ), -) -``` - -## Custom Fonts - -First load the font, then use it by name: - -```python -from arepy_ui import load_font, Text - -# Load font once at startup -load_font("pixel", "assets/fonts/pixel.ttf", base_size=32) - -# Use in text -Text("Pixel text", size=16, font_name="pixel") -``` - -See [Fonts](../features/fonts.md) for more details. +::: arepy_ui.components.text.Text diff --git a/docs/reference/components/textinput.md b/docs/reference/components/textinput.md index 2a6c80c..da57308 100644 --- a/docs/reference/components/textinput.md +++ b/docs/reference/components/textinput.md @@ -1,106 +1,5 @@ # TextInput -Text input field with cursor, selection, and placeholder support. +Single-line text input component. -## Usage - -```python -from arepy_ui import TextInput, Unit - -# Basic input -input = TextInput( - placeholder="Enter your name...", - on_change=lambda text: print(f"Input: {text}"), -) - -# With initial value -input = TextInput( - value="Default text", - on_change=handle_change, - width=Unit.px(250), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `value` | `str` | `""` | Initial text value | -| `placeholder` | `str` | `""` | Placeholder text | -| `on_change` | `Callable[[str], None]` | `None` | Called when text changes | -| `on_submit` | `Callable[[str], None]` | `None` | Called on Enter key | -| `width` | `Unit` | `Unit.px(200)` | Input width | -| `height` | `Unit` | `Unit.px(32)` | Input height | -| `font_size` | `float` | `14.0` | Text size | -| `style` | `Style` | `None` | Additional styling | - -## Properties - -```python -input = TextInput() - -# Get current value -current = input.value - -# Set value programmatically -input.value = "New text" - -# Focus is managed by UIManager -# The input handles focus internally when clicked -# To programmatically focus, you would need to access internal methods -# or trigger a click event on the input -``` - -## Events - -### on_change - -Called whenever the text content changes: - -```python -def handle_change(text: str): - print(f"Current text: {text}") - -TextInput(on_change=handle_change) -``` - -### on_submit - -Called when the user presses Enter: - -```python -def handle_submit(text: str): - print(f"Submitted: {text}") - # Clear the input - input.value = "" - -input = TextInput(on_submit=handle_submit) -``` - -## Examples - -### Search Box - -```python -TextInput( - placeholder="Search...", - on_submit=lambda q: search(q), - width=Unit.px(300), - style=Style(border_radius=20), -) -``` - -### Form Input - -```python -Node( - style=Style(flex_direction=FlexDirection.COLUMN, gap=5), - children=[ - Text("Username", size=12), - TextInput( - placeholder="Enter username", - on_change=lambda v: set_username(v), - ), - ], -) -``` +::: arepy_ui.components.input.TextInput diff --git a/docs/reference/components/video.md b/docs/reference/components/video.md index b243c18..e54165f 100644 --- a/docs/reference/components/video.md +++ b/docs/reference/components/video.md @@ -1,190 +1,11 @@ # Video -!!! warning "Experimental" - This component is not stable and may change in future versions. +Video playback component with optional built-in controls. -Video player component for displaying video files. +Requires the optional `full` extras for playback support. -## Requirements +::: arepy_ui.components.video.ControlsConfig -Video playback requires the `full` extras: +::: arepy_ui.components.video.VideoState -```bash -pip install arepy-ui[full] -``` - -Or with uv: - -```bash -uv add arepy-ui[full] -``` - -This installs `av` (PyAV) and `numpy` for video decoding. - -## Usage - -```python -from arepy_ui.components.video import Video, VideoState, ControlsConfig -from arepy_ui import Unit - -# Basic video -video = Video( - source="assets/intro.mp4", - width=Unit.px(640), - height=Unit.px(360), -) - -# Autoplay with loop -video = Video( - source="assets/background.mp4", - width=Unit.percent(100), - height=Unit.percent(100), - autoplay=True, - loop=True, -) - -# With custom controls -video = Video( - source="assets/cutscene.mp4", - controls=ControlsConfig( - show_play_button=True, - show_progress_bar=True, - show_time=True, - ), -) -``` - -## Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `source` | `str` | required | Path to video file | -| `width` | `Unit` | `Unit.px(320)` | Video width | -| `height` | `Unit` | `Unit.px(240)` | Video height | -| `autoplay` | `bool` | `False` | Start playing automatically | -| `loop` | `bool` | `False` | Loop when finished | -| `muted` | `bool` | `False` | Mute audio | -| `controls` | `ControlsConfig` | `None` | Controls configuration | -| `style` | `Style` | `None` | Additional styling | - -## ControlsConfig - -```python -ControlsConfig( - show_play_button=True, # Show play/pause button - show_progress_bar=True, # Show seek bar - show_time=True, # Show current/total time - show_volume=True, # Show volume slider -) -``` - -## VideoState - -```python -from arepy_ui.components.video import VideoState - -VideoState.STOPPED # Not playing -VideoState.PLAYING # Currently playing -VideoState.PAUSED # Paused -VideoState.ENDED # Finished playing -``` - -## Properties & Methods - -```python -video = Video(source="video.mp4") - -# Playback control -video.play() -video.pause() -video.stop() -video.seek(10.0) # Seek to 10 seconds - -# State -state = video.state # VideoState -current_time = video.current_time # Seconds -duration = video.duration # Total seconds - -# Audio -video.volume = 0.5 # 0.0 to 1.0 -video.muted = True -``` - -## Examples - -### Background Video - -```python -Node( - style=Style(width=Unit.percent(100), height=Unit.percent(100)), - children=[ - Video( - source="assets/menu_bg.mp4", - width=Unit.percent(100), - height=Unit.percent(100), - autoplay=True, - loop=True, - muted=True, - ), - # UI on top of video - Node( - style=Style( - position=PositionType.ABSOLUTE, - top=Unit.px(0), - left=Unit.px(0), - ), - children=[...], - ), - ], -) -``` - -### Cutscene Player - -```python -cutscene = Video( - source="assets/cutscenes/intro.mp4", - width=Unit.percent(100), - height=Unit.percent(100), - on_ended=lambda: transition_to_gameplay(), -) - -# Skip button -Button( - "Skip", - on_click=lambda: (cutscene.stop(), transition_to_gameplay()), - style=Style( - position=PositionType.ABSOLUTE, - bottom=Unit.px(20), - right=Unit.px(20), - ), -) -``` - -### Video with Controls - -```python -Video( - source="assets/tutorial.mp4", - width=Unit.px(800), - height=Unit.px(450), - controls=ControlsConfig( - show_play_button=True, - show_progress_bar=True, - show_time=True, - show_volume=True, - ), -) -``` - -## Supported Formats - -Depends on PyAV/FFmpeg. Common formats: - -- MP4 (H.264) -- WebM (VP8/VP9) -- AVI -- MOV - -!!! note "Performance" - Video decoding can be CPU-intensive. For best performance, use hardware-accelerated codecs (H.264) and reasonable resolutions. +::: arepy_ui.components.video.Video diff --git a/docs/reference/index.md b/docs/reference/index.md index d93ef8f..31e7991 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -80,4 +80,4 @@ Ready-to-use UI components organized by category: |-------|-------------| | [UIManager](api/uimanager.md) | Main UI controller | | [UIDebugger](api/debugger.md) | Visual debugging tool | -| [Animations](api/animations.md) | Tweening system | +| [Animations](api/animations.md) | Sequenced animator and timers | diff --git a/docs/reference/markup/acss.md b/docs/reference/markup/acss.md index 07b8ab2..9a5909e 100644 --- a/docs/reference/markup/acss.md +++ b/docs/reference/markup/acss.md @@ -273,14 +273,13 @@ menu.acss ← Styles (auto-loaded) Or load explicitly: ```python -from arepy_ui.markup import load_aui, load_acss +from arepy_ui.markup import load_aui # Auto-loads menu.acss -root = load_aui("menu.aui") +result = load_aui("menu.aui") -# Or load separately -styles = load_acss("custom-styles.acss") -root = load_aui("menu.aui", styles=styles) +# Or point to an explicit stylesheet file +result = load_aui("menu.aui", stylesheet="custom-styles.acss") ``` ## Complete Example diff --git a/docs/reference/markup/aui.md b/docs/reference/markup/aui.md index 7175d65..00ea7dc 100644 --- a/docs/reference/markup/aui.md +++ b/docs/reference/markup/aui.md @@ -790,14 +790,9 @@ When registering a component, you can set these properties: ## Performance -The markup parser is implemented in **Cython** for maximum performance. To enable Cython acceleration: +The markup parser is performance-sensitive code and the project ships with compiled parser modules in the normal build workflow. The public runtime API does not require a separate `arepy_ui.markup.build_ext` command. -```bash -pip install arepy-ui[markup] -python -m arepy_ui.markup.build_ext -``` - -If Cython is not installed, the pure Python fallback is used automatically. +If you are working from source, use the repository build flow documented in the project setup instead of ad-hoc markup-specific commands. --- diff --git a/docs/reference/markup/custom-components.md b/docs/reference/markup/custom-components.md index d9a8fd5..867c477 100644 --- a/docs/reference/markup/custom-components.md +++ b/docs/reference/markup/custom-components.md @@ -4,37 +4,53 @@ Register custom components for use in AUI markup. ## Overview -You can extend AUI with your own components that can be used like built-in elements. +You can extend AUI by registering regular arepy-ui component classes and mapping them to custom tags. + +The current builder support is intentionally small: + +- Register a component class with `register_component(...)` +- Use the registered tag in `.aui` +- Let the builder pass the resolved `style` and optional `id` +- Let child nodes declared in markup be appended after construction + +Arbitrary markup attributes are not exposed as a generic `props` dictionary in the current runtime. ## Registering a Component ```python -from arepy_ui.markup import register_component -from arepy_ui import Node, Text, Style, Color, Unit - -def IconButton(props: dict) -> Node: - """Custom icon button component.""" - icon = props.get("icon", "⭐") - label = props.get("label", "Button") - on_click = props.get("on_click") - - return Node( - style=Style( - flex_direction=FlexDirection.ROW, - gap=8, - padding=Spacing.xy(h=16, v=8), - background_color=Color(80, 80, 100), - border_radius=8.0, - ), - on_click=on_click, - children=[ - Text(icon, size=20), - Text(label, size=14, color=Color(255, 255, 255)), - ], +from arepy_ui import Color, Node, Style, Unit, register_component + +class HealthBar(Node): + def __init__( + self, + value: float = 100.0, + max_value: float = 100.0, + bar_color: Color = Color(0, 255, 0, 255), + style: Style | None = None, + **kwargs, + ): + merged_style = style or Style( + width=Unit.px(220), + height=Unit.px(18), + background_color=Color(40, 40, 50, 255), + border_radius=6.0, + ) + super().__init__(style=merged_style, **kwargs) + self.value = value + self.max_value = max_value + self.bar_color = bar_color + + def render(self): + super().render() + # Draw the filled bar here. + + +register_component( + HealthBar, + tags=["healthbar", "health-bar"], + color=(255, 100, 100), + category="custom", ) - -# Register the component -register_component("icon-button", IconButton) ``` ## Using in AUI @@ -42,146 +58,76 @@ register_component("icon-button", IconButton) After registration, use the component in markup: ```html - - - - - + + ``` -## Component Props +## What Markup Passes Today -All attributes become props: +For registered custom tags, the builder currently does this: -```html - -``` +- resolves ACSS and inline styles into the `style` kwarg +- forwards the element `id` when present +- instantiates the registered class +- appends declared child elements after the instance is created -```python -def MyComponent(props: dict) -> Node: - title = props.get("title", "Default") - count = int(props.get("count", 0)) - enabled = props.get("enabled") == "true" - on_click = props.get("on_click") # Function reference - - return Node(...) -``` - -## Component with Children - -Access children through `props["children"]`: +That means this works well for components that behave like normal `Node` subclasses and can be configured by style plus any values you set in Python after loading. ```python -def Card(props: dict) -> Node: - """Card container with title.""" - title = props.get("title", "Card") - children = props.get("children", []) - - return Node( - style=Style( - background_color=Color(40, 40, 55), - border_radius=12.0, - padding=Spacing.all(16), - flex_direction=FlexDirection.COLUMN, - gap=12, - ), - children=[ - Text(title, size=18, color=Color(255, 255, 255)), - *children, # Insert child elements - ], - ) - -register_component("card", Card) +result = load_aui("hud.aui") +if result.success and result.root is not None: + health_bar = result.root.find_by_id("player-health") + if isinstance(health_bar, HealthBar): + health_bar.value = 75 + health_bar.max_value = 100 ``` -Usage: +## Child Content + +Child markup is still useful, because the builder attaches child nodes after the custom component instance is created: ```html - - Health: 100 - Mana: 50 - + + + Boss + + ``` -## Complete Example: StatusBar - ```python -from arepy_ui.markup import register_component -from arepy_ui import Node, Text, Style, Color, Unit -from arepy_ui.components import ProgressBar -from arepy_ui.core.types import FlexDirection, AlignItems - -def StatusBar(props: dict) -> Node: - """Health/mana bar with icon and value.""" - icon = props.get("icon", "❤️") - value = float(props.get("value", 100)) - max_value = float(props.get("max", 100)) - color = props.get("color", "#ff4444") - - # Parse hex color - hex_val = color.lstrip("#") - r, g, b = [int(hex_val[i:i+2], 16) for i in (0, 2, 4)] - bar_color = Color(r, g, b) - - return Node( - style=Style( - flex_direction=FlexDirection.ROW, - align_items=AlignItems.CENTER, - gap=8, - width=Unit.px(200), - ), - children=[ - Text(icon, size=20), - ProgressBar( - value=value, - max_value=max_value, - style=Style( - flex=1, - height=Unit.px(12), - ), - fill_color=bar_color, - ), - Text(f"{int(value)}", size=12, color=Color(200, 200, 200)), - ], - ) +class Panel(Node): + pass -register_component("status-bar", StatusBar) + +register_component(Panel, tags=["panel"]) ``` -Usage in AUI: +## Current Limitation -```html - - - - - -``` +Do not rely on arbitrary markup attributes being converted into constructor kwargs for custom tags. Examples like `value="75"`, `max-value="100"`, or a generic `props` dictionary are not aligned with the current builder implementation. + +If you need data-driven custom components today, use one of these approaches: + +- configure the component after `load_aui()` by finding it with `id` +- express styling through ACSS and inline styles +- use built-in tags whose attributes are explicitly supported by the markup builder ## Registration API ```python -from arepy_ui.markup import ( - register_component, - unregister_component, - get_registered_components, +from arepy_ui import get_registry, register_component + +register_component( + HealthBar, + name="HealthBar", + tags=["healthbar", "health-bar"], + color=(255, 100, 100), + category="custom", ) -# Register -register_component("my-widget", MyWidgetFunction) - -# Unregister -unregister_component("my-widget") - -# List all registered -components = get_registered_components() -print(components) # ["icon-button", "card", "status-bar", ...] +registry = get_registry() +print(registry.get_valid_tags()) ``` ## Tips @@ -189,20 +135,18 @@ print(components) # ["icon-button", "card", "status-bar", ...] !!! tip "Naming Convention" Use kebab-case for custom component names: `my-component`, `status-bar`. -!!! tip "Props Validation" - Add defaults and type conversion in your component function. +!!! tip "Constructor Contract" + Keep custom components compatible with normal arepy-ui construction: accept `style`, optional `id`, and `**kwargs`. !!! tip "Reusable Components" Create a `components.py` file to register all your custom components at startup. ```python # components.py -from arepy_ui.markup import register_component +from arepy_ui import register_component def register_all(): - register_component("icon-button", IconButton) - register_component("card", Card) - register_component("status-bar", StatusBar) + register_component(HealthBar, tags=["healthbar", "health-bar"]) # main.py from components import register_all diff --git a/docs/reference/markup/globals.md b/docs/reference/markup/globals.md index 06d8b9c..a1c784a 100644 --- a/docs/reference/markup/globals.md +++ b/docs/reference/markup/globals.md @@ -53,8 +53,9 @@ def setup(game: ArepyEngine): # Now create UI ui_manager = UIManager.from_engine(game, config=UIConfig()) - root = load_aui("menu.aui") - ui_manager.set_root(root) + result = load_aui("menu.aui") + if result.success and result.root is not None: + ui_manager.set_root(result.root) game.add_resource(ui_manager) ``` @@ -149,8 +150,9 @@ def set_ocean_theme(): def refresh_ui(): # Rebuild UI to apply theme - root = load_aui("menu.aui") - ui_manager.set_root(root) + result = load_aui("menu.aui") + if result.success and result.root is not None: + ui_manager.set_root(result.root) ``` ## Theme API diff --git a/docs/reference/markup/index.md b/docs/reference/markup/index.md index f7ff4f7..b583ae6 100644 --- a/docs/reference/markup/index.md +++ b/docs/reference/markup/index.md @@ -87,8 +87,9 @@ Create two files: def setup(game): ui_manager = UIManager.from_engine(game, config=UIConfig()) - root = load_aui("menu.aui", context={"start": start}) - ui_manager.set_root(root) + result = load_aui("menu.aui", handlers={"start": start}) + if result.success and result.root is not None: + ui_manager.set_root(result.root) game.add_resource(ui_manager) ``` @@ -120,10 +121,11 @@ assets/ ```python from arepy_ui.markup import ( load_aui, # Load AUI file - load_acss, # Load ACSS file + load_aui_string, # Load AUI from string load_globals, # Load global styles set_theme, # Switch theme get_theme, # Get current theme - register_component, # Add custom element ) + +from arepy_ui import register_component # Add custom element ``` diff --git a/docs/resources/examples/components.md b/docs/resources/examples/components.md index fd3f02b..94d44a8 100644 --- a/docs/resources/examples/components.md +++ b/docs/resources/examples/components.md @@ -2,9 +2,6 @@ Interactive demo showcasing all arepy-ui components. - -![Components Demo](../../assets/examples/components-demo.gif) - ## Overview This example demonstrates all available components in action. @@ -156,8 +153,8 @@ def on_select_change(value): select_value = value print(f"Selected: {value}") -def update(): - ui_manager.update() +def update(game: ArepyEngine): + ui_manager.update(game.get_delta_time()) def draw(): ui_manager.render() diff --git a/docs/resources/examples/drag-drop.md b/docs/resources/examples/drag-drop.md index 1783377..864e229 100644 --- a/docs/resources/examples/drag-drop.md +++ b/docs/resources/examples/drag-drop.md @@ -2,9 +2,6 @@ Complete drag and drop implementation. - -![Drag Drop Demo](../../assets/examples/drag-drop-demo.gif) - ## Overview This example shows how to build a complete drag and drop system. @@ -12,7 +9,7 @@ This example shows how to build a complete drag and drop system. ## Run the Demo ```bash -uv run examples/demo_drag_drop.py +uv run examples/demo_drag.py ``` ## Source Code @@ -20,7 +17,7 @@ uv run examples/demo_drag_drop.py ```python """Drag and drop demo.""" from arepy import ArepyEngine, SystemPipeline -from arepy_ui import UIManager, Node, Text, Style, Color, Unit +from arepy_ui import UIConfig, UIManager, Node, Text, Style, Color, Unit from arepy_ui.components import Draggable, DropZone from arepy_ui.core.types import FlexDirection, JustifyContent, AlignItems from arepy_ui.core.style import Spacing @@ -55,9 +52,9 @@ def create_ui(): # Two columns Node( style=Style( + width=Unit.percent(100), flex_direction=FlexDirection.ROW, gap=40, - flex=1, ), children=[ Column("Left", left_items, "left"), @@ -92,19 +89,17 @@ def Column(title: str, items: list, column_id: str) -> Node: def DraggableItem(text: str, source: str) -> Node: return Draggable( data={"text": text, "source": source}, - children=[ - Node( - style=Style( - width=Unit.percent(100), - padding=Spacing.xy(h=12, v=8), - background_color=Color(70, 70, 90), - border_radius=6.0, - ), - children=[ - Text(text, size=14, color=Color(220, 220, 230)), - ], + content=Node( + style=Style( + width=Unit.percent(100), + padding=Spacing.symmetric(8, 12), + background_color=Color(70, 70, 90), + border_radius=6.0, ), - ], + children=[ + Text(text, size=14, color=Color(220, 220, 230)), + ], + ), ) def on_drop(target: str, data: dict): @@ -128,8 +123,8 @@ def on_drop(target: str, data: dict): # Rebuild UI create_ui() -def update(): - ui_manager.update() +def update(game: ArepyEngine): + ui_manager.update(game.get_delta_time()) def draw(): ui_manager.render() diff --git a/docs/resources/examples/inventory.md b/docs/resources/examples/inventory.md index 7105fd1..0f120e6 100644 --- a/docs/resources/examples/inventory.md +++ b/docs/resources/examples/inventory.md @@ -2,9 +2,6 @@ Complete grid-based inventory with drag and drop. - -![Inventory Demo](../../assets/examples/inventory-demo.gif) - ## Overview A full inventory system with: @@ -31,7 +28,7 @@ See the [Inventory Tutorial](../../learn/tutorials/inventory.md) for a step-by-s from dataclasses import dataclass from typing import Optional from arepy import ArepyEngine, SystemPipeline -from arepy_ui import UIManager, Node, Text, Style, Color, Unit +from arepy_ui import UIConfig, UIManager, Node, Text, Style, Color, Unit, PositionType from arepy_ui.components import Draggable, DropZone from arepy_ui.core.types import FlexDirection, JustifyContent, AlignItems from arepy_ui.core.style import Spacing @@ -126,33 +123,33 @@ def Slot(index: int, item: Optional[Item]) -> Node: children=[ Draggable( data={"slot": index, "item": item}, - children=[ - Node( - style=Style( - width=Unit.px(40), - height=Unit.px(40), - justify_content=JustifyContent.CENTER, - align_items=AlignItems.CENTER, - ), - children=[ - Text(item.icon, size=24), - Text(str(item.stack), size=10, - color=Color(255, 255, 255), - style=Style( - position="absolute", - right=Unit.px(2), - bottom=Unit.px(2), - ) - ) if item.stack > 1 else None, - ], + content=Node( + style=Style( + width=Unit.px(40), + height=Unit.px(40), + justify_content=JustifyContent.CENTER, + align_items=AlignItems.CENTER, ), - ], + children=[ + Text(item.icon, size=24), + Text( + str(item.stack), + size=10, + color=Color(255, 255, 255), + style=Style( + position=PositionType.ABSOLUTE, + right=Unit.px(2), + bottom=Unit.px(2), + ), + ) if item.stack > 1 else None, + ], + ), ), ], ) -def update(): - ui_manager.update() +def update(game: ArepyEngine): + ui_manager.update(game.get_delta_time()) def draw(): ui_manager.render() diff --git a/docs/resources/examples/markup.md b/docs/resources/examples/markup.md index 8918591..683ba93 100644 --- a/docs/resources/examples/markup.md +++ b/docs/resources/examples/markup.md @@ -2,9 +2,6 @@ Using AUI/ACSS markup files. - -![Markup Demo](../../assets/examples/markup-demo.png) - ## Overview This example shows how to build UI with declarative markup instead of Python code. @@ -133,23 +130,21 @@ def setup(game: ArepyEngine): ui_manager = UIManager.from_engine(game, config=UIConfig()) - # Load AUI with context - root = load_aui("assets/ui/menu.aui", context={ + # Load AUI with handlers + result = load_aui("assets/ui/menu.aui", handlers={ # Event handlers "new_game": new_game, "continue_game": continue_game, "options": options, "quit": quit, - # Data - "game_title": "My Game", - "version": "1.0.0", }) - - ui_manager.set_root(root) + + if result.success and result.root is not None: + ui_manager.set_root(result.root) game.add_resource(ui_manager) -def update(): - ui_manager.update() +def update(game: ArepyEngine): + ui_manager.update(game.get_delta_time()) def draw(): ui_manager.render() @@ -165,17 +160,9 @@ game.run() ## Key Concepts -### Context Variables +### Dynamic Data -Pass data to markup with `{variable}` syntax: - -```html -{player_name} -``` - -```python -load_aui("file.aui", context={"player_name": "Hero"}) -``` +The current loader maps callbacks through `handlers=`. For dynamic values, build the text in Python or rebuild the UI tree when your state changes. ### Event Handlers @@ -186,7 +173,7 @@ Reference functions by name: ``` ```python -load_aui("file.aui", context={"my_handler": my_function}) +load_aui("file.aui", handlers={"my_handler": my_function}) ``` ### CSS Variables diff --git a/docs/resources/tools/debugger.md b/docs/resources/tools/debugger.md index c90d36d..36e0442 100644 --- a/docs/resources/tools/debugger.md +++ b/docs/resources/tools/debugger.md @@ -1,224 +1,40 @@ # UI Debugger -The UIDebugger is a visual debugging tool that helps you inspect and understand your UI layout in real-time. +The debugger is built into `UIManager` and is intended for development-time inspection of layout, hover state, padding, and tree structure. - -![UIDebugger Overview](../assets/debugger-overview.png) -*The UIDebugger showing component bounds and hover information* - -## Overview - -The debugger provides: - -- **Component bounds** - Visual borders around each component -- **Hover inspection** - Detailed info panel when hovering over components -- **Component tree** - Hierarchical view of your UI structure -- **Padding visualization** - See padding areas highlighted -- **Keyboard shortcuts** - Quick toggles for different views - -## Quick Start +## Recommended Setup ```python -from arepy_ui.debug import UIDebugger -from arepy import Renderer2D, Input, Key - -# Create debugger instance -ui_debugger = UIDebugger() - -# The parameters 'renderer' and 'input' will be injected by arepy engine at runtime. -def update(renderer: Renderer2D, input: Input): - dt = renderer.get_delta_time() - ui_manager.update(dt) - - # Toggle debugger with F3 - if input.is_key_pressed(Key.F3): - ui_debugger.toggle() - -def render(): - ui_manager.render() - # Render debug overlay on top - ui_debugger.render(ui_manager.root) -``` - -## Keyboard Shortcuts - -| Key | Action | Description | -|-----|--------|-------------| -| `F3` | Toggle Debug | Enable/disable the entire debugger | -| `F4` | Toggle Bounds | Show/hide component boundary boxes | -| `F5` | Toggle Padding | Show/hide padding visualization | -| `F6` | Toggle Tree | Show/hide component tree panel | - - -![Debugger Shortcuts](../assets/debugger-shortcuts.gif) -*Using keyboard shortcuts to toggle debugger features* - -## Toolbar - -When enabled, the debugger displays a toolbar at the top of the screen: - -``` -[F3] Debug [F4] Bounds [F5] Padding [F6] Tree Hovering: Button #submit -``` - -- Active features are highlighted in color -- Inactive features are dimmed -- Current hovered component is shown on the right - - -![Debugger Toolbar](../assets/debugger-toolbar.png) -*The debugger toolbar showing active features* - -## Component Bounds - -When **Bounds** is enabled (F4), each component gets a colored border: - -- Different component types have different colors -- Hovered components are highlighted with a yellow glow -- Nested components show their hierarchy visually - - -![Bounds Visualization](../assets/debugger-bounds.png) -*Component bounds with different colors per type* - -### Component Colors - -| Component | Color | -|-----------|-------| -| Node | Gray | -| Text | Blue | -| Button | Green | -| TextInput | Cyan | -| Slider | Orange | -| Checkbox | Purple | -| Image | Pink | -| ScrollView | Teal | -| ColorPicker | Gold | - -## Hover Info Panel - -When you hover over a component, a detailed info panel appears showing: - - -![Info Panel](../assets/debugger-info-panel.png) -*Detailed component information on hover* - -### Layout Section - -``` -Position: (100, 200) -Size: 250 × 45 -``` - -Shows the computed position and dimensions in pixels. - -### Style Section - -``` -width: 100% -height: 45px -flex: row -gap: 10 -padding: 8 16 -``` - -Shows the applied style properties. - -### Props Section - -Component-specific properties: - -| Component | Properties Shown | -|-----------|-----------------| -| `Text` | text content, size | -| `Button` | label | -| `TextInput` | value, placeholder | -| `Slider` | value, range | -| `Checkbox` | checked state | -| `Image` | source path | -| `Video` | state, duration | -| `ColorPicker` | current color (RGBA) | -| `Select` | options count, selected index | - -### Tree Section - -``` -Parent: Node -Children: 3 -``` - -Shows the component's position in the hierarchy. - -## Component Tree - -When **Tree** is enabled (F6), a panel appears on the right showing the full component hierarchy: - - -![Component Tree](../assets/debugger-tree.png) -*The component tree panel* - -- Components are indented by depth -- Each component shows its type and ID -- Colored markers indicate component type -- Hovered component is highlighted in yellow - -``` -◆ Node #root - ├── Text #title - ├── Node #content - │ ├── Button #submit - │ └── Button #cancel - └── Text #footer -``` - -## Padding Visualization - -When **Padding** is enabled (F5), padding areas are highlighted in green: - - -![Padding Visualization](../assets/debugger-padding.png) -*Padding areas shown in green overlay* +from arepy.engine.input import Key +from arepy_ui import UIConfig, UIManager + +ui_manager = UIManager.from_engine( + game, + config=UIConfig( + debug_enabled=False, + debug_toggle_key=Key.F3, + debug_bounds_key=Key.F4, + debug_padding_key=Key.F5, + debug_tree_key=Key.F6, + ), +) -This helps you understand: -- Where padding is applied -- The actual size of padding on each side -- How padding affects layout +ui_manager.enable_debug_overlay(True) +debugger = ui_manager.get_debugger() +debugger.show_info = True -## Complete Example -```python -from arepy import ArepyEngine, Renderer2D, SystemPipeline -from arepy_ui import ( - UIManager, - UIConfig, - Node, - Text, - Button, - Style, - Unit, - AlignItems, - JustifyContent, -) +## Current Behavior +- `F3` toggles the overlay by default. +- Bounds follow visual scroll offsets correctly. +- Hover inspection uses the clipped visible area. +- Toolbar labels reflect configured hotkeys. +- Overlay text is ASCII-safe. -def setup(game: ArepyEngine): - # Initialize the UI manager from the engine and configure it - ui_manager = UIManager.from_engine(game, config=UIConfig()) - ui_manager.set_root( - Node( - style=Style( - width=Unit.percent(100), - height=Unit.percent(100), - align_items=AlignItems.CENTER, - justify_content=JustifyContent.CENTER, - ), - children=[ - Text("Debug Demo", size=24), - Button("Click me", on_click=lambda: print("Clicked!")), - ], - ) - ) +## Reference +For the runtime API, see [reference/api/debugger.md](../../reference/api/debugger.md). # Register as a game resource so systems can receive it via DI game.add_resource(ui_manager) diff --git a/docs/resources/tools/hot-reload.md b/docs/resources/tools/hot-reload.md index c6a9f46..2c8bc69 100644 --- a/docs/resources/tools/hot-reload.md +++ b/docs/resources/tools/hot-reload.md @@ -13,7 +13,6 @@ from arepy import ArepyEngine, SystemPipeline from arepy_ui import UIManager, UIConfig from arepy_ui.markup import load_aui, load_globals from pathlib import Path -import time ui_manager: UIManager = None last_modified = {} @@ -33,11 +32,15 @@ def setup(game: ArepyEngine): def reload_ui(): """Reload UI from files.""" try: - root = load_aui("assets/ui/menu.aui", context={ + result = load_aui("assets/ui/menu.aui", handlers={ "start_game": lambda: print("Start!"), }) - ui_manager.set_root(root) - print("UI reloaded!") + if result.success and result.root is not None: + ui_manager.set_root(result.root) + print("UI reloaded!") + else: + for error in result.errors: + print(error) except Exception as e: print(f"Reload failed: {e}") @@ -53,12 +56,12 @@ def check_for_changes(): pass return False -def update(): +def update(game: ArepyEngine): # Check every frame (or throttle for performance) if check_for_changes(): reload_ui() - ui_manager.update() + ui_manager.update(game.get_delta_time()) def draw(): ui_manager.render() @@ -72,6 +75,8 @@ game.set_current_world("main") game.run() ``` +`load_aui()` returns a `ParseResult`, so hot reload should only replace the root when parsing succeeds. + ## Using Watchdog For better performance, use the `watchdog` package: diff --git a/examples/demo_animations.py b/examples/demo_animations.py index d565dfb..ee40d0c 100644 --- a/examples/demo_animations.py +++ b/examples/demo_animations.py @@ -9,13 +9,10 @@ """ import raylib as rl -from arepy import ArepyEngine, Display, Input, Renderer2D, SystemPipeline -from arepy.ecs.world import World +from arepy import ArepyEngine from arepy_ui import ( AlignItems, - Animation, - Animator, Button, Color, Easing, @@ -31,11 +28,9 @@ # Estado global ui_manager: UIManager = None # type: ignore -animator: Animator = None # type: ignore # Nodos animables animated_boxes: list[Node] = [] -easing_labels: list[Text] = [] def create_animated_box(color: Color, label: str) -> tuple[Node, Node]: @@ -71,10 +66,10 @@ def create_animated_box(color: Color, label: str) -> tuple[Node, Node]: def run_all_animations(): """Ejecuta todas las animaciones de demostración.""" - global animator, animated_boxes, ui_manager + global animated_boxes, ui_manager # Limpiar animaciones previas - animator.animations.clear() + ui_manager.animator.clear() easings = [ Easing.LINEAR, @@ -99,24 +94,35 @@ def run_all_animations(): for i, box in enumerate(animated_boxes): if i < len(easings): - # Animar posición X (usando margin.left) - anim = Animation( - target=box.style, - property_name="margin.left", - start_value=0, - end_value=300, - duration=2.0, - easing=easings[i], - ) - animator.add(anim) + delay = i * 0.05 + ui_manager.animator.create().wait(delay).to( + box.style, + "margin.left", + 300, + 2.0, + easings[i], + ).start() + ui_manager.animator.create().wait(delay).to( + box.style, + "opacity", + 0.55, + 0.16, + Easing.EASE_OUT_QUAD, + ).to( + box.style, + "opacity", + 1.0, + 0.20, + Easing.EASE_OUT_QUAD, + ).start() def reset_animations(): """Resetea todas las cajas a su posición inicial.""" - global animated_boxes, animator + global animated_boxes, ui_manager # Limpiar animaciones activas - animator.animations.clear() + ui_manager.animator.clear() # Resetear posiciones (usar Unit.px para crear el objeto correcto) for box in animated_boxes: @@ -126,6 +132,7 @@ def reset_animations(): bottom=Unit.px(0), left=Unit.px(0), ) + box.style.opacity = 1.0 def create_ui() -> Node: @@ -257,44 +264,6 @@ def create_ui() -> Node: return root -def ui_update_system(renderer: Renderer2D, input: Input, display: Display): - """Sistema de UPDATE.""" - global ui_manager, animator - - dt = renderer.get_delta_time() - - # Actualizar animaciones - has_active_animations = len(animator.animations) > 0 - animator.update(dt) - - # Si hay animaciones activas, marcar el layout como dirty para que se recalcule - if has_active_animations: - ui_manager.mark_dirty() - - # Actualizar UI - wheel_scroll = input.get_mouse_wheel_delta() - ui_manager.update(dt, wheel_scroll=wheel_scroll) - - -def ui_render_system(renderer: Renderer2D): - """Sistema de RENDER_UI.""" - global ui_manager - ui_manager.render() - - -def setup_system(game: ArepyEngine): - """Sistema de configuración inicial.""" - global ui_manager, animator - - # Crear animator - animator = Animator() - - # Crear UI usando from_engine - ui_manager = UIManager.from_engine(game) - root = create_ui() - ui_manager.set_root(root) - - def main(): # Ventana resizable rl.SetConfigFlags(rl.FLAG_WINDOW_RESIZABLE) @@ -304,11 +273,11 @@ def main(): width=800, height=700, ) - game.on_startup = lambda: setup_system(game) # type: ignore - world: World = game.create_world("animation_demo") - world.add_system(SystemPipeline.UPDATE, ui_update_system) - world.add_system(SystemPipeline.RENDER_UI, ui_render_system) + world = game.create_world("animation_demo") + + global ui_manager + ui_manager = UIManager.install(world, root=create_ui()) game.set_current_world("animation_demo") game.run() diff --git a/examples/demo_blue_hour_menu.py b/examples/demo_blue_hour_menu.py new file mode 100644 index 0000000..1ff370c --- /dev/null +++ b/examples/demo_blue_hour_menu.py @@ -0,0 +1,1750 @@ +from __future__ import annotations + +"""Minimal Slot Island title screen demo.""" + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +import raylib as rl +from arepy import ( + ArepyEngine, + ArepyShader, + Renderer2D, + ShaderUniformType, + SystemPipeline, + TextureFilter, +) +from arepy.engine.renderer import Rect +from arepy.engine.time import Time + +from arepy_ui import ( + AlignItems, + Checkbox, + Color, + CursorType, + Easing, + FlexDirection, + FontLoadRequest, + JustifyContent, + Node, + PositionType, + ResizeMode, + Slider, + Spacing, + Style, + Text, + UIConfig, + UIManager, + Unit, + draw_text, + load_fonts, +) +from arepy_ui.runtime import get_runtime + + +WHITE = Color(255, 255, 255, 255) +BLACK = Color(8, 8, 8, 255) +SOFT_BLACK = Color(48, 48, 48, 255) +DIM_BLACK = Color(96, 96, 96, 255) +LIGHT_GRAY = Color(232, 232, 232, 255) +MID_GRAY = Color(196, 196, 196, 255) +HAZE_GRAY = Color(244, 244, 244, 255) +PARCHMENT = WHITE +PARCHMENT_SOFT = LIGHT_GRAY +PARCHMENT_GLOW = WHITE +INK = BLACK +INK_SOFT = SOFT_BLACK +WINE = BLACK +WINE_DARK = BLACK +GOLD = BLACK +GOLD_SOFT = MID_GRAY +GOLD_LINE = MID_GRAY +SHADOW_WARM = Color(0, 0, 0, 48) + +FONT_DISPLAY: str | None = None +FONT_BODY: str | None = None +FONT_META: str | None = None + +ui_manager: UIManager | None = None +background_shader: ArepyShader | None = None + +scene_clock = 0.0 +current_view = "idle" +current_settings_view = "video" +active_menu_key = "" +loading_active = False +loading_complete = False +loading_elapsed = 0.0 +loading_progress = 0.0 +loading_stage_index = -1 + + +class MainView(str, Enum): + IDLE = "idle" + SETTINGS = "settings" + CREDITS = "credits" + LOADING = "loading" + + +class SettingsView(str, Enum): + VIDEO = "video" + SOUND = "sound" + INTERFACE = "interface" + + +@dataclass(frozen=True, slots=True) +class ChangelogEntry: + version: str + title: str + detail: str + + +CHANGELOG = [ + ChangelogEntry( + version="0.1.7a", + title="Cozy casino pass", + detail="The menu now leans into warm parchment, burgundy felt, and old-gold accents instead of harsh black-white blocks.", + ), + ChangelogEntry( + version="0.1.6", + title="House flow tightened", + detail="Play, credits, settings, and loading each own their panel, so the right side only shows what matters now.", + ), + ChangelogEntry( + version="0.1.5", + title="Sky parlor backdrop", + detail="The title screen keeps the blue-purple cloud sky, but the interface now reads like a fantasy casino room floating under it.", + ), +] + + +LOADING_PHASES = [ + ( + 0.0, + 0.16, + "Shuffling the first deck", + "The summoned hero is being seated at the opening table.", + ), + ( + 0.9, + 0.42, + "Lighting the velvet hall", + "Lantern glow, cloud drift, and shoreline paths are settling into place.", + ), + ( + 1.9, + 0.72, + "Stacking the island wagers", + "Occupied routes, docks, and first-region markers are being laid out.", + ), + ( + 3.1, + 1.0, + "Opening the house gate", + "The first shore is ready for a lucky hand.", + ), +] + + +menu_buttons: dict[str, "MenuButton"] = {} +settings_tab_buttons: dict[SettingsView, "SettingsTabButton"] = {} +view_panels: dict[MainView, Node] = {} +settings_panels: dict[SettingsView, Node] = {} +intro_nodes: list[Node] = [] +back_buttons: list["BackButton"] = [] + +loading_bar: "LoadingBar | None" = None +loading_percent_text: Text | None = None +loading_status_text: Text | None = None +loading_hint_text: Text | None = None +loading_steps_text: Text | None = None +title_text: Node | None = None +changelog_title: Text | None = None +menu_shell: Node | None = None +detail_shell: Node | None = None +detail_card: Node | None = None + + +FRAGMENT_SHADER = """ +#version 330 + +in vec4 fragColor; +out vec4 finalColor; + +uniform vec2 u_resolution; +uniform float u_time; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; +} + +float fbm(vec2 p) { + float value = 0.0; + float amplitude = 0.5; + for (int i = 0; i < 5; i++) { + value += amplitude * noise(p); + p = p * 2.03 + vec2(11.2, 7.6); + amplitude *= 0.5; + } + return value; +} + +void main() { + vec2 uv = gl_FragCoord.xy / u_resolution.xy; + uv.y = 1.0 - uv.y; + + vec3 top = vec3(0.54, 0.76, 1.00); + vec3 mid = vec3(0.71, 0.81, 1.00); + vec3 bottom = vec3(0.86, 0.78, 0.98); + vec3 color = mix(top, mid, smoothstep(0.0, 0.45, uv.y)); + color = mix(color, bottom, smoothstep(0.35, 1.0, uv.y)); + + float sun = smoothstep(0.18, 0.0, distance(uv, vec2(0.78, 0.18))); + color += vec3(0.12, 0.08, 0.02) * sun; + + float cloud = fbm(vec2(uv.x * 2.7 + u_time * 0.020, uv.y * 4.0 - u_time * 0.008)); + float detail = fbm(vec2(uv.x * 5.8 - u_time * 0.016, uv.y * 7.2 + 4.6)); + float wave = 0.03 * sin(uv.x * 5.4 + u_time * 0.12); + float mask = smoothstep(0.56 - wave, 0.84 - wave, cloud + detail * 0.32); + color = mix(color, vec3(0.97, 0.98, 1.0), mask * 0.58); + + float lower = fbm(vec2(uv.x * 3.0 + 2.4, uv.y * 6.5 - u_time * 0.011)); + float lower_mask = smoothstep(0.58, 0.88, lower) * smoothstep(1.0, 0.30, uv.y); + color = mix(color, vec3(0.92, 0.89, 0.99), lower_mask * 0.22); + + float vignette = uv.x * (1.0 - uv.x) * uv.y * (1.0 - uv.y); + color *= 0.96 + vignette * 0.12; + + finalColor = vec4(color, 1.0); +} +""" + + +def clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float: + return max(minimum, min(maximum, value)) + + +def lerp(start: float, end: float, progress: float) -> float: + return start + (end - start) * progress + + +def ease_to(current: float, target: float, speed: float, dt: float) -> float: + return current + (target - current) * min(1.0, speed * dt) + + +def with_opacity(color: Color, opacity: float) -> Color: + return Color(color.r, color.g, color.b, int(color.a * clamp(opacity))) + + +def mix_color(start: Color, end: Color, progress: float) -> Color: + t = clamp(progress) + return Color( + int(round(lerp(start.r, end.r, t))), + int(round(lerp(start.g, end.g, t))), + int(round(lerp(start.b, end.b, t))), + int(round(lerp(start.a, end.a, t))), + ) + + +def demo_font_path(group: str, filename: str) -> str: + return str( + Path(__file__).resolve().parent + / "fonts" + / "dead-revolver" + / group + / "TTF" + / filename + ) + + +def setup_demo_fonts() -> None: + global FONT_DISPLAY, FONT_BODY, FONT_META + + requests = [ + FontLoadRequest( + name="slot-display", + path=demo_font_path("Display", "DeadRevolverDisplay.ttf"), + base_size=104, + texture_filter=TextureFilter.NEAREST, + glyphs="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /:-", + ), + FontLoadRequest( + name="slot-body", + path=demo_font_path("Game", "DeadRevolverGameCompact.ttf"), + base_size=54, + set_as_default=True, + texture_filter=TextureFilter.NEAREST, + ), + FontLoadRequest( + name="slot-meta", + path=demo_font_path("Digital", "DeadRevolverDigital.ttf"), + base_size=34, + texture_filter=TextureFilter.NEAREST, + ), + ] + load_fonts(requests) + FONT_DISPLAY = "slot-display" + FONT_BODY = "slot-body" + FONT_META = "slot-meta" + + +def font_for(role: str) -> str | None: + if role == "display": + return FONT_DISPLAY + if role == "meta": + return FONT_META + return FONT_BODY + + +def ui_text(text: str, size: float, color: Color, role: str = "body") -> Text: + return Text(text, size=size, color=color, font_name=font_for(role)) + + +def create_separator(height: float = 1.0, color: Color = GOLD_LINE) -> Node: + return Node( + style=Style( + width=Unit.percent(100), + height=Unit.px(height), + background_color=color, + ) + ) + + +def create_stage_panel() -> Node: + panel = Node( + style=Style( + width=Unit.percent(100), + height=Unit.percent(100), + position=PositionType.ABSOLUTE, + top=Unit.px(0), + left=Unit.px(0), + padding=Spacing.all(10), + gap=16, + ) + ) + panel.style.visible = False + panel.style.opacity = 0.0 + return panel + + +def draw_diagonal_fill( + renderer: Renderer2D, + x: float, + y: float, + width: float, + height: float, + cut: float, + color: Color, +) -> None: + total_height = max(1, int(round(height))) + if total_height <= 0 or width <= 0: + return + + step_height = 3 + rows = max(1, (total_height + step_height - 1) // step_height) + for row_index in range(rows): + row_y = y + row_index * step_height + current_height = min(step_height, total_height - row_index * step_height) + progress = (row_index + 1) / rows + row_width = max(1, int(round(width - cut * progress))) + renderer.draw_rectangle( + Rect(int(x), int(row_y), row_width, int(current_height)), + color, + ) + + +class OutlinedTitle(Node): + def __init__(self, label: str): + super().__init__( + style=Style( + width=Unit.percent(100), + height=Unit.px(152), + ) + ) + self.label = label + self.pickable = False + + def render(self) -> None: + if not self.style.visible or self.style.opacity <= 0.0: + return + + opacity = clamp(self.style.opacity) + x = self.computed_x + 8 + y = self.computed_y + 18 + outline_color = with_opacity(BLACK, opacity) + fill_color = with_opacity(WHITE, opacity) + shadow_color = with_opacity(GOLD_SOFT, opacity * 0.85) + offsets = [ + (-3, 0), + (3, 0), + (0, -3), + (0, 3), + (-2, -2), + (2, -2), + (-2, 2), + (2, 2), + ] + + draw_text( + self.label, + x + 7, + y + 7, + 72, + shadow_color, + font_for("display"), + 1.0, + ) + + for offset_x, offset_y in offsets: + draw_text( + self.label, + x + offset_x, + y + offset_y, + 72, + outline_color, + font_for("display"), + 1.0, + ) + + draw_text( + self.label, + x, + y, + 72, + fill_color, + font_for("display"), + 1.0, + ) + + +class MenuButton(Node): + def __init__(self, key: str, label: str, on_press): + super().__init__( + style=Style( + width=Unit.percent(100), + height=Unit.px(84), + cursor=CursorType.POINTING_HAND, + ) + ) + self.key = key + self.label = label + self._on_press = on_press + self.hover_progress = 0.0 + self.hover_target = 0.0 + self.select_progress = 0.0 + self.select_target = 0.0 + self.flash_progress = 0.0 + self.slide_x = -44.0 + self.base_height = 84.0 + self.hover_height = 122.0 + self.current_height = self.base_height + self.on_hover_enter = self._handle_hover_enter + self.on_hover_exit = self._handle_hover_exit + self.on_click = self._handle_click + + def _handle_hover_enter(self) -> None: + self.hover_target = 1.0 + + def _handle_hover_exit(self) -> None: + self.hover_target = 0.0 + + def _handle_click(self) -> None: + self.flash_progress = 1.0 + self._on_press(self.key) + + def set_selected(self, selected: bool) -> None: + self.select_target = 1.0 if selected else 0.0 + + def tick(self, dt: float) -> None: + self.hover_progress = ease_to(self.hover_progress, self.hover_target, 16.0, dt) + self.select_progress = ease_to( + self.select_progress, self.select_target, 12.0, dt + ) + self.flash_progress = max(0.0, self.flash_progress - dt * 2.6) + + target_height = self.base_height + (self.hover_height - self.base_height) * self.hover_target + next_height = ease_to(self.current_height, target_height, 12.0, dt) + if abs(next_height - self.current_height) > 0.1: + self.current_height = next_height + self.style.height = Unit.px(self.current_height) + + def render(self) -> None: + if not self.style.visible or self.style.opacity <= 0.0: + return + + runtime = get_runtime() + x = self.computed_x + self.slide_x + y = self.computed_y + width = self.computed_width + height = self.computed_height + opacity = clamp(self.style.opacity) + active = clamp(self.select_progress + self.hover_progress * 0.9) + growth = 34.0 * active + draw_y = y + draw_width = width + growth + draw_height = height + diagonal_cut = 30.0 + 20.0 * active + shadow_color = with_opacity(SHADOW_WARM, opacity * (0.22 + active * 0.16)) + fill_color = with_opacity(mix_color(PARCHMENT, WINE, active), opacity) + text_color = with_opacity(mix_color(WINE_DARK, PARCHMENT_GLOW, active), opacity) + accent_color = with_opacity(mix_color(GOLD_LINE, GOLD_SOFT, active * 0.7), opacity) + + draw_diagonal_fill( + runtime.renderer, + x + 7, + draw_y + 8, + draw_width, + draw_height, + diagonal_cut, + shadow_color, + ) + + draw_diagonal_fill( + runtime.renderer, + x, + draw_y, + draw_width, + draw_height, + diagonal_cut, + fill_color, + ) + + if self.flash_progress > 0.0: + draw_diagonal_fill( + runtime.renderer, + x, + draw_y, + draw_width, + draw_height, + diagonal_cut, + with_opacity( + mix_color(BLACK, WHITE, 0.8), opacity * self.flash_progress * 0.12 + ), + ) + + runtime.renderer.draw_rectangle( + Rect(int(x + 16), int(draw_y + draw_height - 8), int(54 + active * 44), 4), + accent_color, + ) + runtime.renderer.draw_rectangle( + Rect(int(x + 14), int(draw_y + 12), int(12 + active * 3), int(draw_height - 24)), + with_opacity(mix_color(GOLD_SOFT, GOLD, active), opacity * (0.22 + active * 0.22)), + ) + + draw_text( + self.label.upper(), + x + 26 + active * 12, + draw_y + (draw_height * 0.5) - 21 - active * 2, + 30 + active * 3.0, + text_color, + font_for("body"), + 1.4, + ) + + +class SettingsTabButton(Node): + def __init__(self, tab: SettingsView, label: str, on_press): + super().__init__( + style=Style( + width=Unit.percent(31.8), + height=Unit.px(56), + cursor=CursorType.POINTING_HAND, + ) + ) + self.tab = tab + self.label = label + self._on_press = on_press + self.hover_progress = 0.0 + self.hover_target = 0.0 + self.select_progress = 0.0 + self.select_target = 0.0 + self.on_hover_enter = self._handle_hover_enter + self.on_hover_exit = self._handle_hover_exit + self.on_click = self._handle_click + + def _handle_hover_enter(self) -> None: + self.hover_target = 1.0 + + def _handle_hover_exit(self) -> None: + self.hover_target = 0.0 + + def _handle_click(self) -> None: + self._on_press(self.tab) + + def set_selected(self, selected: bool) -> None: + self.select_target = 1.0 if selected else 0.0 + + def tick(self, dt: float) -> None: + self.hover_progress = ease_to(self.hover_progress, self.hover_target, 18.0, dt) + self.select_progress = ease_to( + self.select_progress, self.select_target, 12.0, dt + ) + + def render(self) -> None: + if not self.style.visible or self.style.opacity <= 0.0: + return + + runtime = get_runtime() + x = self.computed_x + y = self.computed_y + width = self.computed_width + height = self.computed_height + opacity = clamp(self.style.opacity) + active = clamp(self.select_progress + self.hover_progress * 0.65) + fill_color = with_opacity(mix_color(PARCHMENT_SOFT, WINE_DARK, active), opacity) + text_color = with_opacity(mix_color(INK, PARCHMENT_GLOW, active), opacity) + + runtime.renderer.draw_rectangle( + Rect(int(x), int(y), int(width), int(height)), + fill_color, + ) + runtime.renderer.draw_rectangle( + Rect(int(x), int(y + height - 2), int(width), 2), + with_opacity(mix_color(GOLD_LINE, GOLD, active), opacity), + ) + draw_text( + self.label.upper(), + x + 14, + y + 14, + 18, + text_color, + font_for("meta"), + 1.3, + ) + + +class BackButton(Node): + def __init__(self, label: str, on_press): + super().__init__( + style=Style( + width=Unit.px(132), + height=Unit.px(52), + cursor=CursorType.POINTING_HAND, + ) + ) + self.label = label + self._on_press = on_press + self.hover_progress = 0.0 + self.hover_target = 0.0 + self.on_hover_enter = self._handle_hover_enter + self.on_hover_exit = self._handle_hover_exit + self.on_click = self._handle_click + + def _handle_hover_enter(self) -> None: + self.hover_target = 1.0 + + def _handle_hover_exit(self) -> None: + self.hover_target = 0.0 + + def _handle_click(self) -> None: + self._on_press() + + def tick(self, dt: float) -> None: + self.hover_progress = ease_to(self.hover_progress, self.hover_target, 18.0, dt) + + def render(self) -> None: + if not self.style.visible or self.style.opacity <= 0.0: + return + + runtime = get_runtime() + x = self.computed_x + y = self.computed_y + width = self.computed_width + height = self.computed_height + opacity = clamp(self.style.opacity) + active = self.hover_progress + fill_color = with_opacity(mix_color(WHITE, BLACK, active), opacity) + text_color = with_opacity(mix_color(BLACK, WHITE, active), opacity) + + runtime.renderer.draw_rectangle( + Rect(int(x), int(y), int(width), int(height)), + fill_color, + ) + runtime.renderer.draw_rectangle_lines_ex( + Rect(int(x), int(y), int(width), int(height)), + 2, + with_opacity(BLACK, opacity), + ) + draw_text( + self.label.upper(), + x + 18, + y + 14, + 18, + text_color, + font_for("meta"), + 1.3, + ) + + +class LoadingBar(Node): + def __init__(self): + super().__init__( + style=Style( + width=Unit.percent(100), + height=Unit.px(14), + ) + ) + self.progress = 0.0 + self.display_progress = 0.0 + + def tick(self, dt: float) -> None: + self.display_progress = ease_to(self.display_progress, self.progress, 8.5, dt) + + def render(self) -> None: + if not self.style.visible or self.style.opacity <= 0.0: + return + + runtime = get_runtime() + x = self.computed_x + y = self.computed_y + width = self.computed_width + height = self.computed_height + opacity = clamp(self.style.opacity) + + runtime.renderer.draw_rectangle( + Rect(int(x), int(y), int(width), int(height)), + with_opacity(PARCHMENT_SOFT, opacity), + ) + fill_width = max(0, int(width * clamp(self.display_progress))) + if fill_width > 0: + runtime.renderer.draw_rectangle( + Rect(int(x), int(y), fill_width, int(height)), + with_opacity(WINE, opacity), + ) + runtime.renderer.draw_rectangle( + Rect(int(max(x, x + fill_width - 18)), int(y), min(18, fill_width), int(height)), + with_opacity(GOLD_SOFT, opacity * 0.55), + ) + runtime.renderer.draw_rectangle( + Rect(int(x), int(y), int(width), 1), + with_opacity(GOLD_LINE, opacity), + ) + runtime.renderer.draw_rectangle( + Rect(int(x), int(y + height - 1), int(width), 1), + with_opacity(GOLD_LINE, opacity), + ) + + +def create_slider_setting( + title: str, + value: float, + *, + min_value: float = 0.0, + max_value: float = 100.0, + suffix: str = "%", +) -> Node: + container = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + gap=8, + ) + ) + container.add_child(create_separator()) + + row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + justify_content=JustifyContent.SPACE_BETWEEN, + align_items=AlignItems.CENTER, + gap=12, + ) + ) + row.add_child(ui_text(title.upper(), 18, INK, role="body")) + value_text = ui_text(f"{int(value)}{suffix}", 16, SOFT_BLACK, role="meta") + row.add_child(value_text) + + slider = Slider( + min_value=min_value, + max_value=max_value, + value=value, + width=Unit.percent(100), + height=Unit.px(28), + track_color=PARCHMENT_SOFT, + fill_color=WINE, + thumb_color=GOLD, + thumb_size=16.0, + track_height=10.0, + on_change=lambda new_value, text=value_text: setattr( + text, "text", f"{int(new_value)}{suffix}" + ), + style=Style( + width=Unit.percent(100), + height=Unit.px(28), + ), + ) + + container.add_child(row) + container.add_child(slider) + return container + + +def create_toggle_setting(title: str, checked: bool) -> Node: + container = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + gap=8, + ) + ) + container.add_child(create_separator()) + + row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + justify_content=JustifyContent.SPACE_BETWEEN, + align_items=AlignItems.CENTER, + gap=12, + ) + ) + row.add_child(ui_text(title.upper(), 18, INK, role="body")) + + control = Node( + style=Style( + width=Unit.auto(), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + gap=10, + align_items=AlignItems.CENTER, + ) + ) + state_text = ui_text("ON" if checked else "OFF", 16, SOFT_BLACK, role="meta") + checkbox = Checkbox( + checked=checked, + color=WINE, + unchecked_color=PARCHMENT_SOFT, + size=24.0, + on_change=lambda enabled, text=state_text: setattr( + text, "text", "ON" if enabled else "OFF" + ), + ) + control.add_child(state_text) + control.add_child(checkbox) + row.add_child(control) + + container.add_child(row) + return container + + +def create_credit_row(label: str, value: str) -> Node: + row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + gap=6, + ) + ) + row.add_child(create_separator()) + row.add_child(ui_text(label.upper(), 14, DIM_BLACK, role="meta")) + row.add_child(ui_text(value, 18, INK, role="body")) + return row + + +def create_changelog_entry(entry: ChangelogEntry) -> Node: + row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + gap=6, + ) + ) + row.add_child(create_separator()) + row.add_child(ui_text(entry.version, 10, GOLD_LINE, role="meta")) + row.add_child(ui_text(entry.title.upper(), 11, WINE_DARK, role="body")) + row.add_child(ui_text(entry.detail, 11, INK_SOFT, role="body")) + return row + + +def create_panel_header(title: str, subtitle: str) -> Node: + header = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + gap=14, + ) + ) + + top_row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + align_items=AlignItems.CENTER, + ) + ) + back_button = BackButton("Back", return_to_menu) + back_buttons.append(back_button) + top_row.add_child(back_button) + + header.add_child(top_row) + header.add_child(ui_text(title, 46, BLACK, role="display")) + header.add_child(ui_text(subtitle, 16, SOFT_BLACK, role="body")) + header.add_child(create_separator(2.0, BLACK)) + return header + + +def build_loading_steps(active_index: int, complete: bool = False) -> str: + lines = [] + for index, (_start, _target, label, _hint) in enumerate(LOADING_PHASES): + if complete or index < active_index: + prefix = "[OK]" + elif index == active_index: + prefix = "[>>]" + else: + prefix = "[ ]" + lines.append(f"{prefix} {label}") + return "\n".join(lines) + + +def finalize_view_hide(view: MainView) -> None: + panel = view_panels.get(view) + if panel is None or current_view == view.value: + return + panel.style.visible = False + panel.style.left = Unit.px(0) + panel.style.opacity = 0.0 + + +def finalize_menu_hide() -> None: + if menu_shell is None or current_view == MainView.IDLE.value: + return + menu_shell.style.visible = False + menu_shell.style.left = Unit.px(0) + menu_shell.style.opacity = 0.0 + + +def finalize_detail_hide() -> None: + if detail_shell is None or current_view != MainView.IDLE.value: + return + detail_shell.style.visible = False + detail_shell.style.left = Unit.px(0) + detail_shell.style.opacity = 0.0 + if detail_card is not None: + detail_card.style.margin.top = Unit.px(0) + + +def finalize_settings_hide(view: SettingsView) -> None: + panel = settings_panels.get(view) + if panel is None or current_settings_view == view.value: + return + panel.style.visible = False + panel.style.left = Unit.px(0) + panel.style.opacity = 0.0 + + +def switch_view(view: MainView, *, instant: bool = False, force: bool = False) -> None: + global current_view + + if current_view == view.value and not force: + return + + old_view = MainView(current_view) + old_panel = view_panels.get(old_view) + new_panel = view_panels.get(view) + current_view = view.value + + show_menu = view == MainView.IDLE + show_detail = view != MainView.IDLE + + if new_panel is None and show_detail: + return + + if instant or ui_manager is None: + if menu_shell is not None: + menu_shell.style.visible = show_menu + menu_shell.style.opacity = 1.0 if show_menu else 0.0 + menu_shell.style.left = Unit.px(0) + if detail_shell is not None: + detail_shell.style.visible = show_detail + detail_shell.style.opacity = 1.0 if show_detail else 0.0 + detail_shell.style.left = Unit.px(0) + if detail_card is not None: + detail_card.style.margin.top = Unit.px(0) + for panel_view, panel in view_panels.items(): + is_active = show_detail and panel_view == view + panel.style.visible = is_active + panel.style.opacity = 1.0 if is_active else 0.0 + panel.style.left = Unit.px(0) + return + + if old_panel is not None and old_panel is not new_panel: + ui_manager.animator.create().to( + old_panel.style, + "opacity", + 0.0, + 0.16, + Easing.EASE_OUT_CUBIC, + ).call(lambda closing=old_view: finalize_view_hide(closing)).start() + ui_manager.animator.create().to( + old_panel.style, + "left", + Unit.px(16), + 0.16, + Easing.EASE_OUT_CUBIC, + ).start() + + if show_menu: + if detail_shell is not None: + ui_manager.animator.create().to( + detail_shell.style, + "opacity", + 0.0, + 0.18, + Easing.EASE_OUT_CUBIC, + ).call(finalize_detail_hide).start() + if detail_card is not None: + ui_manager.animator.create().to( + detail_card.style, + "margin.top", + Unit.px(18), + 0.18, + Easing.EASE_OUT_CUBIC, + ).start() + if menu_shell is not None: + menu_shell.style.visible = True + menu_shell.style.opacity = 0.0 + menu_shell.style.left = Unit.px(-18) + ui_manager.animator.create().to( + menu_shell.style, + "opacity", + 1.0, + 0.26, + Easing.EASE_OUT_CUBIC, + ).start() + ui_manager.animator.create().to( + menu_shell.style, + "left", + Unit.px(0), + 0.34, + Easing.EASE_OUT_EXPO, + ).start() + return + + if menu_shell is not None: + ui_manager.animator.create().to( + menu_shell.style, + "opacity", + 0.0, + 0.16, + Easing.EASE_OUT_CUBIC, + ).call(finalize_menu_hide).start() + ui_manager.animator.create().to( + menu_shell.style, + "left", + Unit.px(-22), + 0.18, + Easing.EASE_OUT_CUBIC, + ).start() + + if detail_shell is not None: + detail_shell.style.visible = True + if old_view == MainView.IDLE: + detail_shell.style.opacity = 0.0 + detail_shell.style.left = Unit.px(0) + ui_manager.animator.create().to( + detail_shell.style, + "opacity", + 1.0, + 0.22, + Easing.EASE_OUT_CUBIC, + ).start() + + if detail_card is not None and old_view == MainView.IDLE: + detail_card.style.margin.top = Unit.px(20) + ui_manager.animator.create().to( + detail_card.style, + "margin.top", + Unit.px(0), + 0.30, + Easing.EASE_OUT_EXPO, + ).start() + + if new_panel is not None: + new_panel.style.visible = True + new_panel.style.opacity = 0.0 + new_panel.style.left = Unit.px(-18) + ui_manager.animator.create().to( + new_panel.style, + "opacity", + 1.0, + 0.24, + Easing.EASE_OUT_CUBIC, + ).start() + ui_manager.animator.create().to( + new_panel.style, + "left", + Unit.px(0), + 0.30, + Easing.EASE_OUT_EXPO, + ).start() + + +def return_to_menu() -> None: + stop_loading_sequence() + set_active_menu(None) + switch_view(MainView.IDLE) + + +def switch_settings_view( + view: SettingsView, + *, + instant: bool = False, + force: bool = False, +) -> None: + global current_settings_view + + if current_settings_view == view.value and not force: + return + + old_view = SettingsView(current_settings_view) + old_panel = settings_panels.get(old_view) + new_panel = settings_panels.get(view) + current_settings_view = view.value + + for tab, button in settings_tab_buttons.items(): + button.set_selected(tab == view) + + if new_panel is None: + return + + if instant or ui_manager is None: + for panel_view, panel in settings_panels.items(): + panel.style.visible = panel_view == view + panel.style.opacity = 1.0 if panel_view == view else 0.0 + panel.style.left = Unit.px(0) + return + + if old_panel is not None and old_panel is not new_panel: + ui_manager.animator.create().to( + old_panel.style, + "opacity", + 0.0, + 0.14, + Easing.EASE_OUT_CUBIC, + ).call(lambda closing=old_view: finalize_settings_hide(closing)).start() + ui_manager.animator.create().to( + old_panel.style, + "left", + Unit.px(14), + 0.14, + Easing.EASE_OUT_CUBIC, + ).start() + + new_panel.style.visible = True + new_panel.style.opacity = 0.0 + new_panel.style.left = Unit.px(-14) + ui_manager.animator.create().to( + new_panel.style, + "opacity", + 1.0, + 0.20, + Easing.EASE_OUT_CUBIC, + ).start() + ui_manager.animator.create().to( + new_panel.style, + "left", + Unit.px(0), + 0.26, + Easing.EASE_OUT_EXPO, + ).start() + + +def set_active_menu(key: str | None) -> None: + global active_menu_key + + active_menu_key = key or "" + for button_key, button in menu_buttons.items(): + button.set_selected(button_key == active_menu_key) + + +def stop_loading_sequence() -> None: + global loading_active, loading_complete, loading_elapsed, loading_progress, loading_stage_index + + loading_active = False + loading_complete = False + loading_elapsed = 0.0 + loading_progress = 0.0 + loading_stage_index = -1 + if loading_bar is not None: + loading_bar.progress = 0.0 + loading_bar.display_progress = 0.0 + if loading_percent_text is not None: + loading_percent_text.text = "000%" + if loading_status_text is not None: + loading_status_text.text = "PRESS PLAY" + if loading_hint_text is not None: + loading_hint_text.text = "The loading panel only appears after you hit Play." + if loading_steps_text is not None: + loading_steps_text.text = build_loading_steps(-1, False) + + +def apply_loading_stage(index: int) -> None: + global loading_stage_index + + if index == loading_stage_index: + return + + loading_stage_index = index + _start, _target, label, hint = LOADING_PHASES[index] + if loading_status_text is not None: + loading_status_text.text = label.upper() + if loading_hint_text is not None: + loading_hint_text.text = hint + if loading_steps_text is not None: + loading_steps_text.text = build_loading_steps(index, False) + + if ui_manager is not None and loading_status_text is not None: + loading_status_text.style.opacity = 0.25 + ui_manager.animator.create().to( + loading_status_text.style, + "opacity", + 1.0, + 0.22, + Easing.EASE_OUT_CUBIC, + ).start() + + +def finalize_loading_sequence() -> None: + global loading_complete + + if loading_complete: + return + + loading_complete = True + if loading_percent_text is not None: + loading_percent_text.text = "100%" + if loading_status_text is not None: + loading_status_text.text = "SHORELINE READY" + if loading_hint_text is not None: + loading_hint_text.text = "The demo stops on the title screen after the handoff so the transition stays visible." + if loading_steps_text is not None: + loading_steps_text.text = build_loading_steps(len(LOADING_PHASES) - 1, True) + + +def begin_loading_sequence() -> None: + global loading_active, loading_complete, loading_elapsed, loading_progress, loading_stage_index + + set_active_menu("play") + stop_loading_sequence() + switch_view(MainView.LOADING) + loading_active = True + loading_complete = False + loading_elapsed = 0.0 + loading_progress = 0.0 + loading_stage_index = -1 + + +def handle_menu_action(key: str) -> None: + if key == "play": + begin_loading_sequence() + return + + if key == "settings": + stop_loading_sequence() + set_active_menu(key) + switch_view(MainView.SETTINGS) + return + + if key == "credits": + stop_loading_sequence() + set_active_menu(key) + switch_view(MainView.CREDITS) + return + + set_active_menu(key) + rl.CloseWindow() + + +def tick_scene(time: Time) -> None: + global scene_clock, loading_elapsed, loading_progress + + dt = time.delta_seconds + scene_clock += dt + + for button in menu_buttons.values(): + button.tick(dt) + for button in settings_tab_buttons.values(): + button.tick(dt) + for button in back_buttons: + button.tick(dt) + if loading_bar is not None: + loading_bar.tick(dt) + + if loading_active: + loading_elapsed += dt + + active_index = 0 + target_progress = 0.0 + for index, (start_time, target, _label, _hint) in enumerate(LOADING_PHASES): + if loading_elapsed >= start_time: + active_index = index + target_progress = target + + apply_loading_stage(active_index) + loading_progress = ease_to(loading_progress, target_progress, 1.7, dt) + if active_index == len(LOADING_PHASES) - 1 and loading_elapsed >= 4.1: + loading_progress = ease_to(loading_progress, 1.0, 2.6, dt) + + if loading_bar is not None: + loading_bar.progress = loading_progress + if loading_percent_text is not None: + loading_percent_text.text = f"{int(round(loading_progress * 100)):03d}%" + + if loading_elapsed >= 4.4 and loading_progress >= 0.995: + finalize_loading_sequence() + + +def draw_cloud_overlays(renderer: Renderer2D, width: int, height: int) -> None: + for index in range(5): + offset = ((scene_clock * (15.0 + index * 1.4)) + index * 240.0) % ( + width + 360.0 + ) - 220.0 + y = int(height * (0.14 + index * 0.10)) + renderer.draw_rectangle( + Rect(int(offset), y, int(260 + index * 110), int(22 + index * 8)), + Color(250, 251, 255, 132 - index * 18), + ) + + +def render_background_system(renderer: Renderer2D) -> None: + runtime = get_runtime() + width, height = runtime.display.get_window_size() + renderer.clear(WHITE) + + if background_shader is not None: + renderer.set_shader_value( + background_shader, + ShaderUniformType.VEC2, + "u_resolution", + (float(width), float(height)), + ) + renderer.set_shader_value( + background_shader, + ShaderUniformType.FLOAT, + "u_time", + float(scene_clock), + ) + renderer.begin_shader_mode(background_shader) + renderer.draw_rectangle(Rect(0, 0, width, height), WHITE) + renderer.end_shader_mode() + + draw_cloud_overlays(renderer, width, height) + + +def create_idle_panel() -> Node: + panel = create_stage_panel() + panel.style.visible = True + panel.style.opacity = 1.0 + return panel + + +def create_settings_page_video() -> Node: + page = Node( + style=Style( + width=Unit.percent(100), + height=Unit.percent(100), + position=PositionType.ABSOLUTE, + top=Unit.px(0), + left=Unit.px(0), + gap=12, + ) + ) + page.add_child(create_slider_setting("Sky bloom", 92)) + page.add_child(create_slider_setting("Cloud drift", 64)) + page.add_child(create_slider_setting("Lantern sheen", 76)) + return page + + +def create_settings_page_sound() -> Node: + page = Node( + style=Style( + width=Unit.percent(100), + height=Unit.percent(100), + position=PositionType.ABSOLUTE, + top=Unit.px(0), + left=Unit.px(0), + gap=12, + ) + ) + page.add_child(create_slider_setting("Hall volume", 84)) + page.add_child(create_slider_setting("String bed", 72)) + page.add_child(create_slider_setting("Chip clink", 68)) + return page + + +def create_settings_page_interface() -> Node: + page = Node( + style=Style( + width=Unit.percent(100), + height=Unit.percent(100), + position=PositionType.ABSOLUTE, + top=Unit.px(0), + left=Unit.px(0), + gap=12, + ) + ) + page.add_child(create_toggle_setting("Compact HUD", True)) + page.add_child(create_toggle_setting("Win popups", True)) + page.add_child(create_toggle_setting("Larger prompts", False)) + return page + + +def create_settings_panel() -> Node: + panel = create_stage_panel() + panel.add_child( + create_panel_header( + "SETTINGS", + "Adjust the presentation before dealing the first hand.", + ) + ) + + tabs_row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + gap=12, + ) + ) + + settings_tab_buttons.clear() + for tab, label in ( + (SettingsView.VIDEO, "Video"), + (SettingsView.SOUND, "Sound"), + (SettingsView.INTERFACE, "Interface"), + ): + button = SettingsTabButton(tab, label, switch_settings_view) + settings_tab_buttons[tab] = button + tabs_row.add_child(button) + panel.add_child(tabs_row) + + pages_stage = Node( + style=Style( + width=Unit.percent(100), + height=Unit.px(300), + ) + ) + + settings_panels.clear() + settings_panels[SettingsView.VIDEO] = create_settings_page_video() + settings_panels[SettingsView.SOUND] = create_settings_page_sound() + settings_panels[SettingsView.INTERFACE] = create_settings_page_interface() + + for tab, page in settings_panels.items(): + page.style.visible = tab == SettingsView.VIDEO + page.style.opacity = 1.0 if tab == SettingsView.VIDEO else 0.0 + pages_stage.add_child(page) + + panel.add_child(pages_stage) + return panel + + +def create_credits_panel() -> Node: + panel = create_stage_panel() + panel.add_child( + create_panel_header( + "CREDITS", + "The people and pieces behind this title screen pass.", + ) + ) + panel.add_child( + create_credit_row( + "Concept", + "Slot Island // a slot addict is summoned into another world to reclaim floating islands.", + ) + ) + panel.add_child( + create_credit_row( + "Interface", + "A left-rail menu with felt-and-parchment colors, cleaner view switching, and stronger hover feel.", + ) + ) + panel.add_child( + create_credit_row( + "Atmosphere", + "A blue-purple cloud shader now carries the whole title screen without floating island sprites.", + ) + ) + panel.add_child( + create_credit_row( + "Fonts", + "Dead Revolver display, game compact, and digital variants from the provided pack.", + ) + ) + return panel + + +def create_loading_panel() -> Node: + global loading_bar, loading_percent_text, loading_status_text, loading_hint_text, loading_steps_text + + panel = create_stage_panel() + panel.add_child( + create_panel_header( + "LOADING", + "A dedicated transition view before the first playable shore.", + ) + ) + + loading_percent_text = ui_text("000%", 44, BLACK, role="display") + panel.add_child(loading_percent_text) + + loading_bar = LoadingBar() + panel.add_child(loading_bar) + + loading_status_text = ui_text("PRESS PLAY", 18, BLACK, role="meta") + loading_hint_text = ui_text( + "The loading panel only appears after you hit Play.", + 16, + INK_SOFT, + role="body", + ) + loading_steps_text = ui_text( + build_loading_steps(-1, False), 16, INK_SOFT, role="body" + ) + panel.add_child(loading_status_text) + panel.add_child(loading_hint_text) + panel.add_child(create_separator()) + panel.add_child(loading_steps_text) + return panel + + +def create_changelog_panel() -> Node: + global changelog_title + + panel = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + gap=10, + ) + ) + changelog_title = ui_text("EARLY ACCESS LEDGER", 12, GOLD_LINE, role="meta") + panel.add_child(changelog_title) + for entry in CHANGELOG: + panel.add_child(create_changelog_entry(entry)) + return panel + + +def create_ui() -> Node: + global title_text, menu_shell, detail_shell, detail_card + + menu_buttons.clear() + settings_tab_buttons.clear() + view_panels.clear() + settings_panels.clear() + intro_nodes.clear() + back_buttons.clear() + + root = Node( + style=Style( + width=Unit.vw(100), + height=Unit.vh(100), + padding=Spacing.all(0), + gap=0, + ) + ) + + menu_shell = Node( + style=Style( + width=Unit.percent(100), + height=Unit.percent(100), + position=PositionType.ABSOLUTE, + top=Unit.px(0), + left=Unit.px(0), + ) + ) + + title_text = OutlinedTitle("SLOT ISLAND") + title_text.style.position = PositionType.ABSOLUTE + title_text.style.top = Unit.px(52) + title_text.style.left = Unit.px(108) + title_text.style.width = Unit.px(560) + menu_shell.add_child(title_text) + + menu_stack = Node( + style=Style( + width=Unit.px(500), + height=Unit.auto(), + position=PositionType.ABSOLUTE, + top=Unit.px(238), + left=Unit.px(0), + gap=0, + ) + ) + for key, label in ( + ("play", "Play"), + ("settings", "Settings"), + ("credits", "Credits"), + ("quit", "Quit"), + ): + button = MenuButton(key, label, handle_menu_action) + menu_buttons[key] = button + menu_stack.add_child(button) + menu_shell.add_child(menu_stack) + + detail_shell = Node( + style=Style( + width=Unit.percent(100), + height=Unit.percent(100), + position=PositionType.ABSOLUTE, + top=Unit.px(0), + left=Unit.px(0), + justify_content=JustifyContent.CENTER, + align_items=AlignItems.CENTER, + padding=Spacing.symmetric(54, 88), + ) + ) + detail_shell.style.visible = False + detail_shell.style.opacity = 0.0 + + detail_card = Node( + style=Style( + width=Unit.percent(58), + height=Unit.px(612), + background_color=WHITE, + border_color=BLACK, + border_width=3.0, + padding=Spacing.all(30), + ) + ) + + content_stage = Node( + style=Style( + width=Unit.percent(100), + height=Unit.percent(100), + ) + ) + view_panels[MainView.IDLE] = create_idle_panel() + view_panels[MainView.SETTINGS] = create_settings_panel() + view_panels[MainView.CREDITS] = create_credits_panel() + view_panels[MainView.LOADING] = create_loading_panel() + + for panel in view_panels.values(): + content_stage.add_child(panel) + detail_card.add_child(content_stage) + detail_shell.add_child(detail_card) + + intro_nodes.append(title_text) + root.add_child(menu_shell) + root.add_child(detail_shell) + return root + + +def start_intro() -> None: + if ui_manager is None: + return + + for index, node in enumerate(intro_nodes): + node.style.opacity = 0.0 + node.style.margin.top = Unit.px(18) + delay = index * 0.05 + ui_manager.animator.create().wait(delay).to( + node.style, + "opacity", + 1.0, + 0.32, + Easing.EASE_OUT_CUBIC, + ).start() + ui_manager.animator.create().wait(delay).to( + node.style, + "margin.top", + Unit.px(0), + 0.40, + Easing.EASE_OUT_EXPO, + ).start() + + for index, button in enumerate(menu_buttons.values()): + button.style.opacity = 0.0 + button.slide_x = -54.0 + delay = 0.10 + index * 0.05 + ui_manager.animator.create().wait(delay).to( + button.style, + "opacity", + 1.0, + 0.20, + Easing.EASE_OUT_CUBIC, + ).start() + ui_manager.animator.create().wait(delay).to( + button, + "slide_x", + 0.0, + 0.32, + Easing.EASE_OUT_EXPO, + ).start() + + +def main() -> None: + global ui_manager, background_shader + global scene_clock, current_view, current_settings_view, active_menu_key + global loading_active, loading_complete, loading_elapsed, loading_progress, loading_stage_index + + scene_clock = 0.0 + current_view = MainView.IDLE.value + current_settings_view = SettingsView.VIDEO.value + active_menu_key = "" + loading_active = False + loading_complete = False + loading_elapsed = 0.0 + loading_progress = 0.0 + loading_stage_index = -1 + + rl.SetConfigFlags(rl.FLAG_WINDOW_RESIZABLE | rl.FLAG_MSAA_4X_HINT) + + game = ArepyEngine( + title="Slot Island // Title Screen", + width=1366, + height=768, + ) + world = game.create_world("slot_island_title") + + ui_manager = UIManager.install( + world, + config=UIConfig( + resize_mode=ResizeMode.RESPONSIVE, + font_texture_filter=TextureFilter.NEAREST, + ), + ) + + setup_demo_fonts() + ui_manager.set_font_texture_filter(TextureFilter.NEAREST) + ui_manager.set_root(create_ui()) + + switch_view(MainView.IDLE, instant=True, force=True) + switch_settings_view(SettingsView.VIDEO, instant=True, force=True) + stop_loading_sequence() + set_active_menu(None) + + renderer = world.get_resource(Renderer2D) + background_shader = renderer.compile_shader(fragment_source=FRAGMENT_SHADER) + + world.add_system(SystemPipeline.UPDATE, tick_scene) + world.add_system(SystemPipeline.RENDER, render_background_system) + world.on_startup(start_intro) + + @world.on_shutdown + def cleanup_shader() -> None: + if background_shader is not None: + renderer.unload_shader(background_shader) + + game.set_current_world("slot_island_title") + game.run() + + +if __name__ == "__main__": + main() diff --git a/examples/demo_video.py b/examples/demo_video.py index 85180dc..1c2bc36 100644 --- a/examples/demo_video.py +++ b/examples/demo_video.py @@ -1,5 +1,7 @@ +from pathlib import Path + import raylib as rl -from arepy import ArepyEngine, Display, Input, Renderer2D, SystemPipeline +from arepy import ArepyEngine, Display, Input, Renderer2D, SystemPipeline, TextureFilter from arepy.ecs.world import World from arepy_ui import ( @@ -7,8 +9,10 @@ Button, Color, FlexDirection, + FontLoadRequest, JustifyContent, Node, + PositionType, ResizeMode, ScrollView, Spacing, @@ -19,13 +23,166 @@ UIManager, Unit, Video, - configure_runtime, + load_fonts, ) from arepy_ui.components import Divider -from arepy_ui.debug import UIDebugger ui_manager: UIManager = None # type: ignore -ui_debugger: UIDebugger = None # type: ignore + +FONT_BODY: str | None = None +FONT_BRAND: str | None = None +FONT_META: str | None = None + +BG = Color(15, 15, 15, 255) +SURFACE = Color(24, 24, 24, 255) +SURFACE_SOFT = Color(34, 34, 34, 255) +SURFACE_ELEVATED = Color(39, 39, 39, 255) +SURFACE_BORDER = Color(58, 58, 58, 255) +TEXT_PRIMARY = Color(241, 241, 241, 255) +TEXT_SECONDARY = Color(170, 170, 170, 255) +TEXT_TERTIARY = Color(120, 120, 120, 255) +ACCENT_RED = Color(255, 0, 0, 255) +CHIP_ACTIVE = Color(241, 241, 241, 255) +CHIP_ACTIVE_TEXT = Color(15, 15, 15, 255) + + +def noop(): + pass + + +def _first_existing_path(candidates: list[str]) -> str | None: + for candidate in candidates: + if Path(candidate).exists(): + return candidate + return None + + +def setup_demo_fonts() -> None: + global FONT_BODY, FONT_BRAND, FONT_META + + font_base_sizes = { + "yt-body": 20, + "yt-brand": 32, + "yt-meta": 14, + } + + font_sources = { + "yt-body": [ + "C:/Windows/Fonts/arial.ttf", + "C:/Windows/Fonts/segoeui.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + ], + "yt-brand": [ + "C:/Windows/Fonts/arialbd.ttf", + "C:/Windows/Fonts/bahnschrift.ttf", + "C:/Windows/Fonts/seguisb.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf", + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + ], + "yt-meta": [ + "C:/Windows/Fonts/arial.ttf", + "C:/Windows/Fonts/verdana.ttf", + "C:/Windows/Fonts/tahoma.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + ], + } + + requests: list[FontLoadRequest] = [] + loaded_names: dict[str, str] = {} + + for name, candidates in font_sources.items(): + path = _first_existing_path(candidates) + if not path: + continue + requests.append( + FontLoadRequest( + name=name, + path=path, + base_size=font_base_sizes.get(name, 20), + set_as_default=name == "yt-body", + texture_filter=TextureFilter.TRILINEAR, + ) + ) + loaded_names[name] = name + + if requests: + load_fonts(requests) + + FONT_BODY = loaded_names.get("yt-body") + FONT_BRAND = loaded_names.get("yt-brand") or FONT_BODY + FONT_META = loaded_names.get("yt-meta") or FONT_BODY + + +def font_for(role: str) -> str | None: + if role == "brand": + return FONT_BRAND + if role == "meta": + return FONT_META + return FONT_BODY + + +def ui_text(text: str, size: float, color: Color, role: str = "body") -> Text: + return Text(text, size=size, color=color, font_name=font_for(role)) + + +def create_chip(label: str, active: bool = False) -> Node: + chip = Node( + style=Style( + width=Unit.auto(), + height=Unit.auto(), + background_color=CHIP_ACTIVE if active else SURFACE_SOFT, + border_radius=8.0, + padding=Spacing.symmetric(vertical=8, horizontal=12), + ) + ) + chip.add_child( + ui_text( + label, + 12, + CHIP_ACTIVE_TEXT if active else TEXT_PRIMARY, + role="body", + ) + ) + return chip + + +def create_icon_avatar(label: str, color: Color) -> Node: + avatar = Node( + style=Style( + width=Unit.px(36), + height=Unit.px(36), + background_color=color, + border_radius=18.0, + justify_content=JustifyContent.CENTER, + align_items=AlignItems.CENTER, + ) + ) + avatar.add_child(ui_text(label, 13, TEXT_PRIMARY, role="brand")) + return avatar + + +def create_action_button( + label: str, + width: int, + background: Color = SURFACE_SOFT, + text_color: Color = TEXT_PRIMARY, +) -> Button: + return Button( + label, + noop, + Unit.px(width), + Unit.px(36), + background, + text_color=text_color, + border_radius=18.0, + font_size=12, + font_name=font_for("body"), + ) def create_video_card( @@ -37,8 +194,7 @@ def create_video_card( height=Unit.auto(), flex_direction=FlexDirection.ROW, gap=12, - padding=Spacing.all(8), - border_radius=8.0, + padding=Spacing.symmetric(vertical=8, horizontal=0), ) ) @@ -46,77 +202,86 @@ def create_video_card( style=Style( width=Unit.px(168), height=Unit.px(94), - background_color=Color(50, 50, 55, 255), - border_radius=8.0, + background_color=Color(56, 56, 56, 255), + border_radius=10.0, + ) + ) + + thumb_label = Node( + style=Style( + position=PositionType.ABSOLUTE, + top=Unit.px(8), + left=Unit.px(8), + width=Unit.auto(), + height=Unit.auto(), + background_color=Color(0, 0, 0, 190), + border_radius=4.0, + padding=Spacing.symmetric(vertical=3, horizontal=6), ) ) + thumb_label.add_child(ui_text("AREPY", 10, TEXT_PRIMARY, role="meta")) + thumb.add_child(thumb_label) duration_badge = Node( style=Style( + position=PositionType.ABSOLUTE, + right=Unit.px(8), + bottom=Unit.px(8), width=Unit.auto(), height=Unit.auto(), background_color=Color(0, 0, 0, 220), - padding=Spacing.symmetric(horizontal=6, vertical=2), border_radius=4.0, + padding=Spacing.symmetric(vertical=3, horizontal=6), ) ) - duration_badge.add_child(Text(duration, size=11, color=Color(255, 255, 255, 255))) + duration_badge.add_child(ui_text(duration, 10, TEXT_PRIMARY, role="meta")) thumb.add_child(duration_badge) card.add_child(thumb) - # Info info = Node( style=Style( - width=Unit.percent(55), + width=Unit.percent(58), height=Unit.auto(), flex_direction=FlexDirection.COLUMN, gap=4, ) ) - display_title = title[:45] + "..." if len(title) > 45 else title - info.add_child(Text(display_title, size=14, color=Color(255, 255, 255, 255))) - info.add_child(Text(channel, size=12, color=Color(150, 150, 150, 255))) - info.add_child(Text(views, size=12, color=Color(150, 150, 150, 255))) + display_title = title[:48] + "..." if len(title) > 48 else title + info.add_child(ui_text(display_title, 14, TEXT_PRIMARY, role="body")) + info.add_child(ui_text(channel, 12, TEXT_SECONDARY, role="meta")) + info.add_child(ui_text(views, 12, TEXT_TERTIARY, role="meta")) card.add_child(info) return card def create_comment(author: str, text: str, likes: str, time_ago: str) -> Node: - """Crea un comentario.""" comment = Node( style=Style( width=Unit.percent(100), height=Unit.auto(), flex_direction=FlexDirection.ROW, gap=12, - padding=Spacing.symmetric(vertical=12, horizontal=0), + padding=Spacing.symmetric(vertical=14, horizontal=0), ) ) - # Avatar - avatar = Node( - style=Style( - width=Unit.px(40), - height=Unit.px(40), - background_color=Color(80, 80, 120, 255), - border_radius=20.0, - ) - ) + avatar = create_icon_avatar(author[1:3].upper(), Color(67, 102, 190, 255)) + avatar.style.width = Unit.px(40) + avatar.style.height = Unit.px(40) + avatar.style.border_radius = 20.0 comment.add_child(avatar) - # Contenido content = Node( style=Style( width=Unit.percent(90), height=Unit.auto(), flex_direction=FlexDirection.COLUMN, - gap=4, + gap=6, ) ) - # Header del comentario header = Node( style=Style( width=Unit.percent(100), @@ -126,13 +291,12 @@ def create_comment(author: str, text: str, likes: str, time_ago: str) -> Node: align_items=AlignItems.CENTER, ) ) - header.add_child(Text(author, size=13, color=Color(255, 255, 255, 255))) - header.add_child(Text(time_ago, size=12, color=Color(120, 120, 120, 255))) + header.add_child(ui_text(author, 13, TEXT_PRIMARY, role="body")) + header.add_child(ui_text(time_ago, 12, TEXT_TERTIARY, role="meta")) content.add_child(header) - content.add_child(Text(text, size=13, color=Color(220, 220, 220, 255))) + content.add_child(ui_text(text, 13, Color(225, 225, 225, 255), role="body")) - # Likes likes_row = Node( style=Style( width=Unit.auto(), @@ -142,89 +306,133 @@ def create_comment(author: str, text: str, likes: str, time_ago: str) -> Node: align_items=AlignItems.CENTER, ) ) - likes_row.add_child(Text(f" {likes}", size=12, color=Color(150, 150, 150, 255))) - likes_row.add_child(Text("Reply", size=12, color=Color(150, 150, 150, 255))) + likes_row.add_child(ui_text(f"LIKE {likes}", 11, TEXT_SECONDARY, role="meta")) + likes_row.add_child(ui_text("REPLY", 11, TEXT_SECONDARY, role="meta")) content.add_child(likes_row) comment.add_child(content) return comment -def create_ui(video_path: str) -> Node: - """Layout principal estilo YouTube - 100% responsive.""" - - # Root - root = Node( - style=Style( - width=Unit.vw(100), - height=Unit.vh(100), - background_color=Color(15, 15, 15, 255), - flex_direction=FlexDirection.COLUMN, - ) - ) - - # ========== HEADER ========== +def create_header() -> Node: header = Node( style=Style( width=Unit.percent(100), height=Unit.px(56), - background_color=Color(15, 15, 15, 255), + background_color=BG, flex_direction=FlexDirection.ROW, align_items=AlignItems.CENTER, justify_content=JustifyContent.SPACE_BETWEEN, - padding=Spacing.symmetric(horizontal=24, vertical=0), + padding=Spacing.symmetric(vertical=0, horizontal=16), ) ) - # Logo - logo = Text(" YouTube", size=20, color=Color(255, 255, 255, 255)) - header.add_child(logo) + left_cluster = Node( + style=Style( + width=Unit.auto(), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + align_items=AlignItems.CENTER, + gap=14, + ) + ) + left_cluster.add_child(create_action_button("=", 36)) - # Search - search_container = Node( + logo_row = Node( style=Style( - width=Unit.percent(40), - height=Unit.px(40), + width=Unit.auto(), + height=Unit.auto(), flex_direction=FlexDirection.ROW, + align_items=AlignItems.CENTER, + gap=8, + ) + ) + logo_badge = Node( + style=Style( + width=Unit.px(30), + height=Unit.px(22), + background_color=ACCENT_RED, + border_radius=7.0, justify_content=JustifyContent.CENTER, + align_items=AlignItems.CENTER, + ) + ) + logo_badge.add_child(ui_text(">", 13, TEXT_PRIMARY, role="brand")) + logo_row.add_child(logo_badge) + logo_row.add_child(ui_text("YouTube", 20, TEXT_PRIMARY, role="brand")) + left_cluster.add_child(logo_row) + header.add_child(left_cluster) + + search_row = Node( + style=Style( + width=Unit.percent(44), + height=Unit.px(40), + flex_direction=FlexDirection.ROW, + align_items=AlignItems.CENTER, + gap=10, ) ) search_input = TextInput( - placeholder="Search...", + placeholder="Search", width=Unit.percent(100), height=Unit.px(40), + font_size=14, + style=Style( + background_color=Color(18, 18, 18, 255), + border_color=SURFACE_BORDER, + border_width=1.0, + border_radius=20.0, + padding=Spacing.symmetric(vertical=8, horizontal=16), + ), ) - search_container.add_child(search_input) - header.add_child(search_container) + search_input.text_color = TEXT_PRIMARY + search_input.placeholder_color = TEXT_TERTIARY + search_input.default_border_color = SURFACE_BORDER + search_input.focused_border_color = Color(62, 166, 255, 255) + search_row.add_child(search_input) + search_row.add_child(create_action_button("Search", 78, SURFACE_SOFT)) + search_row.add_child(create_action_button("Mic", 48, SURFACE_SOFT)) + header.add_child(search_row) - # User - user_avatar = Node( + right_cluster = Node( style=Style( - width=Unit.px(32), - height=Unit.px(32), - background_color=Color(100, 120, 200, 255), - border_radius=16.0, + width=Unit.auto(), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + align_items=AlignItems.CENTER, + gap=12, ) ) - header.add_child(user_avatar) + right_cluster.add_child(create_action_button("Create", 76, SURFACE_SOFT)) + right_cluster.add_child(create_action_button("Bell", 56, SURFACE_SOFT)) + right_cluster.add_child(create_icon_avatar("AU", Color(151, 93, 186, 255))) + header.add_child(right_cluster) + return header - root.add_child(header) - # Divider bajo header - root.add_child(Divider(color=Color(40, 40, 40, 255), thickness=1)) +def create_ui(video_path: str) -> Node: + root = Node( + style=Style( + width=Unit.vw(100), + height=Unit.vh(100), + background_color=BG, + flex_direction=FlexDirection.COLUMN, + ) + ) + + root.add_child(create_header()) + root.add_child(Divider(color=Color(42, 42, 42, 255), thickness=1)) - # ========== MAIN CONTENT ========== main_wrapper = Node( style=Style( width=Unit.percent(100), - height=Unit.vh(92), # Resto de la ventana + height=Unit.vh(92), flex_direction=FlexDirection.ROW, padding=Spacing.all(24), gap=24, ) ) - # ===== COLUMNA PRINCIPAL (70%) ===== main_column = Node( style=Style( width=Unit.percent(68), @@ -238,11 +446,10 @@ def create_ui(video_path: str) -> Node: width=Unit.percent(100), height=Unit.auto(), flex_direction=FlexDirection.COLUMN, - gap=16, + gap=18, ) ) - # Video Player video = Video( source=video_path, width=Unit.percent(100), @@ -252,16 +459,15 @@ def create_ui(video_path: str) -> Node: ) main_scroll_content.add_child(video) - # T�tulo del video main_scroll_content.add_child( - Text( + ui_text( "Building a YouTube-like Video Player with arepy-ui", - size=22, - color=Color(255, 255, 255, 255), + 23, + TEXT_PRIMARY, + role="brand", ) ) - # Stats row stats_row = Node( style=Style( width=Unit.percent(100), @@ -269,14 +475,13 @@ def create_ui(video_path: str) -> Node: flex_direction=FlexDirection.ROW, justify_content=JustifyContent.SPACE_BETWEEN, align_items=AlignItems.CENTER, + gap=12, ) ) - stats_row.add_child( - Text("1,234,567 views Dec 5, 2025", size=13, color=Color(150, 150, 150, 255)) + ui_text("1,234,567 views Dec 5, 2025", 13, TEXT_SECONDARY, role="meta") ) - # Action buttons actions = Node( style=Style( width=Unit.auto(), @@ -285,46 +490,13 @@ def create_ui(video_path: str) -> Node: gap=8, ) ) - - def noop(): - pass - - actions.add_child( - Button( - " 123K", - noop, - Unit.px(90), - Unit.px(36), - Color(40, 40, 40, 255), - font_size=12, - ) - ) - actions.add_child( - Button("", noop, Unit.px(50), Unit.px(36), Color(40, 40, 40, 255), font_size=12) - ) - actions.add_child( - Button( - "Share", - noop, - Unit.px(70), - Unit.px(36), - Color(40, 40, 40, 255), - font_size=12, - ) - ) - actions.add_child( - Button( - "Save", noop, Unit.px(60), Unit.px(36), Color(40, 40, 40, 255), font_size=12 - ) - ) - + actions.add_child(create_action_button("Like 123K", 110, SURFACE_ELEVATED)) + actions.add_child(create_action_button("Dislike", 86, SURFACE_ELEVATED)) + actions.add_child(create_action_button("Share", 78, SURFACE_ELEVATED)) + actions.add_child(create_action_button("Save", 72, SURFACE_ELEVATED)) stats_row.add_child(actions) main_scroll_content.add_child(stats_row) - # Divider - main_scroll_content.add_child(Divider(color=Color(50, 50, 50, 255))) - - # Channel info channel_row = Node( style=Style( width=Unit.percent(100), @@ -332,122 +504,137 @@ def noop(): flex_direction=FlexDirection.ROW, gap=16, align_items=AlignItems.CENTER, - padding=Spacing.symmetric(vertical=16, horizontal=0), + padding=Spacing.symmetric(vertical=8, horizontal=0), ) ) - - channel_avatar = Node( - style=Style( - width=Unit.px(48), - height=Unit.px(48), - background_color=Color(200, 80, 80, 255), - border_radius=24.0, - ) - ) - channel_row.add_child(channel_avatar) + channel_row.add_child(create_icon_avatar("AI", Color(207, 84, 84, 255))) channel_info = Node( style=Style( - width=Unit.percent(60), + width=Unit.percent(58), height=Unit.auto(), flex_direction=FlexDirection.COLUMN, - gap=2, + gap=4, ) ) - channel_info.add_child(Text("Arepy UI", size=16, color=Color(255, 255, 255, 255))) + channel_info.add_child(ui_text("Arepy UI", 16, TEXT_PRIMARY, role="body")) channel_info.add_child( - Text("15.2K subscribers", size=12, color=Color(150, 150, 150, 255)) + ui_text("15.2K subscribers 124 videos", 12, TEXT_SECONDARY, role="meta") ) channel_row.add_child(channel_info) - channel_row.add_child( - Button( + create_action_button( "Subscribe", - noop, - Unit.px(110), - Unit.px(38), - Color(255, 0, 0, 255), - font_size=14, + 112, + ACCENT_RED, + TEXT_PRIMARY, ) ) - + channel_row.add_child(create_action_button("Join", 64, SURFACE_SOFT)) main_scroll_content.add_child(channel_row) - # Description box desc_box = Node( style=Style( width=Unit.percent(100), height=Unit.auto(), - background_color=Color(30, 30, 35, 255), + background_color=SURFACE, border_radius=12.0, padding=Spacing.all(16), flex_direction=FlexDirection.COLUMN, - gap=8, + gap=10, ) ) desc_box.add_child( - Text( - "This demo showcases the Video component of arepy-ui with full playback controls, " - "responsive layout using vh/vw units, ScrollView for long content, and a clean " - "YouTube-inspired design. All built with Python!", - size=14, - color=Color(200, 200, 200, 255), + ui_text( + "124K views 5 days ago #python #gamedev #ui", + 13, + TEXT_PRIMARY, + role="meta", ) ) - desc_box.add_child(Text("Show more", size=13, color=Color(150, 150, 150, 255))) + desc_box.add_child( + ui_text( + "This demo showcases multiple fonts, a YouTube-inspired layout, custom video controls, " + "responsive sidebars, and long-form content built entirely with arepy-ui components.", + 14, + Color(215, 215, 215, 255), + role="body", + ) + ) + desc_box.add_child(ui_text("Show more", 13, TEXT_SECONDARY, role="meta")) main_scroll_content.add_child(desc_box) - # Comments section - main_scroll_content.add_child(Divider(color=Color(50, 50, 50, 255))) - comments_header = Node( style=Style( width=Unit.percent(100), height=Unit.auto(), flex_direction=FlexDirection.ROW, align_items=AlignItems.CENTER, - gap=24, - padding=Spacing.symmetric(vertical=16, horizontal=0), + gap=16, + padding=Spacing.symmetric(vertical=8, horizontal=0), ) ) - comments_header.add_child( - Text("128 Comments", size=16, color=Color(255, 255, 255, 255)) - ) - comments_header.add_child(Text("Sort by", size=13, color=Color(150, 150, 150, 255))) + comments_header.add_child(ui_text("128 Comments", 18, TEXT_PRIMARY, role="body")) + comments_header.add_child(ui_text("Sort by", 13, TEXT_SECONDARY, role="meta")) main_scroll_content.add_child(comments_header) - # Comments + add_comment_row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + align_items=AlignItems.CENTER, + gap=12, + padding=Spacing.symmetric(vertical=8, horizontal=0), + ) + ) + add_comment_row.add_child(create_icon_avatar("AU", Color(151, 93, 186, 255))) + comment_input = TextInput( + placeholder="Add a comment...", + width=Unit.percent(100), + height=Unit.px(40), + font_size=14, + style=Style( + background_color=BG, + border_color=SURFACE_BORDER, + border_width=0.0, + border_radius=0.0, + padding=Spacing.symmetric(vertical=10, horizontal=0), + ), + ) + comment_input.text_color = TEXT_PRIMARY + comment_input.placeholder_color = TEXT_TERTIARY + comment_input.default_border_color = BG + comment_input.focused_border_color = BG + add_comment_row.add_child(comment_input) + main_scroll_content.add_child(add_comment_row) + main_scroll_content.add_child(Divider(color=Color(50, 50, 50, 255))) + comments_data = [ ( "@pythondev", - "This is exactly what I needed! Great work on the UI framework.", + "This is exactly what I needed. The new font hierarchy makes the demo feel much closer to a real video platform.", "245", "2 hours ago", ), ( "@gamedev_pro", - "The video controls are smooth. How did you handle the frame timing?", + "The seek behavior is much better now. Audio and video recover way faster after scrubbing.", "89", "5 hours ago", ), ( "@ui_enthusiast", - "Love the attention to detail on the scrollbars and hover effects!", + "Love the cleaner header, action chips, and the updated related cards layout.", "156", "1 day ago", ), ( "@coding_wizard", - "Finally a good UI library for Python games. Subscribed!", + "Python UI demos rarely look this polished. Great progress.", "312", "2 days ago", ), - ( - "@learner2025", - "Could you make a tutorial series on building this from scratch?", - "67", - "3 days ago", - ), ] for author, text, likes, time_ago in comments_data: @@ -474,56 +661,66 @@ def noop(): width=Unit.percent(100), height=Unit.auto(), flex_direction=FlexDirection.COLUMN, - gap=12, + gap=14, + ) + ) + + chips_row = Node( + style=Style( + width=Unit.percent(100), + height=Unit.auto(), + flex_direction=FlexDirection.ROW, + gap=8, ) ) + chips_row.add_child(create_chip("All", active=True)) + chips_row.add_child(create_chip("Python")) + chips_row.add_child(create_chip("UI")) + chips_row.add_child(create_chip("Gamedev")) + sidebar_scroll_content.add_child(chips_row) sidebar_scroll_content.add_child( - Text("Related Videos", size=14, color=Color(150, 150, 150, 255)) + ui_text("Up next", 14, TEXT_SECONDARY, role="meta") ) - # Videos relacionados related = [ ( "Python Game Development - Complete Course 2025", "GameDev Academy", - "892K views 2 weeks", + "892K views 2 weeks ago", "2:15:30", ), ( "Building UIs with Flexbox - Deep Dive", "CSS Master", - "234K views 1 month", + "234K views 1 month ago", "45:12", ), ( - "Raylib - Performance Comparison", + "Raylib Performance Comparison", "Code Compare", - "156K views 3 days", + "156K views 3 days ago", "18:45", ), ( "Create a Music Player in Python", "PyTutorials", - "67K views 1 week", + "67K views 1 week ago", "32:20", ), ( "Advanced Python Patterns for Games", "Pro Coder", - "445K views 2 months", + "445K views 2 months ago", "1:05:00", ), - ("UI Animation Techniques", "Motion Design", "123K views 5 days", "28:15"), - ("Python Performance Tips 2025", "Speed Demon", "89K views 1 day", "22:40"), + ("UI Animation Techniques", "Motion Design", "123K views 5 days ago", "28:15"), ( - "Building a Video Editor in Python", - "Creative Code", - "234K views 2 weeks", - "1:45:00", + "Python Performance Tips 2025", + "Speed Demon", + "89K views 1 day ago", + "22:40", ), - ("The Future of Python GUIs", "Tech Talk", "178K views 4 days", "35:50"), - ("Responsive Design Principles", "UI/UX Pro", "92K views 1 week", "41:30"), ] for title, channel, views, duration in related: @@ -551,22 +748,22 @@ def ui_update_system(renderer: Renderer2D, input: Input, display: Display): def ui_render_system(renderer: Renderer2D): - global ui_manager, ui_debugger + global ui_manager ui_manager.render() - if ui_debugger: - ui_debugger.render(ui_manager.root) def setup_system(game: ArepyEngine): - global ui_manager, ui_debugger + global ui_manager ui_manager = UIManager.from_engine( game, config=UIConfig( resize_mode=ResizeMode.RESPONSIVE, + font_texture_filter=TextureFilter.NEAREST, ), ) - ui_debugger = UIDebugger() + + setup_demo_fonts() video_path = "examples/assets/dispatch.mp4" root = create_ui(video_path) @@ -574,6 +771,7 @@ def setup_system(game: ArepyEngine): print("YouTube-style Video Player Demo") print("================================") + print("- Loads multiple font roles with platform fallbacks") print("- Resize window to test responsive layout") print("- Scroll in main content and sidebar") print("- Press F3 for debug overlay") diff --git a/main.py b/main.py index 8217ae9..f2fbc1c 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -from arepy import ArepyEngine, Renderer2D, SystemPipeline +from arepy import ArepyEngine from arepy_ui import ( AlignItems, @@ -12,11 +12,13 @@ Unit, ) - -def setup(game: ArepyEngine): - ui_manager = UIManager.from_engine(game, config=UIConfig()) - ui_manager.set_root( - Node( +if __name__ == "__main__": + game = ArepyEngine(title="My Game", width=800, height=600) + world = game.create_world("main") + UIManager.install( + world, + config=UIConfig(), + root=Node( style=Style( width=Unit.percent(100), height=Unit.percent(100), @@ -27,25 +29,7 @@ def setup(game: ArepyEngine): Text("Hello!", size=24), Button("Click me", on_click=lambda: print("Clicked!")), ], - ) + ), ) - game.add_resource(ui_manager) - - -def update(ui_manager: UIManager, renderer: Renderer2D): - ui_manager.update(renderer.get_delta_time()) - - -def render(ui_manager: UIManager): - ui_manager.render() - - -if __name__ == "__main__": - game = ArepyEngine(title="My Game", width=800, height=600) - setup(game) - - world = game.create_world("main") - world.add_system(SystemPipeline.UPDATE, update) - world.add_system(SystemPipeline.RENDER_UI, render) game.set_current_world("main") game.run() diff --git a/mkdocs.yml b/mkdocs.yml index 34611fc..c719505 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,6 +47,17 @@ theme: plugins: - search + - mkdocstrings: + handlers: + python: + options: + docstring_style: google + heading_level: 2 + members_order: source + show_root_heading: true + show_root_toc_entry: false + show_source: false + show_signature_annotations: true - offline - glightbox: touchNavigation: true @@ -127,7 +138,6 @@ nav: - Creating an Inventory: learn/tutorials/inventory.md - Theme Switching: learn/tutorials/theming.md - # REFERENCE - Component Library (Chakra-style) - Reference: - reference/index.md - Components: @@ -171,7 +181,7 @@ nav: - reference/api/index.md - UIManager: reference/api/uimanager.md - UIDebugger: reference/api/debugger.md - - Animations: reference/api/animations.md + - Animations and Transitions: reference/api/animations.md # RESOURCES - Examples & Tools - Resources: @@ -188,6 +198,7 @@ nav: # ABOUT - About: - about/index.md + - Roadmap: about/roadmap.md - Contributing: about/contributing.md - Changelog: about/changelog.md - License: about/license.md diff --git a/pyproject.toml b/pyproject.toml index eed10c6..f2bba2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "arepy-ui" description = "An open-source ui library for arepy" authors = [{ name = "Abrahan Gil", email = "scr44gr@protonmail.com" }] -license = { file = "LICENSE" } +license = "MIT" classifiers = [ 'Development Status :: 3 - Alpha', 'Programming Language :: Python :: 3.11', @@ -19,7 +19,7 @@ classifiers = [ ] keywords = ['arepy', 'ui', 'library', 'python'] requires-python = ">=3.11" -dependencies = ["arepy"] +dependencies = ["arepy==0.5.5"] dynamic = ['version'] readme = "README.md" @@ -31,6 +31,7 @@ docs = [ "mkdocs-glightbox", "mkdocs-git-revision-date-localized-plugin", "mkdocs-minify-plugin", + "mkdocstrings[python]>=0.28", ] markup = ["cython>=3.0"] @@ -97,8 +98,8 @@ before-build = "pip install cython>=3.0" [tool.cibuildwheel.windows] before-build = "pip install cython>=3.0" -[tool.uv.sources] -arepy = { git = "https://github.com/Scr44gr/arepy", rev = "main" } +[tool.uv] +package = true [tool.ty.environment] root = ["./"] diff --git a/scripts/bench_markup.py b/scripts/bench_markup.py new file mode 100644 index 0000000..fda0108 --- /dev/null +++ b/scripts/bench_markup.py @@ -0,0 +1,173 @@ +"""Benchmark the markup pipeline on a repeated synthetic tree.""" + +from __future__ import annotations + +import importlib.util +import statistics +import sys +import time +from pathlib import Path +from unittest.mock import patch + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +for candidate in REPO_ROOT.glob("build/lib.*/arepy_ui/markup/parsers/css_parser*.pyd"): + spec = importlib.util.spec_from_file_location( + "arepy_ui.markup.parsers.css_parser", candidate + ) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules["arepy_ui.markup.parsers.css_parser"] = module + spec.loader.exec_module(module) + break + +from arepy_ui.components.button import Button +from arepy_ui.components.scroll import ScrollView +from arepy_ui.components.text import Text +from arepy_ui.core.node import Node +from arepy_ui.core.fonts import TextMetrics +from arepy_ui.markup.builder import _clear_builder_caches +from arepy_ui.markup.globals import clear_globals +from arepy_ui.markup.loader import _clear_load_caches, load_aui, load_aui_string + +COMPONENTS = { + "Node": Node, + "Text": Text, + "Button": Button, + "ScrollView": ScrollView, +} + + +class _BenchmarkFontManager: + def measure_text_ex( + self, + text: str, + font_size: float, + font_name: str | None = None, + spacing: float = 1.0, + ) -> TextMetrics: + return TextMetrics(100, 20, 24) + + +def _write_fixture_files(tmp_dir: Path, item_count: int) -> tuple[Path, Path]: + items = [] + for index in range(item_count): + items.append( + f""" + Item {index} + +""" + ) + + aui_path = tmp_dir / "bench.aui" + acss_path = tmp_dir / "bench.acss" + aui_path.write_text( + '\n' + "\n".join(items) + "\n\n", + encoding="utf-8", + ) + acss_path.write_text( + """ +.layout { gap: 12px; padding: 24px; } +.card { width: 220px; height: 120px; padding: 16px; background: #20242a; border-radius: 12px; } +.slot-0 { margin: 4px; } +.slot-1 { margin: 8px; } +.slot-2 { margin: 12px; } +.title { font-size: 18px; color: #f4f4f4; } +.btn { width: 90px; height: 32px; color: #ffffff; background: #3355aa; border-radius: 6px; } +.primary { background: #4477ee; } +button:hover { background: #5f8fff; } +button:active { background: #244caa; } +""".strip() + + "\n", + encoding="utf-8", + ) + return aui_path, acss_path + + +def _benchmark_load(aui_path: Path, rounds: int) -> tuple[float, float]: + cold_timings = [] + hot_timings = [] + font_manager = _BenchmarkFontManager() + + with patch("arepy_ui.markup.loader._get_components", return_value=COMPONENTS): + with patch("arepy_ui.core.fonts.get_font_manager", return_value=font_manager): + + for _ in range(rounds): + clear_globals() + _clear_builder_caches() + _clear_load_caches() + start = time.perf_counter() + result = load_aui(str(aui_path)) + cold_timings.append(time.perf_counter() - start) + if result.root is None: + raise RuntimeError("cold benchmark build failed") + + for _ in range(rounds): + start = time.perf_counter() + result = load_aui(str(aui_path)) + hot_timings.append(time.perf_counter() - start) + if result.root is None: + raise RuntimeError("hot benchmark build failed") + + return statistics.mean(cold_timings), statistics.mean(hot_timings) + + +def _benchmark_load_string( + aui_path: Path, acss_path: Path, rounds: int +) -> tuple[float, float]: + cold_timings = [] + hot_timings = [] + content = aui_path.read_text(encoding="utf-8") + stylesheet = acss_path.read_text(encoding="utf-8") + font_manager = _BenchmarkFontManager() + + with patch("arepy_ui.markup.loader._get_components", return_value=COMPONENTS): + with patch("arepy_ui.core.fonts.get_font_manager", return_value=font_manager): + + for _ in range(rounds): + clear_globals() + _clear_builder_caches() + _clear_load_caches() + start = time.perf_counter() + result = load_aui_string(content, stylesheet) + cold_timings.append(time.perf_counter() - start) + if result.root is None: + raise RuntimeError("cold string benchmark build failed") + + for _ in range(rounds): + start = time.perf_counter() + result = load_aui_string(content, stylesheet) + hot_timings.append(time.perf_counter() - start) + if result.root is None: + raise RuntimeError("hot string benchmark build failed") + + return statistics.mean(cold_timings), statistics.mean(hot_timings) + + +def main() -> None: + item_count = 300 + rounds = 10 + tmp_dir = Path(__file__).resolve().parent / ".bench_tmp" + tmp_dir.mkdir(exist_ok=True) + aui_path, acss_path = _write_fixture_files(tmp_dir, item_count) + + cold_mean, hot_mean = _benchmark_load(aui_path, rounds) + cold_string_mean, hot_string_mean = _benchmark_load_string( + aui_path, acss_path, rounds + ) + node_count = item_count * 3 + 1 + + print(f"items: {item_count}") + print(f"approx_nodes: {node_count}") + print(f"rounds: {rounds}") + print(f"file_cold_mean_ms: {cold_mean * 1000:.3f}") + print(f"file_hot_mean_ms: {hot_mean * 1000:.3f}") + print(f"file_speedup_x: {cold_mean / hot_mean:.2f}") + print(f"string_cold_mean_ms: {cold_string_mean * 1000:.3f}") + print(f"string_hot_mean_ms: {hot_string_mean * 1000:.3f}") + print(f"string_speedup_x: {cold_string_mean / hot_string_mean:.2f}") + + +if __name__ == "__main__": + main() diff --git a/tests/components/test_button.py b/tests/components/test_button.py index e94af2a..a31d3fc 100644 --- a/tests/components/test_button.py +++ b/tests/components/test_button.py @@ -85,3 +85,16 @@ def test_button_with_font_size(self, mock_runtime): # Font size is passed to text_node assert btn.text_node is not None + + def test_button_style_merge_preserves_margin(self, mock_runtime): + from arepy_ui.components.button import Button + from arepy_ui.core.style import Spacing, Style + + btn = Button( + text="Styled", + on_click=lambda: None, + style=Style(margin=Spacing.symmetric(6, 10)), + ) + + assert btn.style.margin.top.value == 6 + assert btn.style.margin.left.value == 10 diff --git a/tests/components/test_drag.py b/tests/components/test_drag.py index 268ba8c..b805c2d 100644 --- a/tests/components/test_drag.py +++ b/tests/components/test_drag.py @@ -12,6 +12,7 @@ get_drag_state, is_dragging, ) +from arepy_ui.core.animation import Easing from arepy_ui.core.node import Node from arepy_ui.core.style import Style from arepy_ui.core.types import Color, Unit @@ -535,12 +536,34 @@ def test_animate_return_with_manager(self, mock_runtime): mock_manager = MagicMock() mock_manager.animator = MagicMock() + first_animation = MagicMock() + second_animation = MagicMock() + first_animation.to.return_value = first_animation + first_animation.start.return_value = first_animation + second_animation.to.return_value = second_animation + second_animation.call.return_value = second_animation + second_animation.start.return_value = second_animation + mock_manager.animator.create.side_effect = [first_animation, second_animation] draggable._manager = mock_manager draggable._animate_return() - # Should add animations to manager - assert mock_manager.animator.add.call_count == 2 + assert mock_manager.animator.create.call_count == 2 + first_animation.to.assert_called_once_with( + draggable, + "computed_x", + 100, + 0.2, + Easing.EASE_OUT_CUBIC, + ) + second_animation.to.assert_called_once_with( + draggable, + "computed_y", + 100, + 0.2, + Easing.EASE_OUT_CUBIC, + ) + second_animation.call.assert_called_once_with(draggable.mark_dirty) class TestDraggableRenderAsOverlay: diff --git a/tests/components/test_image.py b/tests/components/test_image.py index d21e3f4..2b10dd5 100644 --- a/tests/components/test_image.py +++ b/tests/components/test_image.py @@ -115,12 +115,19 @@ def test_image_render(self): def test_image_with_style(self): """Test creating an Image with custom style.""" from arepy_ui.components.image import Image - from arepy_ui.core.style import Style + from arepy_ui.core.style import Spacing, Style + from arepy_ui.core.types import Unit - style = Style(border_radius=8.0) + style = Style( + width=Unit.px(180), + padding=Spacing.all(6), + border_radius=8.0, + ) image = Image(source="test.png", style=style) assert image.style.border_radius == 8.0 + assert image.style.width.value == 180 + assert image.style.padding.top.value == 6 def test_image_border_radius(self): """Test Image with border radius.""" diff --git a/tests/components/test_input.py b/tests/components/test_input.py index fd6f8d5..bc19d57 100644 --- a/tests/components/test_input.py +++ b/tests/components/test_input.py @@ -5,15 +5,17 @@ import pytest from arepy_ui.components.input import TextInput -from arepy_ui.core.types import Color, Unit +from arepy_ui.core.types import Color, CursorType, Unit @pytest.fixture def mock_runtime(): """Mock runtime for render/input tests.""" - with patch("arepy_ui.components.input.get_runtime") as mock_get_runtime, \ - patch("arepy_ui.core.fonts.get_runtime") as mock_fonts_get_runtime, \ - patch("arepy_ui.core.fonts.get_font_manager") as mock_get_font_manager: + with ( + patch("arepy_ui.components.input.get_runtime") as mock_get_runtime, + patch("arepy_ui.core.fonts.get_runtime") as mock_fonts_get_runtime, + patch("arepy_ui.core.fonts.get_font_manager") as mock_get_font_manager, + ): mock_rt = MagicMock() mock_renderer = MagicMock() mock_renderer.measure_text.return_value = 50 @@ -34,7 +36,12 @@ def mock_runtime(): mock_font_manager.measure_text.return_value = 50 mock_get_font_manager.return_value = mock_font_manager - yield {"runtime": mock_rt, "renderer": mock_renderer, "input": mock_input, "font_manager": mock_font_manager} + yield { + "runtime": mock_rt, + "renderer": mock_renderer, + "input": mock_input, + "font_manager": mock_font_manager, + } @pytest.fixture @@ -87,6 +94,16 @@ def test_textinput_focus_state(self): input_field.is_focused = True assert input_field.is_focused == True + def test_textinput_custom_style_preserves_defaults(self): + from arepy_ui.core.style import Style + + input_field = TextInput(style=Style(border_radius=10.0)) + + assert input_field.style.width.value == 200 + assert input_field.style.height.value == 40 + assert input_field.style.border_radius == 10.0 + assert input_field.style.cursor == CursorType.IBEAM + class TestTextInputSelection: """Tests for text selection functionality.""" @@ -424,6 +441,23 @@ def test_ensure_cursor_visible_scrolls_left(self, mock_runtime): assert input_field.text_offset_x == 0 + def test_text_metrics_cache_reuses_prefix_measurements(self, mock_runtime): + input_field = TextInput() + input_field.value = "Hello" + input_field.computed_x = 0 + input_field.computed_width = 200 + input_field.computed_height = 40 + + with patch("arepy_ui.components.input.measure_text") as mock_measure_text: + mock_measure_text.side_effect = lambda text, size: len(text) * 10 + + first = input_field._get_char_position_at_x(36, mock_runtime["runtime"]) + second = input_field._get_char_position_at_x(36, mock_runtime["runtime"]) + + assert first == 2 + assert second == 2 + assert mock_measure_text.call_count == len(input_field.value) + class TestTextInputHandleKey: """Tests for _handle_key method.""" diff --git a/tests/components/test_listview.py b/tests/components/test_listview.py index 3b46adb..d3fe207 100644 --- a/tests/components/test_listview.py +++ b/tests/components/test_listview.py @@ -167,6 +167,25 @@ def test_listview_render(self): # Should not raise listview.render() + def test_listview_render_does_not_open_nested_scissor(self): + """ListView relies on item culling instead of nested scissor clipping.""" + from arepy_ui.components.listview import ListItem, ListView + + items = [ + ListItem(label="Item 1", value="v1"), + ListItem(label="Item 2", value="v2"), + ] + listview = ListView(items=items) + listview.computed_x = 0 + listview.computed_y = 0 + listview.computed_width = 200 + listview.computed_height = 100 + + listview.render() + + self.mock_renderer.begin_scissor_mode.assert_not_called() + self.mock_renderer.end_scissor_mode.assert_not_called() + def test_listview_with_custom_size(self): """Test ListView with custom dimensions.""" from arepy_ui.components.listview import ListView diff --git a/tests/components/test_tabs.py b/tests/components/test_tabs.py index 9e35fcc..9b4d416 100644 --- a/tests/components/test_tabs.py +++ b/tests/components/test_tabs.py @@ -5,6 +5,7 @@ import pytest from arepy_ui.core.fonts import TextMetrics +from arepy_ui.core.types import FlexDirection @pytest.fixture(autouse=True) @@ -69,6 +70,44 @@ def test_tabs_set_active(self, mock_runtime): tabs.set_tab(2) assert tabs.active_index == 2 + def test_tabs_only_active_content_is_visible(self, mock_runtime): + from arepy_ui.components.tabs import Tabs + from arepy_ui.core.node import Node + + first = Node() + second = Node() + tabs = Tabs(tabs=[("One", first), ("Two", second)]) + + assert first.style.visible is True + assert second.style.visible is False + + tabs.set_tab(1) + + assert first.style.visible is False + assert second.style.visible is True + + def test_tabs_set_same_index_is_noop(self, mock_runtime): + from arepy_ui.components.tabs import Tabs + from arepy_ui.core.node import Node + + tabs = Tabs(tabs=[("One", Node()), ("Two", Node())]) + tabs._set_tab_active_state = MagicMock(wraps=tabs._set_tab_active_state) # type: ignore + + tabs.set_tab(0) + + tabs._set_tab_active_state.assert_not_called() # type: ignore + + def test_tabs_set_tab_marks_root_dirty_when_managed(self, mock_runtime): + from arepy_ui.components.tabs import Tabs + from arepy_ui.core.node import Node + + tabs = Tabs(tabs=[("One", Node()), ("Two", Node())]) + tabs._manager = MagicMock() + + tabs.set_tab(1) + + tabs._manager.mark_dirty.assert_called_once() # type: ignore + def test_tabs_has_tab_bar(self, mock_runtime): from arepy_ui.components.tabs import Tabs from arepy_ui.core.node import Node @@ -84,3 +123,75 @@ def test_tabs_has_content_wrapper(self, mock_runtime): tabs = Tabs(tabs=[("Tab", Node())]) assert tabs.content_wrapper is not None + assert tabs.children[-1] is tabs.content_wrapper + + def test_tabs_custom_style_preserves_default_column_layout(self, mock_runtime): + from arepy_ui.components.tabs import Tabs + from arepy_ui.core.node import Node + from arepy_ui.core.style import Style + from arepy_ui.core.types import Color + + tabs = Tabs( + tabs=[("Tab", Node())], + style=Style(background_color=Color(1, 2, 3, 255)), + ) + + assert tabs.style.flex_direction == FlexDirection.COLUMN + assert tabs.style.background_color == Color(1, 2, 3, 255) + + def test_tabs_auto_height_updates_with_active_content(self, mock_runtime): + from arepy_ui.components.tabs import Tabs + from arepy_ui.core.node import Node + from arepy_ui.core.style import Style + from arepy_ui.core.types import Unit + + short_content = Node(style=Style(width=Unit.percent(100), height=Unit.px(40))) + tall_content = Node(style=Style(width=Unit.percent(100), height=Unit.px(120))) + tabs = Tabs( + tabs=[("Short", short_content), ("Tall", tall_content)], + width=Unit.px(300), + ) + + tabs.calculate_layout(0, 0, 300, 400) + short_height = tabs.computed_height + + tabs.set_tab(1) + tabs.calculate_layout(0, 0, 300, 400) + tall_height = tabs.computed_height + + assert tall_height > short_height + + def test_tabs_keep_following_container_positioned_below(self, mock_runtime): + from arepy_ui.components.tabs import Tabs + from arepy_ui.core.node import Node + from arepy_ui.core.style import Style + from arepy_ui.core.types import FlexDirection, Unit + + root = Node( + style=Style( + width=Unit.px(400), + height=Unit.auto(), + flex_direction=FlexDirection.COLUMN, + gap=12, + ) + ) + short_content = Node(style=Style(width=Unit.percent(100), height=Unit.px(40))) + tall_content = Node(style=Style(width=Unit.percent(100), height=Unit.px(120))) + tabs = Tabs( + tabs=[("Short", short_content), ("Tall", tall_content)], + width=Unit.percent(100), + ) + sibling = Node(style=Style(width=Unit.percent(100), height=Unit.px(30))) + + root.add_child(tabs) + root.add_child(sibling) + + root.calculate_layout(0, 0, 400, 600) + initial_sibling_y = sibling.computed_y + assert initial_sibling_y >= tabs.computed_y + tabs.computed_height + + tabs.set_tab(1) + root.calculate_layout(0, 0, 400, 600) + + assert sibling.computed_y >= tabs.computed_y + tabs.computed_height + assert sibling.computed_y > initial_sibling_y diff --git a/tests/components/test_text.py b/tests/components/test_text.py index 22054a9..e5c3909 100644 --- a/tests/components/test_text.py +++ b/tests/components/test_text.py @@ -56,3 +56,31 @@ def test_text_empty(self, mock_runtime): text = Text("") assert text.text == "" + + def test_single_line_text_uses_single_cached_line(self, mock_runtime): + from arepy_ui.components.text import Text + + text = Text("Hello") + + assert text._lines == ("Hello",) + assert text._is_multiline is False + + def test_multiline_text_measures_first_line_once(self, mock_runtime): + from arepy_ui.components.text import Text + from arepy_ui.core.fonts import TextMetrics + + metrics_by_line = { + "Line 1": TextMetrics(width=80.0, height=20.0, line_height=24.0), + "Line 2": TextMetrics(width=100.0, height=20.0, line_height=24.0), + "Line 3": TextMetrics(width=90.0, height=20.0, line_height=24.0), + } + + with patch( + "arepy_ui.components.text.measure_text_ex", + side_effect=lambda text, *_args: metrics_by_line[text], + ) as measure_mock: + text = Text("Line 1\nLine 2\nLine 3") + + assert measure_mock.call_count == 3 + assert text.style.width.value == 100.0 + assert text.style.height.value == 72.0 diff --git a/tests/components/test_textarea.py b/tests/components/test_textarea.py index 6ed5135..615cfe8 100644 --- a/tests/components/test_textarea.py +++ b/tests/components/test_textarea.py @@ -220,3 +220,24 @@ def test_textarea_cursor_blink(self): assert textarea.cursor_timer == 0.0 assert textarea.show_cursor == True + + def test_textarea_line_metrics_cache_reuses_measurements(self): + """Line metrics are computed once per line content.""" + from arepy_ui.components.textarea import TextArea + + textarea = TextArea() + textarea._lines = ["Hello"] + textarea.computed_x = 0 + textarea.computed_y = 0 + textarea.computed_width = 400 + textarea.computed_height = 200 + + self.mock_renderer.measure_text.reset_mock() + self.mock_renderer.measure_text.side_effect = lambda text, size: len(text) * 10 + + first = textarea._get_column_at_x(0, 24, 0, self.mock_runtime) + second = textarea._get_column_at_x(0, 24, 0, self.mock_runtime) + + assert first == 2 + assert second == 2 + assert self.mock_renderer.measure_text.call_count == len(textarea._lines[0]) diff --git a/tests/components/test_video.py b/tests/components/test_video.py index a45d8e1..80ac659 100644 --- a/tests/components/test_video.py +++ b/tests/components/test_video.py @@ -36,7 +36,8 @@ def test_controls_config_default(self): assert config.show_progress == True assert config.show_time == True assert config.show_volume == True - assert config.auto_hide == False + assert config.auto_hide == True + assert config.style.height.value == 52 def test_controls_config_custom(self): """Test ControlsConfig with custom values.""" @@ -54,3 +55,259 @@ def test_controls_config_custom(self): assert config.auto_hide == True assert config.hide_delay == 5.0 assert config.accent_color == Color(255, 0, 0, 255) + + +class TestVideoStyleMerge: + def test_video_style_overrides_dimensions(self): + from arepy_ui.components.video import Video + from arepy_ui.core.style import Style + from arepy_ui.core.types import Unit + + video = Video( + source="demo.mp4", + style=Style(width=Unit.px(320), height=Unit.px(180)), + controls=False, + ) + + assert video.style.width.value == 320 + assert video.style.height.value == 180 + + +class TestVideoControlsLayout: + @staticmethod + def _build_video(): + from arepy_ui.components import video as video_module + from arepy_ui.components.video import Video + from arepy_ui.core.types import Unit + + runtime = MagicMock() + runtime.has_audio_device = False + + with ( + patch.object(video_module, "HAS_VIDEO_DEPS", True), + patch("arepy_ui.components.video.get_runtime", return_value=runtime), + patch("arepy_ui.core.fonts.get_font_manager") as mock_font_manager, + ): + mock_font_manager.return_value.measure_text_ex.return_value = MagicMock( + width=30.0, height=12.0, line_height=14.0 + ) + return Video( + source="demo.mp4", + width=Unit.px(400), + height=Unit.px(400), + ) + + def test_controls_bar_is_anchored_to_bottom(self): + from arepy_ui.core.types import PositionType + + video = self._build_video() + + assert video._controls_bar is not None + assert video._controls_bar.style.position == PositionType.ABSOLUTE + assert video._controls_bar.style.bottom is not None + assert video._controls_bar.style.bottom.value == 0 + + def test_controls_bar_defaults_to_no_vertical_padding(self): + + video = self._build_video() + + assert video._controls_bar is not None + assert video._controls_bar.style.padding.top.value == 0 + assert video._controls_bar.style.padding.bottom.value == 0 + + def test_controls_bar_defaults_to_square_corners_and_more_spacing(self): + video = self._build_video() + + assert video._controls_bar is not None + assert video._controls_bar.style.border_radius == 0 + assert video._controls_bar.style.gap == 14 + assert video._controls_bar.style.padding.left.value == 18 + assert video._controls_bar.style.padding.right.value == 18 + + def test_controls_use_ascii_safe_default_labels(self): + video = self._build_video() + + assert video._play_button is not None + assert video._volume_btn is not None + assert video._play_button.text_node.text == "PLAY" + assert video._volume_btn.text_node.text == "VOL" + + def test_controls_update_default_labels_with_state(self): + from arepy_ui.components.video import VideoState + + video = self._build_video() + video._state = VideoState.PLAYING + video.muted = True + + with patch("arepy_ui.core.fonts.get_font_manager") as mock_font_manager: + mock_font_manager.return_value.measure_text_ex.return_value = MagicMock( + width=40.0, height=12.0, line_height=14.0 + ) + video._update_controls() + + assert video._play_button is not None + assert video._volume_btn is not None + assert video._play_button.text_node.text == "PAUSE" + assert video._volume_btn.text_node.text == "MUTE" + + def test_controls_bar_tracks_visible_video_frame_bottom(self): + video = self._build_video() + + video._video_width = 1920 + video._video_height = 1080 + video.calculate_layout(0, 0, 400, 400) + + assert video._controls_bar is not None + assert video._controls_bar.computed_x == pytest.approx(0.0) + assert video._controls_bar.computed_width == pytest.approx(400.0) + assert video._controls_bar.computed_y == pytest.approx(260.5) + assert ( + video._controls_bar.computed_y + video._controls_bar.computed_height + ) == pytest.approx(312.5) + + def test_controls_auto_hide_after_mouse_inactivity(self): + from arepy_ui.components.video import VideoState + + video = self._build_video() + video._state = VideoState.PLAYING + video._video_display_bounds = (0.0, 0.0, 400.0, 400.0) + video.calculate_layout(0, 0, 400, 400) + initial_video_height = video.computed_height + initial_controls_height = ( + video._controls_bar.computed_height if video._controls_bar else 0 + ) + + runtime = MagicMock() + runtime.input.get_mouse_position.return_value = (100.0, 100.0) + runtime.input.is_mouse_button_down.return_value = False + runtime.renderer.get_delta_time.return_value = 1.6 + + with patch("arepy_ui.components.video.get_runtime", return_value=runtime): + video._update_controls_visibility(runtime) + assert video._controls_visible == True + video._update_controls_visibility(runtime) + video._update_controls_visibility(runtime) + + assert video._controls_visible == False + assert video._controls_bar is not None + assert video.computed_height == initial_video_height + assert video._controls_bar.computed_height == initial_controls_height + + def test_controls_reappear_on_mouse_move_over_video(self): + from arepy_ui.components.video import VideoState + + video = self._build_video() + video._state = VideoState.PLAYING + video._video_display_bounds = (0.0, 0.0, 400.0, 400.0) + video.calculate_layout(0, 0, 400, 400) + video._set_controls_visible(False) + video._last_mouse_position = (10.0, 10.0) + + runtime = MagicMock() + runtime.input.get_mouse_position.return_value = (40.0, 40.0) + runtime.input.is_mouse_button_down.return_value = False + runtime.renderer.get_delta_time.return_value = 0.1 + + video._update_controls_visibility(runtime) + + assert video._controls_visible == True + assert video._controls_bar is not None + assert video._controls_bar.computed_height == pytest.approx(52.0) + + +class TestVideoSeekSync: + @staticmethod + def _build_video_with_audio(): + from arepy_ui.components import video as video_module + from arepy_ui.components.video import Video + from arepy_ui.core.types import Unit + + runtime = MagicMock() + runtime.has_audio_device = True + runtime.audio_device = MagicMock() + runtime.renderer = MagicMock() + + with ( + patch.object(video_module, "HAS_VIDEO_DEPS", True), + patch("arepy_ui.components.video.get_runtime", return_value=runtime), + patch("arepy_ui.core.fonts.get_font_manager") as mock_font_manager, + ): + mock_font_manager.return_value.measure_text_ex.return_value = MagicMock( + width=30.0, height=12.0, line_height=14.0 + ) + video = Video( + source="demo.mp4", + width=Unit.px(400), + height=Unit.px(240), + ) + + return video, runtime + + def test_seek_primes_video_frame_before_audio_resumes(self): + video, runtime = self._build_video_with_audio() + + frame_a = MagicMock() + frame_a.pts = 25 + frame_a.to_ndarray.return_value.tobytes.return_value = b"frame-a" + + frame_b = MagicMock() + frame_b.pts = 27 + frame_b.to_ndarray.return_value.tobytes.return_value = b"frame-b" + + stream = MagicMock() + stream.time_base = 0.1 + + container = MagicMock() + container.decode.return_value = iter([frame_a, frame_b]) + + video._initialized = True + video._streaming_texture = "texture" + video._video_container = container + video._video_stream = stream + video._video_fps = 10.0 + video._duration = 10.0 + video._has_audio = True + video._audio_music = object() + video._state = "playing" + + with patch("arepy_ui.components.video.get_runtime", return_value=runtime): + video.seek(2.6) + + container.seek.assert_called_once_with(26, stream=stream, backward=True) + runtime.renderer.update_streaming_texture.assert_called_once_with( + "texture", b"frame-b" + ) + assert video.current_time == pytest.approx(2.7) + assert video._post_seek_sync_remaining == pytest.approx( + video._post_seek_sync_duration + ) + + audio_calls = [ + call + for call in runtime.audio_device.mock_calls + if call[0] + in { + "pause_music", + "seek_music_stream", + "update_music_stream", + "resume_music", + } + ] + assert audio_calls[0][0] == "pause_music" + assert audio_calls[1][0] == "seek_music_stream" + assert audio_calls[1][1][1] == pytest.approx(2.7) + assert audio_calls[2][0] == "update_music_stream" + assert audio_calls[3][0] == "resume_music" + + def test_post_seek_sync_window_retargets_instead_of_skipping_frames(self): + video, runtime = self._build_video_with_audio() + video._current_time = 1.0 + video._post_seek_sync_remaining = 0.2 + runtime.renderer.get_delta_time.return_value = 0.05 + + with patch.object(video, "_prime_video_at_time", return_value=True) as prime: + with patch.object(video, "_get_next_frame") as get_next_frame: + video._sync_video_to_audio(runtime, 1.5) + + prime.assert_called_once_with(1.5, runtime) + get_next_frame.assert_not_called() diff --git a/tests/core/test_animation.py b/tests/core/test_animation.py index 37f2d87..0a92d70 100644 --- a/tests/core/test_animation.py +++ b/tests/core/test_animation.py @@ -3,21 +3,23 @@ import pytest from arepy_ui.core.animation import Animation, Animator, Easing, apply_easing +from arepy_ui.core.style import Spacing, Style +from arepy_ui.core.types import Color, Unit, Vector2 class DummyTarget: - """Objeto dummy para probar animaciones.""" + """Objeto dummy para probar secuencias de animacion.""" def __init__(self): self.opacity = 1.0 self.x = 0.0 self.y = 0.0 - self.scale = 1.0 + self.color = Color(0, 0, 0, 0) + self.position = Vector2(0, 0) + self.style = Style(margin=Spacing.all(0)) class TestEasing: - """Test the Easing enum and apply_easing function.""" - def test_linear(self): assert apply_easing(0.0, Easing.LINEAR) == 0.0 assert apply_easing(0.5, Easing.LINEAR) == 0.5 @@ -26,13 +28,11 @@ def test_linear(self): def test_ease_in_quad(self): assert apply_easing(0.0, Easing.EASE_IN_QUAD) == 0.0 assert apply_easing(1.0, Easing.EASE_IN_QUAD) == 1.0 - # ease_in_quad(0.5) = 0.5^2 = 0.25 assert apply_easing(0.5, Easing.EASE_IN_QUAD) == pytest.approx(0.25) def test_ease_out_quad(self): assert apply_easing(0.0, Easing.EASE_OUT_QUAD) == 0.0 assert apply_easing(1.0, Easing.EASE_OUT_QUAD) == 1.0 - # ease_out: t * (2 - t) at 0.5 = 0.5 * 1.5 = 0.75 assert apply_easing(0.5, Easing.EASE_OUT_QUAD) == pytest.approx(0.75) def test_ease_in_out_quad(self): @@ -43,7 +43,6 @@ def test_ease_in_out_quad(self): def test_ease_in_cubic(self): assert apply_easing(0.0, Easing.EASE_IN_CUBIC) == 0.0 assert apply_easing(1.0, Easing.EASE_IN_CUBIC) == 1.0 - # 0.5^3 = 0.125 assert apply_easing(0.5, Easing.EASE_IN_CUBIC) == pytest.approx(0.125) def test_ease_out_cubic(self): @@ -51,7 +50,6 @@ def test_ease_out_cubic(self): assert apply_easing(1.0, Easing.EASE_OUT_CUBIC) == pytest.approx(1.0) def test_ease_out_elastic(self): - # EASE_OUT_ELASTIC may not be implemented with 0/1 boundary, check existence result = apply_easing(0.5, Easing.EASE_OUT_ELASTIC) assert isinstance(result, float) @@ -61,177 +59,141 @@ def test_ease_out_bounce(self): class TestAnimation: - def test_animation_creation(self): - target = DummyTarget() - anim = Animation( - target=target, # type: ignore - property_name="opacity", - start_value=0.0, - end_value=1.0, - duration=1.0, - easing=Easing.LINEAR, - ) - assert anim.target is target - assert anim.property_name == "opacity" - assert anim.duration == 1.0 - assert not anim.is_finished - - def test_animation_update_linear(self): + def test_animation_requires_animator_binding(self): + animation = Animation() + + with pytest.raises(RuntimeError): + animation.start() + + def test_animation_sequence_waits_and_calls(self): + animator = Animator() target = DummyTarget() target.opacity = 0.0 - anim = Animation( - target=target, # type: ignore - property_name="opacity", - start_value=0.0, - end_value=1.0, - duration=1.0, - easing=Easing.LINEAR, - ) - - # 50% del tiempo - anim.update(0.5) + callbacks: list[str] = [] + + animator.create().wait(0.25).to( + target, + "opacity", + 1.0, + 0.5, + Easing.LINEAR, + ).call(lambda: callbacks.append("done")).start() + + animator.update(0.2) + assert target.opacity == pytest.approx(0.0) + assert callbacks == [] + + animator.update(0.3) assert target.opacity == pytest.approx(0.5) - assert not anim.is_finished + assert callbacks == [] - # 100% del tiempo - anim.update(0.5) + animator.update(0.25) assert target.opacity == pytest.approx(1.0) - assert anim.is_finished + assert callbacks == ["done"] + assert len(animator.animations) == 0 - def test_animation_easing_required(self): - target = DummyTarget() - # Animation requires easing parameter - anim = Animation( - target=target, # type: ignore - property_name="opacity", - start_value=0.0, - end_value=1.0, - duration=1.0, - easing=Easing.EASE_OUT_QUAD, - ) - assert anim.easing == Easing.EASE_OUT_QUAD - - def test_animation_on_complete_callback(self): + def test_animation_supports_custom_easing_callable(self): + animator = Animator() target = DummyTarget() - callback_called = [False] - def on_complete(): - callback_called[0] = True + animator.create().to( + target, + "x", + 10.0, + 1.0, + lambda progress: progress * progress, + ).start() - anim = Animation( - target=target, # type: ignore - property_name="opacity", - start_value=0.0, - end_value=1.0, - duration=0.5, - easing=Easing.LINEAR, - on_complete=on_complete, - ) + animator.update(0.5) + assert target.x == pytest.approx(2.5) - anim.update(0.6) - assert callback_called[0], "Callback should have been called" + def test_animation_interpolates_vector_and_color_properties(self): + animator = Animator() + target = DummyTarget() - def test_animation_negative_values(self): + animator.create().to( + target, + "position", + Vector2(10, 20), + 1.0, + Easing.LINEAR, + ).start() + animator.create().to( + target, + "color", + Color(100, 150, 200, 255), + 1.0, + Easing.LINEAR, + ).start() + + animator.update(0.5) + + assert target.position.x == pytest.approx(5.0) + assert target.position.y == pytest.approx(10.0) + assert target.color.r == 50 + assert target.color.g == 75 + assert target.color.b == 100 + assert target.color.a in {127, 128} + + def test_animation_interpolates_nested_unit_property(self): + animator = Animator() target = DummyTarget() - target.x = 100.0 - anim = Animation( - target=target, # type: ignore - property_name="x", - start_value=100.0, - end_value=-100.0, - duration=1.0, - easing=Easing.LINEAR, - ) - anim.update(0.5) - assert target.x == pytest.approx(0.0) + animator.create().to( + target, + "style.margin.left", + 100.0, + 1.0, + Easing.LINEAR, + ).start() - anim.update(0.5) - assert target.x == pytest.approx(-100.0) + animator.update(0.5) + assert isinstance(target.style.margin.left, Unit) + assert target.style.margin.left.value == pytest.approx(50.0) + assert target.style.margin.left.type == Unit.px(0).type -class TestAnimator: - def test_animator_add_animation(self): + def test_animation_cannot_be_modified_after_start(self): animator = Animator() - target = DummyTarget() - anim = Animation( - target=target, # type: ignore - property_name="opacity", - start_value=0, - end_value=1, - duration=1, - easing=Easing.LINEAR, - ) - animator.add(anim) - assert len(animator.animations) == 1 + animation = animator.create().to(object(), "__class__", object, 0.0) + animation.start() - def test_animator_update_removes_completed(self): - animator = Animator() - target = DummyTarget() - anim = Animation( - target=target, # type: ignore - property_name="opacity", - start_value=0, - end_value=1, - duration=0.5, - easing=Easing.LINEAR, - ) - animator.add(anim) - - animator.update(1.0) # Suficiente para completar - assert len(animator.animations) == 0 + with pytest.raises(RuntimeError): + animation.wait(0.1) - def test_animator_multiple_animations(self): + +class TestAnimator: + def test_animator_clear_cancels_active_sequences(self): animator = Animator() target = DummyTarget() - anim1 = Animation( - target=target, # type: ignore - property_name="opacity", - start_value=0, - end_value=1, - duration=1, - easing=Easing.LINEAR, - ) - anim2 = Animation( - target=target, # type: ignore - property_name="x", - start_value=0, - end_value=100, - duration=2, - easing=Easing.LINEAR, - ) - - animator.add(anim1) - animator.add(anim2) + animator.create().to(target, "x", 100.0, 1.0, Easing.LINEAR).start() + animator.create().to(target, "y", 50.0, 1.0, Easing.LINEAR).start() + assert len(animator.animations) == 2 - animator.update(1.5) # Completa anim1, no anim2 - assert len(animator.animations) == 1 + animator.clear() - def test_animator_clear(self): + assert len(animator.animations) == 0 + + def test_animator_keeps_new_sequences_started_from_callbacks(self): animator = Animator() target = DummyTarget() - animator.add( - Animation( - target=target, # type: ignore - property_name="x", - start_value=0, - end_value=1, - duration=1, - easing=Easing.LINEAR, - ) - ) - animator.add( - Animation( - target=target, # type: ignore - property_name="y", - start_value=0, - end_value=1, - duration=1, - easing=Easing.LINEAR, + + animator.create().call( + lambda: animator.create() + .to( + target, + "y", + 20.0, + 1.0, + Easing.LINEAR, ) - ) + .start() + ).start() - animator.animations.clear() - assert len(animator.animations) == 0 + animator.update(0.0) + assert len(animator.animations) == 1 + + animator.update(0.5) + assert target.y == pytest.approx(10.0) diff --git a/tests/core/test_fonts.py b/tests/core/test_fonts.py index d56ccfe..a3f0ae8 100644 --- a/tests/core/test_fonts.py +++ b/tests/core/test_fonts.py @@ -180,3 +180,127 @@ def test_font_manager_get_font(self): font = manager.get_font("my_font") assert font is not None + + def test_measure_text_ex_uses_cache(self): + """Repeated measurements with the same key should hit the in-memory cache.""" + from arepy_ui.core.fonts import FontManager + + manager = FontManager() + self.mock_renderer.measure_text_ex.return_value = (120.0, 24.0) + self.mock_renderer.get_font_default.return_value = MagicMock() + + first = manager.measure_text_ex("Hello cache", 18.0) + second = manager.measure_text_ex("Hello cache", 18.0) + + assert first.width == second.width + assert self.mock_renderer.measure_text_ex.call_count == 1 + + def test_font_manager_load_multiple_fonts(self): + """Batch loading should load all requests and return their names.""" + from arepy_ui.core.fonts import FontLoadRequest, FontManager + + manager = FontManager() + requests = [ + FontLoadRequest(name="title", path="fonts/title.ttf", base_size=48), + FontLoadRequest( + name="body", + path="fonts/body.ttf", + base_size=24, + set_as_default=True, + ), + FontLoadRequest(name="mono", path="fonts/mono.ttf", base_size=16), + ] + + loaded = manager.load_fonts(requests) + + assert loaded == ["title", "body", "mono"] + assert set(manager._fonts.keys()) == {"title", "body", "mono"} + assert manager._default_font_name == "body" + assert self.mock_renderer.load_font_ex.call_count == 3 + + def test_font_manager_batch_load_preserves_unaffected_measurement_cache(self): + """Batch loading should preserve cached default metrics when the default font is unchanged.""" + from arepy_ui.core.fonts import FontLoadRequest, FontManager, TextMetrics + + manager = FontManager() + manager._measurement_cache[("__default__", "Hello", 16.0, 1.0)] = TextMetrics( + width=100.0, + height=20.0, + line_height=20.0, + ) + + manager.load_fonts( + [ + FontLoadRequest(name="title", path="fonts/title.ttf", base_size=48), + FontLoadRequest(name="body", path="fonts/body.ttf", base_size=24), + ] + ) + + assert ("__default__", "Hello", 16.0, 1.0) in manager._measurement_cache + + def test_font_manager_batch_load_invalidates_default_cache_when_default_changes( + self, + ): + """Changing the default font in a batch should invalidate default-font measurements.""" + from arepy_ui.core.fonts import FontLoadRequest, FontManager, TextMetrics + + manager = FontManager() + manager._measurement_cache[("__default__", "Hello", 16.0, 1.0)] = TextMetrics( + width=100.0, + height=20.0, + line_height=20.0, + ) + + manager.load_fonts( + [ + FontLoadRequest( + name="body", + path="fonts/body.ttf", + base_size=24, + set_as_default=True, + ), + ] + ) + + assert ("__default__", "Hello", 16.0, 1.0) not in manager._measurement_cache + + def test_font_manager_load_font_with_glyph_subset(self): + """Glyph subsets should be forwarded to the renderer as codepoints.""" + from arepy_ui.core.fonts import FontManager + + manager = FontManager() + manager.load_font("score", "fonts/score.ttf", glyphs="SCORE: 0123456789") + + glyph_codes = self.mock_renderer.load_font_ex.call_args.args[2] + assert ord("S") in glyph_codes + assert ord("0") in glyph_codes + assert len(glyph_codes) < len(range(32, 127)) + + def test_font_manager_unload_font(self): + """Unloading a single font should remove it and return True.""" + from arepy_ui.core.fonts import FontManager + + manager = FontManager() + manager.load_font("hud", "fonts/hud.ttf", set_as_default=True) + + unloaded = manager.unload_font("hud") + + assert unloaded is True + assert "hud" not in manager._fonts + assert manager._default_font_name is None + self.mock_renderer.unload_font.assert_called_once() + + def test_font_manager_unload_fonts(self): + """Batch unload should remove all known names and ignore unknown ones.""" + from arepy_ui.core.fonts import FontManager + + manager = FontManager() + manager.load_font("title", "fonts/title.ttf") + manager.load_font("body", "fonts/body.ttf", set_as_default=True) + + unloaded = manager.unload_fonts(["title", "body", "missing"]) + + assert unloaded == ["title", "body"] + assert manager._fonts == {} + assert manager._default_font_name is None + assert self.mock_renderer.unload_font.call_count == 2 diff --git a/tests/core/test_node.py b/tests/core/test_node.py index fa9a32a..aa07c06 100644 --- a/tests/core/test_node.py +++ b/tests/core/test_node.py @@ -518,6 +518,50 @@ def test_propagate_position_with_absolute_child(self): assert child.computed_x == 130 assert child.computed_y == 140 + +class TestNodeDirtyRelayoutRoot: + def test_relayout_root_defaults_to_parent_for_descendant_change(self): + root = Node(style=Style(width=Unit.px(400), height=Unit.px(300))) + container = Node(style=Style(width=Unit.px(200), height=Unit.px(100))) + child = Node(style=Style(width=Unit.px(50), height=Unit.px(20))) + + root.add_child(container) + container.add_child(child) + + assert child._get_relayout_root() is container + + def test_relayout_root_climbs_through_auto_sized_ancestors(self): + root = Node(style=Style(width=Unit.px(400), height=Unit.px(300))) + auto_container = Node( + style=Style( + width=Unit.auto(), + height=Unit.px(100), + flex_direction=FlexDirection.ROW, + ) + ) + child = Node(style=Style(width=Unit.px(50), height=Unit.px(20))) + + root.add_child(auto_container) + auto_container.add_child(child) + + assert child._get_relayout_root() is root + + +class TestNodeStyleBinding: + def test_node_binds_style_owner(self): + style = Style(width=Unit.px(100), height=Unit.px(50)) + node = Node(style=style) + + assert style._owner is node + + def test_replacing_node_style_rebinds_owner(self): + node = Node() + new_style = Style(width=Unit.px(100)) + + node.style = new_style + + assert new_style._owner is node + def test_propagate_position_invisible_node(self): parent = Node(style=Style(width=Unit.px(200), height=Unit.px(200))) child = Node(style=Style(width=Unit.px(50), height=Unit.px(50), visible=False)) @@ -909,7 +953,8 @@ def test_mark_dirty_with_manager(self): node.mark_dirty() - mock_manager.mark_dirty.assert_called_once() + mock_manager.mark_dirty_node.assert_called_once_with(node) + mock_manager.mark_dirty.assert_not_called() def test_mark_dirty_propagates_to_parent(self): parent = Node() @@ -921,7 +966,8 @@ def test_mark_dirty_propagates_to_parent(self): child.mark_dirty() - mock_manager.mark_dirty.assert_called() + mock_manager.mark_dirty_node.assert_called_once_with(parent) + mock_manager.mark_dirty.assert_not_called() class TestNodePropagateManager: @@ -943,6 +989,36 @@ def test_propagate_manager_on_add_child(self): class TestNodePropagatePositionAbsolute: """Tests for _propagate_position_to_children with absolute positioning.""" + def test_propagate_absolute_respects_parent_padding(self): + from arepy_ui.core.types import PositionType + + parent = Node( + style=Style( + width=Unit.px(200), + height=Unit.px(200), + padding=Spacing.all(10), + ) + ) + child = Node( + style=Style( + width=Unit.px(50), + height=Unit.px(50), + position=PositionType.ABSOLUTE, + left=Unit.px(30), + top=Unit.px(40), + ) + ) + parent.add_child(child) + parent.calculate_layout(0, 0, 800, 600) + + parent.computed_x = 100 + parent.computed_y = 100 + + parent._propagate_position_to_children(parent) + + assert child.computed_x == 140 + assert child.computed_y == 150 + def test_propagate_absolute_right_position(self): from arepy_ui.core.types import PositionType diff --git a/tests/core/test_style.py b/tests/core/test_style.py index 74d6f52..9b612da 100644 --- a/tests/core/test_style.py +++ b/tests/core/test_style.py @@ -1,8 +1,15 @@ """Tests para arepy_ui.core.style""" +from unittest.mock import MagicMock + import pytest -from arepy_ui.core.style import Spacing, Style +from arepy_ui.core.style import ( + Spacing, + Style, + merge_non_default_style_fields, + merge_style_fields, +) from arepy_ui.core.types import ( AlignItems, Color, @@ -106,3 +113,75 @@ def test_style_with_min_max(self): assert style.max_width.value == 500 # type: ignore assert style.min_height.value == 20 # type: ignore assert style.max_height.value == 200 # type: ignore + + def test_style_marks_owner_dirty_on_layout_change(self): + owner = MagicMock() + style = Style() + style._bind_owner(owner) + + style.width = Unit.px(120) + + owner.mark_dirty.assert_called_once() + + def test_style_does_not_mark_owner_dirty_on_appearance_change(self): + owner = MagicMock() + style = Style() + style._bind_owner(owner) + + style.background_color = Color(255, 0, 0, 255) + + owner.mark_dirty.assert_not_called() + + def test_spacing_mutation_marks_owner_dirty(self): + owner = MagicMock() + style = Style() + style._bind_owner(owner) + + style.padding.left = Unit.px(12) + + owner.mark_dirty.assert_called_once() + + def test_set_measured_size_marks_owner_dirty_once(self): + owner = MagicMock() + style = Style() + style._bind_owner(owner) + + changed = style.set_measured_size(120, 40) + + assert changed is True + assert style.width.value == 120 + assert style.height.value == 40 + owner.mark_dirty.assert_called_once() + + def test_set_measured_size_skips_unchanged_values(self): + owner = MagicMock() + style = Style(width=Unit.px(120), height=Unit.px(40)) + style._bind_owner(owner) + + changed = style.set_measured_size(120, 40) + + assert changed is False + owner.mark_dirty.assert_not_called() + + def test_merge_style_fields_clones_spacing(self): + base = Style() + override = Style(padding=Spacing.symmetric(4, 8)) + + merge_style_fields(base, override, ("padding",)) + + assert base.padding.left.value == 8 + assert base.padding is not override.padding + + def test_merge_non_default_style_fields_preserves_base_defaults(self): + base = Style(width=Unit.px(200), height=Unit.px(40)) + override = Style(border_radius=8.0) + + merge_non_default_style_fields( + base, + override, + ("width", "height", "border_radius"), + ) + + assert base.width.value == 200 + assert base.height.value == 40 + assert base.border_radius == 8.0 diff --git a/tests/core/test_timers.py b/tests/core/test_timers.py new file mode 100644 index 0000000..d478ef7 --- /dev/null +++ b/tests/core/test_timers.py @@ -0,0 +1,58 @@ +"""Tests para arepy_ui.core.timers""" + +from arepy_ui.core.timers import Timers + + +class TestTimers: + def test_after_runs_once(self): + events: list[str] = [] + timers = Timers() + + timers.after(0.2, lambda: events.append("done")) + + timers.update(0.1) + assert events == [] + assert len(timers.timers) == 1 + + timers.update(0.1) + assert events == ["done"] + assert len(timers.timers) == 0 + + def test_every_repeats_until_callback_returns_false(self): + events: list[int] = [] + timers = Timers() + + def on_tick() -> bool: + events.append(len(events) + 1) + return len(events) < 3 + + timers.every(0.1, on_tick) + timers.update(0.35) + + assert events == [1, 2, 3] + assert len(timers.timers) == 0 + + def test_timer_can_be_cancelled(self): + events: list[str] = [] + timers = Timers() + + timer = timers.after(0.1, lambda: events.append("cancelled")) + timer.cancel() + timers.update(0.2) + + assert events == [] + assert len(timers.timers) == 0 + + def test_timer_created_from_callback_is_kept(self): + events: list[str] = [] + timers = Timers() + + def schedule_next() -> None: + timers.after(0.1, lambda: events.append("nested")) + + timers.after(0.0, schedule_next) + timers.update(0.0) + + assert len(timers.timers) == 1 + timers.update(0.1) + assert events == ["nested"] diff --git a/tests/core/test_transitions.py b/tests/core/test_transitions.py index 5aec491..76ee403 100644 --- a/tests/core/test_transitions.py +++ b/tests/core/test_transitions.py @@ -550,8 +550,11 @@ def test_sequence_runner_with_delay(self): # First update - still in delay runner.update(0.3) - assert runner._delay_timer == 0.3 + assert runner._current_item_started is False + assert fade._elapsed == 0.0 # Second update - starts item runner.update(0.3) - assert runner._delay_timer >= 0.5 + assert runner._current_item_started is True + assert runner._delay_timer is None + assert fade._elapsed == 0.0 diff --git a/tests/markup/test_aui_parser.py b/tests/markup/test_aui_parser.py index bd07799..8cf5757 100644 --- a/tests/markup/test_aui_parser.py +++ b/tests/markup/test_aui_parser.py @@ -146,6 +146,14 @@ def test_parse_self_closing_tag(self): assert root.children[0].tag == "image" assert root.children[0].attributes.get("src") == "test.png" + def test_parse_colorpicker_tag(self): + content = '' + root, errors = parse_aui(content) + + assert root is not None + assert root.tag == "colorpicker" + assert errors == [] + def test_parse_button_tag(self): content = '' root, errors = parse_aui(content) @@ -226,6 +234,24 @@ def test_parser_errors_property(self): # Parser should handle unclosed tags gracefully assert isinstance(parser.errors, list) + def test_parse_reports_unclosed_tag(self): + root, errors = parse_aui("") + + assert root is not None + assert any("Unclosed tag " in error for error in errors) + + def test_parse_reports_unterminated_quoted_attribute(self): + root, errors = parse_aui('' after " in error for error in errors) + class TestParseAUIFunction: """Tests for the parse_aui convenience function.""" diff --git a/tests/markup/test_builder.py b/tests/markup/test_builder.py index f9eb376..59e7b88 100644 --- a/tests/markup/test_builder.py +++ b/tests/markup/test_builder.py @@ -10,12 +10,19 @@ from arepy_ui.core.node import Node from arepy_ui.core.types import Unit, UnitType from arepy_ui.markup.builder import ( + _COMPILED_NODE_PLAN_CACHE, + _RESOLVED_STYLE_CACHE, _STYLE_CONVERTERS, + _TAG_ATTRIBUTE_PLAN_CACHE, + _compile_node_plan_uncached, + _apply_tag_attributes, + _clear_builder_caches, _convert_style_value, build_component, resolve_styles, ) from arepy_ui.markup.errors import ErrorCollector +from arepy_ui.markup.globals import clear_globals, load_globals_string, set_theme from arepy_ui.markup.parsers import parse_acss, parse_aui # Components dictionary needed by build_component @@ -89,6 +96,14 @@ def test_style_converters_dict_has_all_keys(self): class TestResolveStyles: """Tests for style resolution from stylesheet.""" + def setup_method(self): + _clear_builder_caches() + clear_globals() + + def teardown_method(self): + _clear_builder_caches() + clear_globals() + def test_resolve_styles_with_id(self): aui_content = '' css_content = """ @@ -105,8 +120,8 @@ def test_resolve_styles_with_id(self): # Builder converts to Unit objects assert styles is not None assert isinstance(styles.get("width"), Unit) - assert styles.get("width").type == UnitType.PERCENT # type: ignore - assert styles.get("width").value == 100.0 # type: ignore + assert styles.get("width").type == UnitType.PERCENT # type: ignore + assert styles.get("width").value == 100.0 # type: ignore def test_resolve_styles_with_class(self): aui_content = '' @@ -152,6 +167,139 @@ def test_resolve_styles_empty_stylesheet(self): assert isinstance(styles, dict) + def test_resolve_styles_reuses_cache_for_same_selector_signature(self): + aui_content = '' + css_content = ".card { width: 100px; height: 50px; }" + + first_root, _ = parse_aui(aui_content) + second_root, _ = parse_aui(aui_content) + stylesheet = parse_acss(css_content) + assert first_root is not None + assert second_root is not None + + with patch( + "arepy_ui.markup.builder._convert_style_value", + wraps=_convert_style_value, + ) as mock_convert: + resolve_styles(first_root, stylesheet) + first_call_count = mock_convert.call_count + resolve_styles(second_root, stylesheet) + + assert first_call_count > 0 + assert mock_convert.call_count == first_call_count + + def test_resolve_styles_cache_invalidates_on_theme_change(self): + load_globals_string( + """ + :root { --fg: #111111; } + :root.light { --fg: #eeeeee; } + .headline { color: var(--fg); } + """ + ) + root, _ = parse_aui('Hello') + assert root is not None + + dark_styles = resolve_styles(root, None) + set_theme("light") + light_styles = resolve_styles(root, None) + + assert dark_styles["text_color"] != light_styles["text_color"] + + def test_resolve_styles_cache_invalidates_on_clear_globals(self): + load_globals_string( + """ + .headline { color: #112233; } + """ + ) + root, _ = parse_aui('Hello') + assert root is not None + + styled = resolve_styles(root, None) + clear_globals() + reset = resolve_styles(root, None) + + assert styled.get("text_color") is not None + assert reset.get("text_color") is None + + def test_resolved_style_cache_uses_lru_bound(self, monkeypatch): + monkeypatch.setattr( + "arepy_ui.markup.builder._MAX_RESOLVED_STYLE_CACHE_ENTRIES", 2 + ) + + stylesheet = parse_acss( + """ + .card-1 { width: 10px; } + .card-2 { width: 20px; } + .card-3 { width: 30px; } + """ + ) + roots = [] + for name in ("card-1", "card-2", "card-3"): + root, _ = parse_aui(f'') + assert root is not None + roots.append(root) + + for root in roots: + resolve_styles(root, stylesheet) + + assert len(_RESOLVED_STYLE_CACHE) == 2 + + def test_tag_attribute_plan_cache_uses_lru_bound(self, monkeypatch): + monkeypatch.setattr( + "arepy_ui.markup.builder._MAX_TAG_ATTRIBUTE_PLAN_CACHE_ENTRIES", 2 + ) + + roots = [] + for width in ("10px", "20px", "30px"): + root, _ = parse_aui(f'') + assert root is not None + roots.append(root) + + for root in roots: + _apply_tag_attributes(root.tag, root, {}, {}, {}, None) + + assert len(_TAG_ATTRIBUTE_PLAN_CACHE) == 2 + + def test_compiled_node_plan_is_reused_for_same_tree(self): + root, _ = parse_aui( + """ + + + + + """ + ) + assert root is not None + + with patch( + "arepy_ui.markup.builder._compile_node_plan_uncached", + wraps=_compile_node_plan_uncached, + ) as mock_compile: + build_component(root, None, {}, COMPONENTS) + first_call_count = mock_compile.call_count + build_component(root, None, {}, COMPONENTS) + + assert first_call_count > 0 + assert mock_compile.call_count == first_call_count + + def test_compiled_node_plan_cache_uses_lru_bound(self, monkeypatch): + monkeypatch.setattr( + "arepy_ui.markup.builder._MAX_COMPILED_NODE_PLAN_CACHE_ENTRIES", 2 + ) + + roots = [] + for index in range(3): + root, _ = parse_aui( + f"" + ) + assert root is not None + roots.append(root) + + for root in roots: + build_component(root, None, {}, COMPONENTS) + + assert len(_COMPILED_NODE_PLAN_CACHE) == 2 + class TestBuildComponent: """Tests for building components from AUI nodes.""" @@ -266,7 +414,7 @@ def test_build_with_handlers(self): root, _ = parse_aui(aui_content) stylesheet = parse_acss(css_content) assert root is not None - component = build_component(root, stylesheet, handlers, COMPONENTS) # type: ignore + component = build_component(root, stylesheet, handlers, COMPONENTS) # type: ignore assert component is not None assert component.on_click is not None @@ -397,6 +545,29 @@ def test_column_tag_sets_flex_direction(self): assert styles.get("flex_direction") == FlexDirection.COLUMN + @patch("arepy_ui.core.fonts.get_font_manager") + def test_text_color_is_resolved_once_from_styles(self, mock_fm): + from arepy_ui.core.types import Color + from arepy_ui.core.fonts import TextMetrics + + mock_fm.return_value.measure_text_ex.return_value = TextMetrics(100, 20, 24) + + aui_content = 'Title' + css_content = ".headline { color: #112233; }" + + root, _ = parse_aui(aui_content) + stylesheet = parse_acss(css_content) + assert root is not None + + component = build_component(root, stylesheet, {}, COMPONENTS) + + assert component is not None + assert isinstance(component, Text) + assert isinstance(component.color, Color) + assert component.color.r == 17 + assert component.color.g == 34 + assert component.color.b == 51 + class TestBuildComponentErrors: """Tests for error handling in build_component.""" @@ -481,7 +652,7 @@ def test_select_with_options(self): root, _ = parse_aui(aui_content) assert root is not None component = build_component(root, None, {}, components) - assert component.options == ["a", "b", "c"] # type: ignore + assert component.options == ["a", "b", "c"] # type: ignore def test_input_attributes(self): from arepy_ui.components.input import TextInput @@ -492,7 +663,7 @@ def test_input_attributes(self): assert root is not None component = build_component(root, None, {}, components) - assert component.placeholder == "Enter name" # type: ignore + assert component.placeholder == "Enter name" # type: ignore @patch("arepy_ui.core.fonts.get_font_manager") def test_button_with_handler(self, mock_fm): @@ -509,7 +680,7 @@ def test_button_with_handler(self, mock_fm): component = build_component(root, None, handlers, COMPONENTS) assert component is not None - component.on_click() # type: ignore + component.on_click() # type: ignore assert len(clicked) == 1 def test_slider_with_on_change_handler(self): @@ -524,5 +695,5 @@ def test_slider_with_on_change_handler(self): assert root is not None component = build_component(root, None, handlers, components) - component.on_change(50) # type: ignore + component.on_change(50) # type: ignore assert values == [50] diff --git a/tests/markup/test_css_parser.py b/tests/markup/test_css_parser.py index e9f1beb..db640d3 100644 --- a/tests/markup/test_css_parser.py +++ b/tests/markup/test_css_parser.py @@ -243,6 +243,19 @@ def test_parse_whitespace_content(self): sheet = parse_acss(content) assert sheet.rules == [] + def test_parse_ignores_block_and_line_comments(self): + content = """ + /* remove this */ + .first { width: 100px; } + // and this too + .second { height: 200px; } + """ + sheet = parse_acss(content) + + assert len(sheet.rules) == 2 + assert sheet.resolve_class("first")["width"] == ("px", 100.0) + assert sheet.resolve_class("second")["height"] == ("px", 200.0) + class TestACSSComplexStyles: """Tests for complex ACSS styling scenarios.""" @@ -353,3 +366,24 @@ def test_complex_selector_parsing(self): # ID selector assert sheet.resolve_id("main")["width"] == ("percent", 100.0) + + def test_pseudo_selectors_use_indexed_resolution(self): + content = """ + .button:hover { background: #123456; } + #hero:active { background: #654321; } + text:hover { color: #abcdef; } + """ + sheet = parse_acss(content) + + assert sheet.resolve_class_pseudo("button", "hover")["background"] == ( + "color", + "#123456", + ) + assert sheet.resolve_id_pseudo("hero", "active")["background"] == ( + "color", + "#654321", + ) + assert sheet.resolve_element_pseudo("text", "hover")["color"] == ( + "color", + "#abcdef", + ) diff --git a/tests/markup/test_globals.py b/tests/markup/test_globals.py index f575603..2f49a90 100644 --- a/tests/markup/test_globals.py +++ b/tests/markup/test_globals.py @@ -270,3 +270,24 @@ def test_cache_invalidation_on_theme_change(self): styles = gs.resolve_for_class("item") assert styles.get("color") == "light" + + def test_multiple_global_stylesheets_merge_variables(self): + load_globals_string( + """ + :root { --base: #111111; } + .panel { background: var(--base); } + """ + ) + load_globals_string( + """ + :root { --accent: #ff0000; } + .badge { color: var(--accent); } + """ + ) + + gs = get_global_styles() + + assert gs.get_variable("base") == "#111111" + assert gs.get_variable("accent") == "#ff0000" + assert gs.resolve_for_class("panel").get("background") == "#111111" + assert gs.resolve_for_class("badge").get("color") == "#ff0000" diff --git a/tests/markup/test_loader.py b/tests/markup/test_loader.py new file mode 100644 index 0000000..ee2528f --- /dev/null +++ b/tests/markup/test_loader.py @@ -0,0 +1,169 @@ +"""Tests for markup loader caching behavior.""" + +from pathlib import Path +from unittest.mock import patch + +from arepy_ui.markup.loader import ( + _AUI_FILE_CACHE, + _AUI_STRING_CACHE, + _INLINE_STYLESHEET_CACHE, + _clear_load_caches, + load_aui, + load_aui_string, +) + + +class TestLoaderCaching: + def setup_method(self): + _clear_load_caches() + + def teardown_method(self): + _clear_load_caches() + + @patch("arepy_ui.markup.loader._get_components", return_value={}) + @patch("arepy_ui.markup.loader.build_component", return_value=object()) + def test_load_aui_reuses_parsed_file_cache( + self, _mock_build, _mock_components, tmp_path + ): + aui_path = tmp_path / "ui.aui" + acss_path = tmp_path / "ui.acss" + aui_path.write_text("Hello", encoding="utf-8") + acss_path.write_text("text { color: #fff; }", encoding="utf-8") + + fake_root = object() + fake_sheet = object() + + with ( + patch( + "arepy_ui.markup.loader.parse_aui_file", + return_value=(fake_root, []), + ) as mock_parse_aui_file, + patch( + "arepy_ui.markup.loader.parse_acss_file", + return_value=fake_sheet, + ) as mock_parse_acss_file, + ): + first = load_aui(str(aui_path)) + second = load_aui(str(aui_path)) + + assert first.root is not None + assert second.root is not None + assert mock_parse_aui_file.call_count == 1 + assert mock_parse_acss_file.call_count == 1 + + @patch("arepy_ui.markup.loader._get_components", return_value={}) + @patch("arepy_ui.markup.loader.build_component", return_value=object()) + def test_load_aui_invalidates_cache_when_source_changes( + self, _mock_build, _mock_components, tmp_path + ): + aui_path = tmp_path / "panel.aui" + aui_path.write_text("Hello", encoding="utf-8") + + fake_root = object() + + with patch( + "arepy_ui.markup.loader.parse_aui_file", + return_value=(fake_root, []), + ) as mock_parse_aui_file: + load_aui(str(aui_path)) + aui_path.write_text("Hello again", encoding="utf-8") + load_aui(str(aui_path)) + + assert mock_parse_aui_file.call_count == 2 + + def test_load_aui_string_reuses_inline_stylesheet_cache(self): + with patch("arepy_ui.markup.loader.parse_aui", return_value=(object(), [])): + with patch( + "arepy_ui.markup.loader.parse_acss", + side_effect=lambda content: {"content": content}, + ) as mock_parse_acss: + with ( + patch( + "arepy_ui.markup.loader.build_component", + return_value=object(), + ), + patch("arepy_ui.markup.loader._get_components", return_value={}), + ): + load_aui_string("Hello", ".title { color: #fff; }") + load_aui_string("Hello", ".title { color: #fff; }") + + assert mock_parse_acss.call_count == 1 + + def test_load_aui_string_reuses_parsed_aui_cache(self): + with patch( + "arepy_ui.markup.loader.parse_aui", + return_value=(object(), []), + ) as mock_parse_aui: + with ( + patch( + "arepy_ui.markup.loader.build_component", + return_value=object(), + ), + patch("arepy_ui.markup.loader._get_components", return_value={}), + ): + load_aui_string("Hello") + load_aui_string("Hello") + + assert mock_parse_aui.call_count == 1 + + @patch("arepy_ui.markup.loader._get_components", return_value={}) + @patch("arepy_ui.markup.loader.build_component", return_value=object()) + def test_load_aui_file_cache_uses_lru_bound( + self, _mock_build, _mock_components, tmp_path, monkeypatch + ): + monkeypatch.setattr("arepy_ui.markup.loader._MAX_AUI_FILE_CACHE_ENTRIES", 2) + + paths = [] + for index in range(3): + path = tmp_path / f"{index}.aui" + path.write_text("Hello", encoding="utf-8") + paths.append(path) + + with patch( + "arepy_ui.markup.loader.parse_aui_file", + return_value=(object(), []), + ): + for path in paths: + load_aui(str(path), stylesheet=None) + + assert len(_AUI_FILE_CACHE) == 2 + + def test_inline_stylesheet_cache_uses_lru_bound(self, monkeypatch): + monkeypatch.setattr( + "arepy_ui.markup.loader._MAX_INLINE_STYLESHEET_CACHE_ENTRIES", 2 + ) + + with patch("arepy_ui.markup.loader.parse_aui", return_value=(object(), [])): + with patch( + "arepy_ui.markup.loader.parse_acss", + side_effect=lambda content: {"content": content}, + ): + with ( + patch( + "arepy_ui.markup.loader.build_component", + return_value=object(), + ), + patch("arepy_ui.markup.loader._get_components", return_value={}), + ): + load_aui_string("Hello", ".a { color: #111; }") + load_aui_string("Hello", ".b { color: #222; }") + load_aui_string("Hello", ".c { color: #333; }") + + assert len(_INLINE_STYLESHEET_CACHE) == 2 + + def test_aui_string_cache_uses_lru_bound(self, monkeypatch): + monkeypatch.setattr("arepy_ui.markup.loader._MAX_AUI_STRING_CACHE_ENTRIES", 2) + + with patch("arepy_ui.markup.loader.parse_aui", return_value=(object(), [])): + with ( + patch( + "arepy_ui.markup.loader.build_component", + return_value=object(), + ), + patch("arepy_ui.markup.loader._get_components", return_value={}), + ): + load_aui_string("One") + load_aui_string("Two") + load_aui_string("Three") + + assert len(_AUI_STRING_CACHE) == 2 diff --git a/tests/test_manager.py b/tests/test_manager.py index 7e4b1bc..da9f9b2 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -3,11 +3,15 @@ from unittest.mock import MagicMock, patch import pytest +from arepy.ecs.systems import SystemPipeline +from arepy.ecs.world import World +from arepy.engine.input import Key +from arepy.engine.time import Time from arepy_ui.config import ResizeMode, UIConfig from arepy_ui.core.node import Node from arepy_ui.core.style import Style -from arepy_ui.core.types import Unit +from arepy_ui.core.types import FlexDirection, Unit @pytest.fixture @@ -17,12 +21,15 @@ def mock_runtime(): mock.display.get_window_size.return_value = (1280, 720) mock.renderer.get_font_default.return_value = MagicMock() mock.renderer.is_stencil_available.return_value = True + mock.renderer.measure_text_ex.return_value = (100.0, 20.0) mock.input.get_mouse_position.return_value = (0, 0) mock.input.is_mouse_button_pressed.return_value = False with patch("arepy_ui.manager.get_runtime", return_value=mock): with patch("arepy_ui.runtime.get_runtime", return_value=mock): - yield mock + with patch("arepy_ui.core.node.get_runtime", return_value=mock): + with patch("arepy_ui.core.fonts.get_runtime", return_value=mock): + yield mock class TestUIManager: @@ -68,6 +75,10 @@ def test_mark_dirty(self, mock_runtime): manager.mark_dirty() assert manager.is_dirty, "is_dirty should be True after mark_dirty" + assert ( + manager._dirty_layout_root is None + or manager._dirty_layout_root is manager.root + ) def test_get_reference_size(self, mock_runtime): from arepy_ui.manager import UIManager @@ -117,6 +128,248 @@ def test_from_engine(self, mock_runtime): # Verify manager was created with config assert manager.config.resize_mode == ResizeMode.RESPONSIVE + def test_from_world(self, mock_runtime): + """Test creating UIManager from a World's shared resources.""" + from arepy_ui.manager import UIManager + + mock_world = MagicMock() + renderer = MagicMock() + input_device = MagicMock() + display = MagicMock() + asset_store = MagicMock() + audio_device = MagicMock() + + def get_resource(resource_type): + resources = { + "Renderer2D": renderer, + "Input": input_device, + "Display": display, + "AssetStore": asset_store, + "AudioDevice": audio_device, + } + return resources[resource_type.__name__] + + mock_world.get_resource.side_effect = get_resource + + with patch("arepy_ui.manager.configure_runtime") as mock_configure: + manager = UIManager.from_world(mock_world, config=UIConfig()) + + mock_configure.assert_called_once_with( + renderer=renderer, + input=input_device, + display=display, + asset_store=asset_store, + audio_device=audio_device, + ) + assert manager.config is not None + + def test_install_registers_resource_and_systems(self, mock_runtime): + from arepy_ui.manager import ( + UIManager, + _world_render_system, + _world_update_system, + ) + + mock_world = MagicMock() + renderer = MagicMock() + input_device = MagicMock() + display = MagicMock() + + def get_resource(resource_type): + resources = { + "Renderer2D": renderer, + "Input": input_device, + "Display": display, + } + if resource_type.__name__ not in resources: + raise KeyError(resource_type.__name__) + return resources[resource_type.__name__] + + mock_world.get_resource.side_effect = get_resource + root = Node(style=Style(width=Unit.px(100), height=Unit.px(50))) + + manager = UIManager.install(mock_world, root=root, config=UIConfig()) + + mock_world.add_resource.assert_called_once_with(manager) + assert mock_world.add_system.call_count == 2 + assert mock_world.add_system.call_args_list[0].args == ( + SystemPipeline.UPDATE, + _world_update_system, + ) + assert mock_world.add_system.call_args_list[1].args == ( + SystemPipeline.RENDER_UI, + _world_render_system, + ) + assert manager.root is root + + def test_update_system_uses_injected_time_and_input(self, mock_runtime): + from arepy_ui.manager import UIManager + + manager = UIManager() + manager.update = MagicMock() + + time = Time(0.0) + time.delta_seconds = 0.25 + input_device = MagicMock() + input_device.get_mouse_wheel_delta.return_value = -2.0 + + manager.update_system(time, input_device) + + manager.update.assert_called_once_with(0.25, wheel_scroll=-2.0) + + def test_installed_world_system_receives_ui_manager_resources(self, mock_runtime): + from arepy_ui.manager import UIManager, _world_update_system + + time = Time(0.0) + time.delta_seconds = 0.5 + input_device = MagicMock() + input_device.get_mouse_wheel_delta.return_value = 1.25 + world = World( + "test", + global_resources={ + "Time": time, + "Input": input_device, + }, + ) + + manager = UIManager() + manager.update = MagicMock() + world.add_resource(manager) + world.add_system(SystemPipeline.UPDATE, _world_update_system) + + world.get_registry().run(SystemPipeline.UPDATE) + + manager.update.assert_called_once_with(0.5, wheel_scroll=1.25) + + def test_update_toggles_integrated_debugger_with_default_key(self, mock_runtime): + from arepy_ui.manager import UIManager + + manager = UIManager() + mock_runtime.input.is_key_pressed.side_effect = lambda key: key == Key.F3 + + manager.update(0.016) + + assert manager.get_debugger().enabled is True + + def test_set_debug_toggle_key_uses_custom_key(self, mock_runtime): + from arepy_ui.manager import UIManager + + manager = UIManager() + manager.set_debug_toggle_key(Key.F2) + mock_runtime.input.is_key_pressed.side_effect = lambda key: key == Key.F2 + + manager.update(0.016) + + assert manager.get_debugger().enabled is True + assert manager.get_debugger().toggle_hotkey_label == "F2" + + def test_render_calls_integrated_debugger(self, mock_runtime): + from arepy_ui.manager import UIManager + + manager = UIManager() + root = Node(style=Style(width=Unit.px(100), height=Unit.px(60))) + manager.set_root(root) + manager.enable_debug_overlay(True) + manager.get_debugger().render = MagicMock() # type: ignore + + with patch("arepy_ui.core.node.get_runtime", return_value=mock_runtime): + manager.render() + + manager.get_debugger().render.assert_called_once_with(root) # type: ignore + + def test_config_can_start_with_debug_enabled(self, mock_runtime): + from arepy_ui.manager import UIManager + + manager = UIManager(UIConfig(debug_enabled=True)) + + assert manager.get_debugger().enabled is True + + def test_debugger_props_handle_video_string_state(self, mock_runtime): + from arepy_ui.components.video import Video, VideoState + from arepy_ui.manager import UIManager + + manager = UIManager() + video = Video(source="demo.mp4", controls=False) + video._state = VideoState.PLAYING + video._duration = 12.34 + + props = manager.get_debugger()._get_component_props(video) + + assert "state: playing" in props + assert "duration: 12.3s" in props + + def test_debugger_frame_tracks_scrollview_visual_offset(self, mock_runtime): + from arepy_ui.components.scroll import ScrollView + from arepy_ui.manager import UIManager + + manager = UIManager() + + child = Node(style=Style(width=Unit.px(80), height=Unit.px(40))) + content = Node( + style=Style( + width=Unit.px(200), + height=Unit.px(400), + flex_direction=None, + ), + children=[child], + ) + scrollview = ScrollView( + width=Unit.px(200), + height=Unit.px(120), + content=content, + ) + + manager.set_root(scrollview) + scrollview.scroll_y = -50 + content.computed_x = 0 + content.computed_y = 0 + content.computed_width = 200 + content.computed_height = 400 + child.computed_x = 10 + child.computed_y = 90 + child.computed_width = 80 + child.computed_height = 40 + + debugger = manager.get_debugger() + debugger._build_frame(scrollview) + frame = debugger._frame_index[child] + + assert frame.rect == (10, 40, 80, 40) + assert frame.visible_rect == (10, 40, 80, 40) + + def test_debugger_hover_respects_scrollview_clip(self, mock_runtime): + from arepy_ui.components.scroll import ScrollView + from arepy_ui.manager import UIManager + + manager = UIManager() + + child = Node(style=Style(width=Unit.px(100), height=Unit.px(40))) + content = Node(style=Style(width=Unit.px(200), height=Unit.px(300))) + content.add_child(child) + scrollview = ScrollView( + width=Unit.px(200), + height=Unit.px(100), + content=content, + ) + + manager.set_root(scrollview) + scrollview.scroll_y = -30 + content.computed_x = 0 + content.computed_y = 0 + content.computed_width = 200 + content.computed_height = 300 + child.computed_x = 0 + child.computed_y = 100 + child.computed_width = 100 + child.computed_height = 40 + + debugger = manager.get_debugger() + debugger._build_frame(scrollview) + + assert debugger._frame_index[child].visible_rect == (0, 70, 100, 30) + assert debugger._find_node_at(10, 95) == child + assert debugger._find_node_at(150, 95) == content + class TestUIManagerModals: def test_show_modal(self, mock_runtime): @@ -224,13 +477,13 @@ def test_request_focus_calls_on_blur(self, mock_runtime): manager = UIManager() node1 = Node() - node1._on_blur = MagicMock() # type: ignore + node1._on_blur = MagicMock() # type: ignore node2 = Node() manager.request_focus(node1) manager.request_focus(node2) - node1._on_blur.assert_called_once() # type: ignore + node1._on_blur.assert_called_once() # type: ignore assert manager.get_focused_node() is node2 def test_release_focus(self, mock_runtime): @@ -273,12 +526,12 @@ def test_clear_focus_calls_on_blur(self, mock_runtime): manager = UIManager() node = Node() - node._on_blur = MagicMock() # type: ignore + node._on_blur = MagicMock() # type: ignore manager.request_focus(node) manager.clear_focus() - node._on_blur.assert_called_once() # type: ignore + node._on_blur.assert_called_once() # type: ignore class TestUIManagerUpdate: @@ -343,21 +596,116 @@ def on_resize(w, h): def test_update_resize_debounce(self, mock_runtime): from arepy_ui.manager import UIManager - config = UIConfig(layout_debounce_ms=100) + config = UIConfig(layout_debounce_ms=100, resize_mode=ResizeMode.RESPONSIVE) manager = UIManager(config=config) - manager.set_root(Node()) + root = Node(style=Style(width=Unit.vw(50), height=Unit.vh(25))) + manager.set_root(root) + + assert root.computed_width == 640 + assert root.computed_height == 180 # Simulate resize mock_runtime.display.get_window_size.return_value = (1920, 1080) manager.update(0.016) - # Should have pending resize - assert manager._pending_resize is True + # Layout should still be debounced + assert root.computed_width == 640 + assert root.computed_height == 180 # After debounce time passes manager.update(0.1) - assert manager._pending_resize is False + assert root.computed_width == 960 + assert root.computed_height == 270 + + +class TestUIManagerPartialLayout: + def test_mark_dirty_node_merges_to_common_ancestor(self, mock_runtime): + from arepy_ui.manager import UIManager + + manager = UIManager() + root = Node() + left = Node() + right = Node() + left_child = Node() + right_child = Node() + + root.add_child(left) + root.add_child(right) + left.add_child(left_child) + right.add_child(right_child) + + manager.set_root(root) + manager.mark_dirty_node(left) + manager.mark_dirty_node(right) + + assert manager._dirty_layout_root is root + + def test_recalculate_layout_uses_partial_root_when_available(self, mock_runtime): + from arepy_ui.manager import UIManager + + class CountingNode(Node): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.layout_calls = 0 + + def calculate_layout(self, parent_x, parent_y, parent_width, parent_height): + self.layout_calls += 1 + return super().calculate_layout( + parent_x, parent_y, parent_width, parent_height + ) + + manager = UIManager() + root = CountingNode(style=Style(width=Unit.px(800), height=Unit.px(600))) + container = CountingNode(style=Style(width=Unit.px(200), height=Unit.px(100))) + child = CountingNode(style=Style(width=Unit.px(50), height=Unit.px(20))) + sibling = CountingNode(style=Style(width=Unit.px(60), height=Unit.px(20))) + + root.add_child(container) + root.add_child(sibling) + container.add_child(child) + + manager.set_root(root) + + root.layout_calls = 0 + container.layout_calls = 0 + child.layout_calls = 0 + sibling.layout_calls = 0 + + child.mark_dirty() + manager._recalculate_layout() + + assert root.layout_calls == 0 + assert container.layout_calls == 1 + assert child.layout_calls == 1 + assert sibling.layout_calls == 0 + + def test_partial_relayout_preserves_final_flex_position(self, mock_runtime): + from arepy_ui.manager import UIManager + + manager = UIManager() + root = Node( + style=Style( + width=Unit.px(200), + height=Unit.px(200), + flex_direction=FlexDirection.COLUMN, + gap=10, + ) + ) + first = Node(style=Style(width=Unit.px(50), height=Unit.px(50))) + second = Node(style=Style(width=Unit.px(50), height=Unit.px(50))) + + root.add_child(first) + root.add_child(second) + + manager.set_root(root) + + assert second.computed_y == 60 + + manager.mark_dirty_node(second) + manager._recalculate_layout() + + assert second.computed_y == 60 def test_update_click_clears_focus(self, mock_runtime): from arepy_ui.manager import UIManager @@ -395,12 +743,12 @@ def test_render_with_root(self, mock_runtime): manager = UIManager() root = Node() - root.render = MagicMock() # type: ignore + root.render = MagicMock() # type: ignore manager.set_root(root) manager.render() - root.render.assert_called_once() # type: ignore + root.render.assert_called_once() # type: ignore def test_render_initializes_stencil(self, mock_runtime): from arepy_ui.manager import UIManager @@ -525,20 +873,16 @@ def test_update_tooltip_shows_after_delay(self, mock_runtime): # Create node with tooltip node = Node() - node.tooltip = "Test tooltip" # type: ignore + node.tooltip = "Test tooltip" # type: ignore manager._hovered_node = node - # First update - timer starts, tooltip text set - manager._update_tooltip(Vector2(100, 100), 0.3) - manager._tooltip_text = ( - "Test tooltip" # Ensure text matches for timer increment - ) + manager._update_tooltip(Vector2(100, 100)) assert manager._tooltip_visible is False - # Manually set tooltip_timer to simulate time passing - manager._tooltip_timer = 0.3 - # Second update with same tooltip text - timer exceeds delay - manager._update_tooltip(Vector2(100, 100), 0.3) + manager.timers.update(0.3) + assert manager._tooltip_visible is False + + manager.timers.update(0.3) assert manager._tooltip_visible is True def test_update_tooltip_resets_on_change(self, mock_runtime): @@ -549,22 +893,22 @@ def test_update_tooltip_resets_on_change(self, mock_runtime): manager._tooltip_delay = 0.1 node1 = Node() - node1.tooltip = "Tooltip 1" # type: ignore + node1.tooltip = "Tooltip 1" # type: ignore manager._hovered_node = node1 - # Set first tooltip manually and make it visible - manager._tooltip_text = "Tooltip 1" - manager._tooltip_timer = 0.2 - manager._update_tooltip(Vector2(100, 100), 0.2) + manager._update_tooltip(Vector2(100, 100)) + manager.timers.update(0.2) assert manager._tooltip_visible is True # Change to new tooltip node2 = Node() - node2.tooltip = "Tooltip 2" # type: ignore + node2.tooltip = "Tooltip 2" # type: ignore manager._hovered_node = node2 - manager._update_tooltip(Vector2(100, 100), 0.05) - # Timer should reset + manager._update_tooltip(Vector2(100, 100)) + assert manager._tooltip_visible is False + + manager.timers.update(0.05) assert manager._tooltip_visible is False def test_update_tooltip_clears_when_no_tooltip(self, mock_runtime): @@ -574,20 +918,18 @@ def test_update_tooltip_clears_when_no_tooltip(self, mock_runtime): manager = UIManager() node = Node() - node.tooltip = "Test" # type: ignore + node.tooltip = "Test" # type: ignore manager._hovered_node = node manager._tooltip_delay = 0.1 - # Set tooltip visible manually - manager._tooltip_text = "Test" - manager._tooltip_timer = 0.2 - manager._update_tooltip(Vector2(100, 100), 0.2) + manager._update_tooltip(Vector2(100, 100)) + manager.timers.update(0.2) assert manager._tooltip_visible is True # Clear hovered node manager._hovered_node = Node() # No tooltip - manager._update_tooltip(Vector2(100, 100), 0.016) + manager._update_tooltip(Vector2(100, 100)) assert manager._tooltip_visible is False @@ -653,19 +995,19 @@ def test_modal_blocks_main_input(self, mock_runtime): manager = UIManager() root = Node() - root.handle_input = MagicMock(return_value=False) # type: ignore + root.handle_input = MagicMock(return_value=False) # type: ignore manager.set_root(root) modal = Node(style=Style(width=Unit.px(100), height=Unit.px(100))) - modal.handle_input = MagicMock(return_value=True) # type: ignore + modal.handle_input = MagicMock(return_value=True) # type: ignore manager.show_modal(modal) mock_runtime.input.get_mouse_position.return_value = (50, 50) manager.update(0.016) # Modal handles input, root does not - modal.handle_input.assert_called() # type: ignore - root.handle_input.assert_not_called() # type: ignore + modal.handle_input.assert_called() # type: ignore + root.handle_input.assert_not_called() # type: ignore def test_click_outside_modal_closes(self, mock_runtime): from arepy_ui.manager import UIManager @@ -715,6 +1057,7 @@ def test_handle_resize_responsive(self, mock_runtime): manager._handle_resize() assert manager.is_dirty is True + assert manager._dirty_layout_root is manager.root def test_handle_resize_fixed(self, mock_runtime): from arepy_ui.manager import UIManager @@ -748,6 +1091,25 @@ def test_handle_resize_scale_fit(self, mock_runtime): manager._handle_resize() assert manager.is_dirty is True + assert manager._dirty_layout_root is manager.root + + def test_handle_resize_overrides_partial_dirty_root(self, mock_runtime): + from arepy_ui.manager import UIManager + + config = UIConfig(resize_mode=ResizeMode.RESPONSIVE) + manager = UIManager(config=config) + root = Node() + child = Node() + root.add_child(child) + manager.set_root(root) + + manager.mark_dirty_node(child) + assert manager._dirty_layout_root is child + + manager._handle_resize() + + assert manager.is_dirty is True + assert manager._dirty_layout_root is root def test_update_with_scale_mode_transforms_mouse(self, mock_runtime): from arepy_ui.manager import UIManager @@ -759,7 +1121,7 @@ def test_update_with_scale_mode_transforms_mouse(self, mock_runtime): ) manager = UIManager(config=config) root = Node() - root.handle_input = MagicMock(return_value=False) # type: ignore + root.handle_input = MagicMock(return_value=False) # type: ignore manager.set_root(root) mock_runtime.input.get_mouse_position.return_value = (640, 360) @@ -767,7 +1129,26 @@ def test_update_with_scale_mode_transforms_mouse(self, mock_runtime): manager.update(0.016) # Just verify update doesn't crash with scale mode - root.handle_input.assert_called() # type: ignore + root.handle_input.assert_called() # type: ignore + + def test_resize_updates_viewport_units_layout(self, mock_runtime): + from arepy_ui.manager import UIManager + from unittest.mock import patch + + with patch("arepy_ui.core.node.get_runtime", return_value=mock_runtime): + config = UIConfig(resize_mode=ResizeMode.RESPONSIVE) + manager = UIManager(config=config) + root = Node(style=Style(width=Unit.vw(50), height=Unit.vh(25))) + manager.set_root(root) + + assert root.computed_width == 640 + assert root.computed_height == 180 + + mock_runtime.display.get_window_size.return_value = (1920, 1080) + manager.update(0.016) + + assert root.computed_width == 960 + assert root.computed_height == 270 class TestLayoutCallbacks: @@ -877,48 +1258,48 @@ def test_render_modals_with_backdrop(self, mock_runtime): manager = UIManager() modal = Node() - modal._has_backdrop = True # type: ignore - modal.render = MagicMock() # type: ignore + modal._has_backdrop = True # type: ignore + modal.render = MagicMock() # type: ignore manager._modals = [modal] with patch("arepy_ui.manager.get_runtime", return_value=mock_runtime): manager._render_modals() mock_runtime.renderer.draw_rectangle.assert_called() - modal.render.assert_called_once() # type: ignore + modal.render.assert_called_once() # type: ignore def test_render_modals_without_backdrop(self, mock_runtime): from arepy_ui.manager import UIManager manager = UIManager() modal = Node() - modal._has_backdrop = False # type: ignore - modal.render = MagicMock() # type: ignore + modal._has_backdrop = False # type: ignore + modal.render = MagicMock() # type: ignore manager._modals = [modal] with patch("arepy_ui.manager.get_runtime", return_value=mock_runtime): manager._render_modals() mock_runtime.renderer.draw_rectangle.assert_not_called() - modal.render.assert_called_once() # type: ignore + modal.render.assert_called_once() # type: ignore def test_render_multiple_modals(self, mock_runtime): from arepy_ui.manager import UIManager manager = UIManager() modal1 = Node() - modal1._has_backdrop = True # type: ignore - modal1.render = MagicMock() # type: ignore + modal1._has_backdrop = True # type: ignore + modal1.render = MagicMock() # type: ignore modal2 = Node() - modal2._has_backdrop = True # type: ignore - modal2.render = MagicMock() # type: ignore + modal2._has_backdrop = True # type: ignore + modal2.render = MagicMock() # type: ignore manager._modals = [modal1, modal2] with patch("arepy_ui.manager.get_runtime", return_value=mock_runtime): manager._render_modals() - modal1.render.assert_called_once() # type: ignore - modal2.render.assert_called_once() # type: ignore + modal1.render.assert_called_once() # type: ignore + modal2.render.assert_called_once() # type: ignore assert mock_runtime.renderer.draw_rectangle.call_count == 2 @@ -932,7 +1313,9 @@ def test_find_node_with_cursor_in_scrollview(self, mock_runtime): # Create a ScrollView with a child content = Node() - scrollview = ScrollView(width=Unit.px(200), height=Unit.px(200), content=content) + scrollview = ScrollView( + width=Unit.px(200), height=Unit.px(200), content=content + ) scrollview.computed_x = 0 scrollview.computed_y = 0 scrollview.computed_width = 200 diff --git a/uv.lock b/uv.lock index fba67eb..8a116ad 100644 --- a/uv.lock +++ b/uv.lock @@ -4,12 +4,16 @@ requires-python = ">=3.11" [[package]] name = "arepy" -version = "0.4.8" -source = { git = "https://github.com/Scr44gr/arepy?rev=main#de63a9fce83573cf6574352bc4425c73ddfaa222" } +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bitarray" }, { name = "raylib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/80/16/d0b6060e068c59f4077c0b8165c2bd0367c7a776d8061f5b5d5bb5d7c717/arepy-0.5.4.tar.gz", hash = "sha256:62c2db2f8b1c366e2a42d8094a4d004a0cce7724a805f4567843a2a67e79dacf", size = 85441, upload-time = "2026-04-13T18:47:44.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/f6/6e0060bfb70e7fde2eaffd7907c447876f5aaadb44aac910a325b8f0049e/arepy-0.5.4-py3-none-any.whl", hash = "sha256:ddaa4507be3e4fb66d0f7a442d3529f12c8122649959b164459db8cdea971cd0", size = 81625, upload-time = "2026-04-13T18:47:43.044Z" }, +] [[package]] name = "arepy-ui" @@ -30,6 +34,7 @@ docs = [ { name = "mkdocs-glightbox" }, { name = "mkdocs-material" }, { name = "mkdocs-minify-plugin" }, + { name = "mkdocstrings", extra = ["python"] }, ] full = [ { name = "av" }, @@ -41,7 +46,7 @@ markup = [ [package.metadata] requires-dist = [ - { name = "arepy", git = "https://github.com/Scr44gr/arepy?rev=main" }, + { name = "arepy", specifier = "==0.5.4" }, { name = "av", marker = "extra == 'full'", specifier = "==16.0.1" }, { name = "cython", marker = "extra == 'dev'", specifier = ">=3.0" }, { name = "cython", marker = "extra == 'markup'", specifier = ">=3.0" }, @@ -49,6 +54,7 @@ requires-dist = [ { name = "mkdocs-glightbox", marker = "extra == 'docs'" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, { name = "mkdocs-minify-plugin", marker = "extra == 'docs'" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.28" }, { name = "numpy", marker = "extra == 'full'", specifier = "==2.3.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, @@ -131,75 +137,75 @@ wheels = [ [[package]] name = "bitarray" -version = "3.8.0" +version = "3.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/06/92fdc84448d324ab8434b78e65caf4fb4c6c90b4f8ad9bdd4c8021bfaf1e/bitarray-3.8.0.tar.gz", hash = "sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", size = 151991, upload-time = "2025-11-02T21:41:15.117Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/47/b5da717e7bbe97a6dc4c986f053ca55fd3276078d78f68f9e8b417d1425a/bitarray-3.8.1.tar.gz", hash = "sha256:f90bb3c680804ec9630bcf8c0965e54b4de84d33b17d7da57c87c30f0c64c6f5", size = 152471, upload-time = "2026-04-02T16:29:01.712Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/7d/63558f1d0eb09217a3d30c1c847890879973e224a728fcff9391fab999b8/bitarray-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6", size = 148502, upload-time = "2025-11-02T21:39:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7b/f957ad211cb0172965b5f0881b67b99e2b6d41512af0a1001f44a44ddf4a/bitarray-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607", size = 145484, upload-time = "2025-11-02T21:39:10.904Z" }, - { url = "https://files.pythonhosted.org/packages/9f/dc/897973734f14f91467a3a795a4624752238053ecffaec7c8bbda1e363fda/bitarray-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52", size = 330909, upload-time = "2025-11-02T21:39:12.276Z" }, - { url = "https://files.pythonhosted.org/packages/67/be/24b4b792426d92de289e73e09682915d567c2e69d47e8857586cbdc865d0/bitarray-3.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f", size = 358469, upload-time = "2025-11-02T21:39:13.766Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0e/2eda69a7a59a6998df8fb57cc9d1e0e62888c599fb5237b0a8b479a01afb/bitarray-3.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425", size = 369131, upload-time = "2025-11-02T21:39:15.041Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7b/8a372d6635a6b2622477b2f96a569b2cd0318a62bc95a4a2144c7942c987/bitarray-3.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096", size = 337089, upload-time = "2025-11-02T21:39:16.124Z" }, - { url = "https://files.pythonhosted.org/packages/93/f0/8eca934dbe5dee47a0e5ef44eeb72e85acacc8097c27cd164337bc4ec5d3/bitarray-3.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4", size = 328504, upload-time = "2025-11-02T21:39:17.321Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/928b8e23a9950f8a8bfc42bc1e7de41f4e27f57de01a716308be5f683c2b/bitarray-3.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc", size = 356461, upload-time = "2025-11-02T21:39:18.396Z" }, - { url = "https://files.pythonhosted.org/packages/a9/93/4fb58417aff47fa2fe1874a39c9346b589a1d78c93a9cb24cccede5dc737/bitarray-3.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf", size = 353008, upload-time = "2025-11-02T21:39:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/da/54/aa04e4a7b45aa5913f08ee377d43319b0979925e3c0407882eb29df3be66/bitarray-3.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125", size = 334048, upload-time = "2025-11-02T21:39:20.924Z" }, - { url = "https://files.pythonhosted.org/packages/da/52/e851f41076df014c05d6ac1ce34fbf7db5fa31241da3e2f09bb2be9e283d/bitarray-3.8.0-cp311-cp311-win32.whl", hash = "sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b", size = 142907, upload-time = "2025-11-02T21:39:22.312Z" }, - { url = "https://files.pythonhosted.org/packages/28/01/db0006148b1dd13b4ac2686df8fa57d12f5887df313a506e939af0cb0997/bitarray-3.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b", size = 149670, upload-time = "2025-11-02T21:39:23.341Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ea/b7d55ee269b1426f758a535c9ec2a07c056f20f403fa981685c3c8b4798c/bitarray-3.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2", size = 146709, upload-time = "2025-11-02T21:39:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/82/a0/0c41d893eda756315491adfdbf9bc928aee3d377a7f97a8834d453aa5de1/bitarray-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8", size = 148575, upload-time = "2025-11-02T21:39:25.718Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/12ab2f4a4429bd844b419c37877caba93d676d18be71354fbbeb21d9f4cc/bitarray-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d", size = 145454, upload-time = "2025-11-02T21:39:26.695Z" }, - { url = "https://files.pythonhosted.org/packages/26/58/314b3e3f219533464e120f0c51ac5123e7b1c1b91f725a4073fb70c5a858/bitarray-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20", size = 332949, upload-time = "2025-11-02T21:39:27.801Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ce/ca8c706bd8341c7a22dd92d2a528af71f7e5f4726085d93f81fd768cb03b/bitarray-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1", size = 360599, upload-time = "2025-11-02T21:39:28.964Z" }, - { url = "https://files.pythonhosted.org/packages/ef/dc/aa181df85f933052d962804906b282acb433cb9318b08ec2aceb4ee34faf/bitarray-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220", size = 371972, upload-time = "2025-11-02T21:39:30.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d9/b805bfa158c7bcf4df0ac19b1be581b47e1ddb792c11023aed80a7058e78/bitarray-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35", size = 340303, upload-time = "2025-11-02T21:39:31.342Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/5308cc97ea929e30727292617a3a88293470166851e13c9e3f16f395da55/bitarray-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77", size = 330494, upload-time = "2025-11-02T21:39:32.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/89/64f1596cb80433323efdbc8dcd0d6e57c40dfbe6ea3341623f34ec397edd/bitarray-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d", size = 358123, upload-time = "2025-11-02T21:39:34.331Z" }, - { url = "https://files.pythonhosted.org/packages/27/fd/f3d49c5443b57087f888b5e118c8dd78bb7c8e8cfeeed250f8e92128a05f/bitarray-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de", size = 356046, upload-time = "2025-11-02T21:39:35.449Z" }, - { url = "https://files.pythonhosted.org/packages/aa/db/1fd0b402bd2b47142e958b6930dbb9445235d03fa703c9a24caa6e576ae2/bitarray-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e", size = 336872, upload-time = "2025-11-02T21:39:36.891Z" }, - { url = "https://files.pythonhosted.org/packages/58/73/680b47718f1313b4538af479c4732eaca0aeda34d93fc5b869f87932d57d/bitarray-3.8.0-cp312-cp312-win32.whl", hash = "sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55", size = 143025, upload-time = "2025-11-02T21:39:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/f8/11/7792587c19c79a8283e8838f44709fa4338a8f7d2a3091dfd81c07ae89c7/bitarray-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b", size = 149969, upload-time = "2025-11-02T21:39:39.715Z" }, - { url = "https://files.pythonhosted.org/packages/9a/00/9df64b5d8a84e8e9ec392f6f9ce93f50626a5b301cb6c6b3fe3406454d66/bitarray-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954", size = 146907, upload-time = "2025-11-02T21:39:40.815Z" }, - { url = "https://files.pythonhosted.org/packages/3e/35/480364d4baf1e34c79076750914664373f561c58abb5c31c35b3fae613ff/bitarray-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9", size = 148582, upload-time = "2025-11-02T21:39:42.268Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a8/718b95524c803937f4edbaaf6480f39c80f6ed189d61357b345e8361ffb6/bitarray-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e", size = 145433, upload-time = "2025-11-02T21:39:43.552Z" }, - { url = "https://files.pythonhosted.org/packages/03/66/4a10f30dc9e2e01e3b4ecd44a511219f98e63c86b0e0f704c90fac24059b/bitarray-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd", size = 332986, upload-time = "2025-11-02T21:39:44.656Z" }, - { url = "https://files.pythonhosted.org/packages/53/25/4c08774d847f80a1166e4c704b4e0f1c417c0afe6306eae0bc5e70d35faa/bitarray-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a", size = 360634, upload-time = "2025-11-02T21:39:45.798Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/bf8ad26169ebd0b2746d5c7564db734453ca467f8aab87e9d43b0a794383/bitarray-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb", size = 371992, upload-time = "2025-11-02T21:39:46.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/16/ce166754e7c9d10650e02914552fa637cf3b2591f7ed16632bbf6b783312/bitarray-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a", size = 340315, upload-time = "2025-11-02T21:39:48.182Z" }, - { url = "https://files.pythonhosted.org/packages/de/2a/fbba3a106ddd260e84b9a624f730257c32ba51a8a029565248dfedfdf6f2/bitarray-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5", size = 330473, upload-time = "2025-11-02T21:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/68/97/56cf3c70196e7307ad32318a9d6ed969dbdc6a4534bbe429112fa7dfe42e/bitarray-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594", size = 358129, upload-time = "2025-11-02T21:39:51.189Z" }, - { url = "https://files.pythonhosted.org/packages/fd/be/afd391a5c0896d3339613321b2f94af853f29afc8bd3fbc327431244c642/bitarray-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428", size = 356005, upload-time = "2025-11-02T21:39:52.355Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a8e1a371babba29bad3378bb3a2cdca2b012170711e7fe1f22031a6b7b95/bitarray-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6", size = 336862, upload-time = "2025-11-02T21:39:54.345Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/6dc1d0fdc06991c8dc3b1fcfe1ae49fbaced42064cd1b5f24278e73fe05f/bitarray-3.8.0-cp313-cp313-win32.whl", hash = "sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2", size = 143018, upload-time = "2025-11-02T21:39:56.361Z" }, - { url = "https://files.pythonhosted.org/packages/2e/72/76e13f5cd23b8b9071747909663ce3b02da24a5e7e22c35146338625db35/bitarray-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9", size = 149977, upload-time = "2025-11-02T21:39:57.718Z" }, - { url = "https://files.pythonhosted.org/packages/01/37/60f336c32336cc3ec03b0c61076f16ea2f05d5371c8a56e802161d218b77/bitarray-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee", size = 146930, upload-time = "2025-11-02T21:39:59.308Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b0/411327a6c7f6b2bead64bb06fe60b92e0344957ec1ab0645d5ccc25fdafe/bitarray-3.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89", size = 148563, upload-time = "2025-11-02T21:40:01.006Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bc/ff80d97c627d774f879da0ea93223adb1267feab7e07d5c17580ffe6d632/bitarray-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310", size = 145422, upload-time = "2025-11-02T21:40:02.535Z" }, - { url = "https://files.pythonhosted.org/packages/66/e7/b4cb6c5689aacd0a32f3aa8a507155eaa33528c63de2f182b60843fbf700/bitarray-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c", size = 332852, upload-time = "2025-11-02T21:40:03.645Z" }, - { url = "https://files.pythonhosted.org/packages/e7/91/fbd1b047e3e2f4b65590f289c8151df1d203d75b005f5aae4e072fe77d76/bitarray-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d", size = 360801, upload-time = "2025-11-02T21:40:04.827Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4a/63064c593627bac8754fdafcb5343999c93ab2aeb27bcd9d270a010abea5/bitarray-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5", size = 371408, upload-time = "2025-11-02T21:40:05.985Z" }, - { url = "https://files.pythonhosted.org/packages/46/97/ddc07723767bdafd170f2ff6e173c940fa874192783ee464aa3c1dedf07d/bitarray-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2", size = 340033, upload-time = "2025-11-02T21:40:07.189Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1e/e1ea9f1146fd4af032817069ff118918d73e5de519854ce3860e2ed560ff/bitarray-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe", size = 330774, upload-time = "2025-11-02T21:40:08.496Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9f/8242296c124a48d1eab471fd0838aeb7ea9c6fd720302d99ab7855d3e6d3/bitarray-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11", size = 358337, upload-time = "2025-11-02T21:40:10.035Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6b/9095d75264c67d479f298c80802422464ce18c3cdd893252eeccf4997611/bitarray-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7", size = 355639, upload-time = "2025-11-02T21:40:11.485Z" }, - { url = "https://files.pythonhosted.org/packages/a0/af/c93c0ae5ef824136e90ac7ddf6cceccb1232f34240b2f55a922f874da9b4/bitarray-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860", size = 336999, upload-time = "2025-11-02T21:40:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/81/0f/72c951f5997b2876355d5e671f78dd2362493254876675cf22dbd24389ae/bitarray-3.8.0-cp314-cp314-win32.whl", hash = "sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25", size = 142169, upload-time = "2025-11-02T21:40:14.031Z" }, - { url = "https://files.pythonhosted.org/packages/8a/55/ef1b4de8107bf13823da8756c20e1fbc9452228b4e837f46f6d9ddba3eb3/bitarray-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4", size = 148737, upload-time = "2025-11-02T21:40:15.436Z" }, - { url = "https://files.pythonhosted.org/packages/5f/26/bc0784136775024ac56cc67c0d6f9aa77a7770de7f82c3a7c9be11c217cd/bitarray-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e", size = 146083, upload-time = "2025-11-02T21:40:17.135Z" }, - { url = "https://files.pythonhosted.org/packages/6e/64/57984e64264bf43d93a1809e645972771566a2d0345f4896b041ce20b000/bitarray-3.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521", size = 149455, upload-time = "2025-11-02T21:40:18.558Z" }, - { url = "https://files.pythonhosted.org/packages/81/c0/0d5f2eaef1867f462f764bdb07d1e116c33a1bf052ea21889aefe4282f5b/bitarray-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa", size = 146491, upload-time = "2025-11-02T21:40:19.665Z" }, - { url = "https://files.pythonhosted.org/packages/65/c6/bc1261f7a8862c0c59220a484464739e52235fd1e2afcb24d7f7d3fb5702/bitarray-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8", size = 339721, upload-time = "2025-11-02T21:40:21.277Z" }, - { url = "https://files.pythonhosted.org/packages/81/d8/289ca55dd2939ea17b1108dc53bffc0fdc5160ba44f77502dfaae35d08c6/bitarray-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3", size = 367823, upload-time = "2025-11-02T21:40:22.463Z" }, - { url = "https://files.pythonhosted.org/packages/91/a2/61e7461ca9ac0fcb70f327a2e84b006996d2a840898e69037a39c87c6d06/bitarray-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df", size = 377341, upload-time = "2025-11-02T21:40:23.789Z" }, - { url = "https://files.pythonhosted.org/packages/6c/87/4a0c9c8bdb13916d443e04d8f8542eef9190f31425da3c17c3478c40173f/bitarray-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f", size = 344985, upload-time = "2025-11-02T21:40:25.261Z" }, - { url = "https://files.pythonhosted.org/packages/17/4c/ff9259b916efe53695b631772e5213699c738efc2471b5ffe273f4000994/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8", size = 336796, upload-time = "2025-11-02T21:40:26.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/4b/51b2468bbddbade5e2f3b8d5db08282c5b309e8687b0f02f75a8b5ff559c/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8", size = 365085, upload-time = "2025-11-02T21:40:28.224Z" }, - { url = "https://files.pythonhosted.org/packages/bf/79/53473bfc2e052c6dbb628cdc1b156be621c77aaeb715918358b01574be55/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773", size = 361012, upload-time = "2025-11-02T21:40:29.635Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b1/242bf2e44bfc69e73fa2b954b425d761a8e632f78ea31008f1c3cfad0854/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9", size = 340644, upload-time = "2025-11-02T21:40:31.089Z" }, - { url = "https://files.pythonhosted.org/packages/cf/01/12e5ecf30a5de28a32485f226cad4b8a546845f65f755ce0365057ab1e92/bitarray-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149", size = 143630, upload-time = "2025-11-02T21:40:32.351Z" }, - { url = "https://files.pythonhosted.org/packages/b6/92/6b6ade587b08024a8a890b07724775d29da9cf7497be5c3cbe226185e463/bitarray-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e", size = 150250, upload-time = "2025-11-02T21:40:33.596Z" }, - { url = "https://files.pythonhosted.org/packages/ed/40/be3858ffed004e47e48a2cefecdbf9b950d41098b780f9dc3aa609a88351/bitarray-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f", size = 147015, upload-time = "2025-11-02T21:40:35.064Z" }, + { url = "https://files.pythonhosted.org/packages/05/5c/32ace44d0313b4a9986d2abc3a1349744920dafcfb6a4e454a10ed09ef5a/bitarray-3.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:660e11b9932f58f10151d0febd11f77d3b0d48d6fa4dd4686d8983f40187101e", size = 149069, upload-time = "2026-04-02T16:26:36.671Z" }, + { url = "https://files.pythonhosted.org/packages/6d/85/7bd0a218478f0a226ddfb756dd64286f8ee3c61a17991a1a50aae8d89dca/bitarray-3.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fb1df55f5700187c6db4b47dbdaf8a0653a111341ac7fccc596b397aa3399e65", size = 146036, upload-time = "2026-04-02T16:26:38.179Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/e4e6aec6874efac185959f4627b6a61a88c0dad3ec92eee433fd395daa78/bitarray-3.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:838fd67b3d00c5a64181073282a2c0bf8f76465da4844d5e79d2dbbc64c987dc", size = 333036, upload-time = "2026-04-02T16:26:39.723Z" }, + { url = "https://files.pythonhosted.org/packages/50/5f/d493eb77f79b58eaa489e9e032aa1c91f6af844287b341c6be681df11b0d/bitarray-3.8.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5743f532e408cfd716fa16776b5a6447b83ff2cf39021fb5f8d052aa0f331508", size = 361247, upload-time = "2026-04-02T16:26:41.023Z" }, + { url = "https://files.pythonhosted.org/packages/24/a3/2e3f33c66f61754b5bb4724d54c9c1122699facc580bcb416d44f1164ffc/bitarray-3.8.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c8c66f5d8055cb84ad0ea14af57b3579cb0b6db589f2086f5e33f0922cf2354", size = 371922, upload-time = "2026-04-02T16:26:42.373Z" }, + { url = "https://files.pythonhosted.org/packages/05/03/4dfca9a69dfa69cde6fdbcfafbc039e069e105ea2443688177f6873d8444/bitarray-3.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3fe25871f1758519a3ad8dcafb1bd95c5d1aaeb122e6492ac739ab11fa5907", size = 339203, upload-time = "2026-04-02T16:26:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/14/5d/a2275da6c935893f275624c88afab6cdd5b6aa916d0b45c50dd400cafb20/bitarray-3.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e9ff57452fcadfd1a379314234657b8f4e9967ae64480ddf7c2fd82139bc8cf8", size = 330956, upload-time = "2026-04-02T16:26:45.675Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/7f4041c7a7e94ef3e7de86fdb4102d3fe366998b507de77ba0fe5dff6c44/bitarray-3.8.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4e34f1cb6cdb036c5f4a839a2b74419f75fa36177a70c4bab2867f48973cbe44", size = 358882, upload-time = "2026-04-02T16:26:47.327Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/2d0c381327c0f5bc49681b799bbe7d80d5e629079f9609a79d39da6e8b8f/bitarray-3.8.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:698c37fca3761af69a09a1d39cc0492f7e8cb9e263af39a288dce8f3b8a9e2bc", size = 355761, upload-time = "2026-04-02T16:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d9/66644d45d9f844d1c78b80f3517c8717ac4b4d9853ec61bd02b3cabc06e6/bitarray-3.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:81ede1f094f26eeaff62e029ff1bc4e84e9d568f20d4669f64dcf7c7b18a28fc", size = 336422, upload-time = "2026-04-02T16:26:49.988Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7d/4ea3fd2424535630d4d236bc0c721621260b39878eed669dbc1deb5c6b22/bitarray-3.8.1-cp311-cp311-win32.whl", hash = "sha256:8a345b5dc8ab8cafdf338e08530d48fe3f73df27f4ff569be793c7a7e7bb6b6b", size = 143391, upload-time = "2026-04-02T16:26:51.69Z" }, + { url = "https://files.pythonhosted.org/packages/d0/4f/46309fcf9e1793c7184e3fc1aa73d7daf2b6a2b0fa1efbcf8d497101690e/bitarray-3.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:ddcd25a1f72b2b545fb27e17882046a6c161f3f24514b2e028c00c58ed73a2dd", size = 150143, upload-time = "2026-04-02T16:26:52.9Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1e/10289fb8e44fdd2d01adcc24d64b5c45ead709fbec76ee973f42e22b3059/bitarray-3.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:dc2cab92c42991b711132bc52405680e075d1505d4356c4468bc6e9c93d49137", size = 147024, upload-time = "2026-04-02T16:26:54.151Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4f/6ab3767b6642a6cbee4353f10a71fe25ade9899d539fae47c3d50686ebe2/bitarray-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4494c599effa16064f2b600f6eb28115182d6826847d795a55691339788d8a4d", size = 149202, upload-time = "2026-04-02T16:26:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/eb/53/22bfffd13dd0a266f90011338b24eec45f25c91d37155bb2aa330351e17d/bitarray-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ff2ca039a161d49a8c713f5380def315c6f793df5fe348b94782b1dbee37a644", size = 145999, upload-time = "2026-04-02T16:26:56.849Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/60aff29c88b648e18248921001cf9d7169abeda4d8db96f2dc1a24ed98ca/bitarray-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df3ffa6ef88166bb36f5d1492e71e664868b9b8b6afd55821e0ac0cb96625441", size = 335945, upload-time = "2026-04-02T16:26:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/225380610a01ae0d8f2f5256e531bae7135b2ade6f4607156424718ec43a/bitarray-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:478b9f0ea86f957624dd2b159066855716f78db94666e9b04babe85fc013e01b", size = 364213, upload-time = "2026-04-02T16:26:59.742Z" }, + { url = "https://files.pythonhosted.org/packages/6c/df/83899be9a74ec5878972e8b636f645ef1771e146c6425a161fdafdd74aaa/bitarray-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e127b2e7fc533728295196f9265d12834530f475bc6cd6f74619df415d04b8b1", size = 375409, upload-time = "2026-04-02T16:27:01.081Z" }, + { url = "https://files.pythonhosted.org/packages/6c/93/38bc15cb097107d220a942eb66dc50882496d7da54f41e5eea6c31b1c443/bitarray-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ef49462a615de062dcac8281944d0b036fe1e9c96a6c690bf6cf5e4b5488f0e", size = 343645, upload-time = "2026-04-02T16:27:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c3/75fae6991946f8bf643ec50233432ea81b5b65bfdb2918b09d7e37605380/bitarray-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4da256fc567a57ded2a4aa962fc9e9d430ab740e5c67be9e98a63ef4eb467f2f", size = 333844, upload-time = "2026-04-02T16:27:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7e/649e7c3bb12ba938c387bcad6a6c0b84312663c9807ec1457888936690d8/bitarray-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b46b7aec9272fd81c984e723e599957629a91204120b3e7f0933f138e0792fdf", size = 361267, upload-time = "2026-04-02T16:27:05.361Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5f/db0fb71a7c6c3ef047b84256157e96fa35e10ed8b79b80e892d354ab37f6/bitarray-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2dc07dab252c63c4f6600e200b26fa05207db6b650d41ae88ab0cec4d6c59459", size = 359373, upload-time = "2026-04-02T16:27:07.106Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b6/a082d84cba7ba509b48d160034f6a2d31df6bf4fff0471801e888bba96c9/bitarray-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29c8c10a49d6a9586f592116618b99c3dabcb24d881b7a649e0691ef87f314c4", size = 340633, upload-time = "2026-04-02T16:27:08.794Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b7/1ba7ec1f3aa62933dfef505b09de0b75778a3cb05984ee8bb798539381db/bitarray-3.8.1-cp312-cp312-win32.whl", hash = "sha256:67125404d12547443d74113862a80c10310cf875aff8dbfc5548fee1d9737123", size = 143521, upload-time = "2026-04-02T16:27:10.423Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/5ff9d30a1121810f336517e51b1cbdea0fa92e92b142efe0741e335dc14e/bitarray-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ba0339d6aa80615a17f47fabc5700485e9469121d658458f95cdd2003288c28b", size = 150451, upload-time = "2026-04-02T16:27:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/a6/08/51e49eb09ca45ecda4a5f05b70a10977a5f0ac39967c79479e9d3e41cb29/bitarray-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:c0b367a00e8c88a714b2384c97dedcc85340547b3a54b6037a42fca5554d0576", size = 147218, upload-time = "2026-04-02T16:27:13.566Z" }, + { url = "https://files.pythonhosted.org/packages/13/79/015a30f40f716a0372907a7ac5c399db5428209dcf264b85ef1305f9b3e2/bitarray-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f4b105a1686eb486069a9e578d502d1998e890d8144012225de9e0450aeabd", size = 149201, upload-time = "2026-04-02T16:27:15.383Z" }, + { url = "https://files.pythonhosted.org/packages/23/fe/f70b150ea9a330daecc546a5a63576ba2d6b3bacc1ccde42abc9dd35a1ad/bitarray-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b3118ec012a799456f7fca6cc002c078590578b7640fbaab52d8ecb9a651f1c1", size = 146001, upload-time = "2026-04-02T16:27:17.041Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/2c637658851ea0408c7375f5f278c0ebb69cbe861f8fcc9477db14ee7fa2/bitarray-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2762db8049b230520358ac742cbc57bceaacebe34e5d25c096f2b4bc3887a3a8", size = 335162, upload-time = "2026-04-02T16:27:18.587Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3c/ae665a0b2d6183cc706c03b683b7f9ad53195731379ab82dfa537e73f70f/bitarray-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b67b869f860eb19055e2560844d8c7d0935245938935bdb764b3e683e2014e2", size = 363031, upload-time = "2026-04-02T16:27:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/7b7c37fbb15209525f0daff1a51a042c035e931ebd526aabb483fdc7a476/bitarray-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a661f3492462e7adf8a054fb7414a22fc8251f1e18b9d8cbcf008d2dc85f012", size = 374623, upload-time = "2026-04-02T16:27:21.468Z" }, + { url = "https://files.pythonhosted.org/packages/96/dd/26a17534742561974e5b2a3448d70fd8d370ed885bd88bbbb36bdd022875/bitarray-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:300e3026d17ae3328320ba78d3165bdb1c43d0dfdbc461a69ebbdc005d9ce0b3", size = 342850, upload-time = "2026-04-02T16:27:22.814Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/97410e88a8b441c1a6e5841c651e483787c3c87f2b98c1d2421aee23790d/bitarray-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ad5a71c1ef4a2e404c2c888db09226c821d9d14eff8813e1da873572f5fbb89d", size = 333109, upload-time = "2026-04-02T16:27:24.271Z" }, + { url = "https://files.pythonhosted.org/packages/64/1a/74a3af2d314ec6a035ae8f139491ace4fc8b3362bfdc86aee652b8f15be5/bitarray-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:78cbda57a2808d994517b53571eaa2d9299359f63aa71cf4bc94210169aad8b1", size = 360334, upload-time = "2026-04-02T16:27:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/d8/86/01ea58ca9795401489f9de662ef9ba759d6712870696a5806441b2c14224/bitarray-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:89c7c125a0913d71ba9cc1fa8e14c7cfe1517b1c1f45416e1f9babcedd3b545d", size = 358674, upload-time = "2026-04-02T16:27:27.597Z" }, + { url = "https://files.pythonhosted.org/packages/68/c9/14587fd3c712047af60a875889ad69926386c3fdbf8061e9baf23d12d997/bitarray-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7875abfd90f2ae3aa22d50f3fa1c93bbae456458cc73d3179b838f07bed1fc10", size = 339689, upload-time = "2026-04-02T16:27:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/5b/82/a8cc5172dba50c90d7cc89d9f5c1cfb99deb77af10b4762eb75ece52e20a/bitarray-3.8.1-cp313-cp313-win32.whl", hash = "sha256:21add0aa968496a2bd8341d85720d09808e22e0adc7dbefc1e0f8f67c4b83f36", size = 143503, upload-time = "2026-04-02T16:27:30.948Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b2/f647dcd098c275a67b89d21c92471180996a797cec11e308b4d1936d170d/bitarray-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:40d1b57012bf9b4fefd25345aaa95aab3ca510cc693f33c2cb02a4b771d8e51a", size = 150441, upload-time = "2026-04-02T16:27:32.642Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/bde39d566f70149c6858c7e61c0a0d902a643a136a56dd37b6135cc59a68/bitarray-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:72b32d8c471930c95d49640ec99f7694f9b040ca1342ff03ed69d3aea90f9339", size = 147209, upload-time = "2026-04-02T16:27:34.289Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/d07ee3ef120a3b3f1db2434c4b955fbf900bb3f878e25a71ee82408e9d91/bitarray-3.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fe989bbed9d6f332c1e24d333936f3fa1375f380cd8028da0b985dcdefa6015a", size = 149181, upload-time = "2026-04-02T16:27:35.608Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bf/43bf76bbf95354e74b80923e8aa7d6cb178e25546eeab0705524ad4d5171/bitarray-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:75e33c9187da271d1dbeb2582ab2df2e441346492098f67559b09173ea4edde4", size = 146020, upload-time = "2026-04-02T16:27:37.279Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/868644e4f61220529ceea0be6dff1c659a7c20dc354f8c5aa367409e6150/bitarray-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd7e3158be382f8f140caccc0dc7742a7553ce4bf2978982abe3054d2cedd705", size = 335102, upload-time = "2026-04-02T16:27:39.065Z" }, + { url = "https://files.pythonhosted.org/packages/f9/6f/0eb0ed1214bb6436e078a44006127685b587b6aeb1600bc2f77bf53e96b9/bitarray-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9fa5620f7f352f9706924c0e2071a212be36421f09ee064b0fd7e1128289fcdb", size = 363405, upload-time = "2026-04-02T16:27:40.596Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/07d6ec5ad40792ff92857ac51cb91c01f855e3edb7b589eb099937420722/bitarray-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:190b20cbffc9cd7f308f7a57d406119c3af3ae197613325fd2d92d99c8882ad6", size = 374225, upload-time = "2026-04-02T16:27:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/46/53/2c5d688ea0f91025d3fdd08e13f9d5195c384953961070ce79719efd18b3/bitarray-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec3d0a6c37a816ea6e3550697c60d90861c9b0f982a98a40b59ac1f7a360bfa9", size = 342742, upload-time = "2026-04-02T16:27:43.706Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8a/25a932f02a8d1ca0c97cae62399f475d219669dbfecca3b2d7567effec73/bitarray-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:746e25f17ba4203b5933773782cf2d30bca5cdb66a9ba5d48a53a6c795aedc57", size = 333236, upload-time = "2026-04-02T16:27:45.237Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a2/83fc66eb64ee0e74dc04894fbe8ae7e2d083c824ae9c1396d68a14c50760/bitarray-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ab363a5baae965fb3438f2137583853ad9c77d7e45f2a62ba63e609a34d792ea", size = 360526, upload-time = "2026-04-02T16:27:46.724Z" }, + { url = "https://files.pythonhosted.org/packages/a1/60/eee517c36956d9fc8d4ae2b2fbcf9122477b0730dacd52a2800226b11e61/bitarray-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5e30d8e399f38ae1ec86aa9be76d20ba15872dd0c41b4b46d1b78905857363b9", size = 358208, upload-time = "2026-04-02T16:27:48.221Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a5/2d35fb2d1abc8afa4fef93f3ad96192eebd81595c3f9389a95f5d01ee782/bitarray-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0f099a4a77daf9bb99787070854894fe588c7d6988ea729f970ba2b3b82c7559", size = 339373, upload-time = "2026-04-02T16:27:49.911Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/64d6d485725076472e0f151643ac4fa78ed54f10f6b7bf9620690a24af7a/bitarray-3.8.1-cp314-cp314-win32.whl", hash = "sha256:539880ddf9a8cc54c9e6126e7d072c991563f0c90ef73b3519a783d53df00352", size = 142632, upload-time = "2026-04-02T16:27:51.371Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/cf02dfac88f4c7d3de2dbafec4ec0616eaf9547dd7be98e81dc0fde97a77/bitarray-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:c08cd5b19c570e1e9e094a6ce70d35bb39d12360e0763474ed9374229f174fcc", size = 149180, upload-time = "2026-04-02T16:27:53.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a8/8e56397347bad7b042aefee9afc0cb085f2a779f7c8cc38954b12671d37c/bitarray-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0da5f17bed67ffe1d72f79fbf98403513a6e51a4f9b8293c1ff8a64e121242be", size = 146389, upload-time = "2026-04-02T16:27:54.759Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a6/52bcd001c5cdf5c381a7317b07157070be1a6bc7fa5d58314ef6da33626b/bitarray-3.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:154a19e1dcd430494fdad7d1a0fb36383baaa363e1cb9d5a7b744cd2418c44d2", size = 150091, upload-time = "2026-04-02T16:27:56.154Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/86f51124a9d0e622be0bd171b797e0d507d5c9d6f76f5b97cb12e4ecf113/bitarray-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:814bb54db2a016026efc055a3527461e5eb551c0d91b32eeade003829ff84311", size = 147130, upload-time = "2026-04-02T16:27:57.916Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/2153c6de23e26d0bfa65e0994ef771f06f8697a9ae65473923f6922ab1b9/bitarray-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac49519fcfeb4a7ecdf6b7d0ec6cac409e59f94c1bb54630db577a97893b6e38", size = 343168, upload-time = "2026-04-02T16:27:59.345Z" }, + { url = "https://files.pythonhosted.org/packages/55/bd/6ff9be5965c11e2f67bec674cd1bbe41e81531ec970251986f4d4978a72d/bitarray-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:329b994944993c45c3845047476ef4f231fe1a53972f18f8d005fd12fac163e1", size = 371961, upload-time = "2026-04-02T16:28:00.956Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/9aca563d253ed28be47b9a8d5f2fe0942e0191bc4ef49589e7670177807c/bitarray-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d7b786a1ddd9b8dda17c445060a94a465cba2e113603ae7bdc5364efc1efd11", size = 381778, upload-time = "2026-04-02T16:28:02.589Z" }, + { url = "https://files.pythonhosted.org/packages/be/e2/81ac4c98694857b7eabdb9ada77db5b44fb6b6d5d19a3a716fb8a486c251/bitarray-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd9b848c17ef034f2ae31b2a1bd9276710c2baf03509f1f3fa4dc4382b0a1b53", size = 348165, upload-time = "2026-04-02T16:28:04.431Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c5/2107bf1474a139f934621703135985f2acfae92d786561edde62ec557f60/bitarray-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a33f8931ac91ebc23ce4decb99ed8fdddba2bafd2af3bb2781bcfd9878d4822", size = 340225, upload-time = "2026-04-02T16:28:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/31/95/a3d2571055279a09373d1f93249404ac37a34045e334935adbc2ce780f83/bitarray-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07626f76a248fce5ebbb10fb0d4899d3c7f908ba21cb2fb4f5a7a9daf24c20cd", size = 369440, upload-time = "2026-04-02T16:28:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/ef/56/7fad5bb52c0b9cdcdea4405f5d9a41f5efbf2873045d668bc2b8db10213b/bitarray-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:18f3a2c8908e63a66d3994808254397a5f989b1fb91087c33739f62bf1a1a064", size = 364733, upload-time = "2026-04-02T16:28:09.086Z" }, + { url = "https://files.pythonhosted.org/packages/10/b0/a23a1c312206c65146021aea68d69c8c7d817ae2f99698cbc23b3c744bba/bitarray-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ced27af6aee28782260bfa5643797937e96a6489bca972202834017208cf74f5", size = 343729, upload-time = "2026-04-02T16:28:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/fe5dbdfadcba2314551614d023db100e3aea7b09da2cdcbf1386f5c797a6/bitarray-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:cf99e36c0f6ae5643ecef7ad7e1194aeb4a9798d9cff60b20ac041533fa6db0a", size = 144160, upload-time = "2026-04-02T16:28:12.702Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0a/e95ed44f6d89e63ed666755fc0773223b10df0058d5de30d529f4cf35948/bitarray-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9befda0dbd27ed95fba1c26be4bf98a49ba166b3c91beb5fc04364c130ce950c", size = 150852, upload-time = "2026-04-02T16:28:14.149Z" }, + { url = "https://files.pythonhosted.org/packages/0f/90/454b88b193743b4cd0fce0819a11b1c43b7f629cb2533f6ddc62cbb5e097/bitarray-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:4b7d7d10a1c82050efbb9a83d7a43974f70cf8f021afb86463b42e4ac4e5a46b", size = 147343, upload-time = "2026-04-02T16:28:15.632Z" }, ] [[package]] @@ -543,6 +549,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, +] + [[package]] name = "htmlmin2" version = "0.1.13" @@ -703,6 +718,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + [[package]] name = "mkdocs-get-deps" version = "0.2.0" @@ -790,6 +819,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" }, ] +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, +] + [[package]] name = "numpy" version = "2.3.5" @@ -1058,36 +1123,44 @@ wheels = [ [[package]] name = "raylib" -version = "5.5.0.3" +version = "5.5.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/77/be23455a3ad6588860daa7cea0c27e762858d7e3a6dc81b5b7fc2bb972a1/raylib-5.5.0.3.tar.gz", hash = "sha256:f7cfabe7400bf334fc953df6ab99c7435cd4b2251c495040a48d22d44544080a", size = 184322, upload-time = "2025-09-03T16:04:22.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/4b/858958762c075c54058ee3b0771838fd505ca908871e6a0397b01086e526/raylib-5.5.0.4.tar.gz", hash = "sha256:996506e8a533cd7a6a3ef6c44ec11f9d6936698f2c394a991af8022be33079a0", size = 184413, upload-time = "2025-12-11T15:32:12.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/f6/2d41282332286fcef2cd782580c5190513a5229c98e0e1d4569c00740d4f/raylib-5.5.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:07e43f2130017da557957dded9fd53abd81d7c0bc4d8f274f2f77027012026f7", size = 1640623, upload-time = "2025-09-03T16:02:36.43Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3a/801c147ef5232ad87184357655330914e99d4a8b4ef3b0cc7c97dca9f935/raylib-5.5.0.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:299a0eb585255ff301c5625a139fceeb051bdd0610adcf573079e28c1b4573b1", size = 1256123, upload-time = "2025-09-03T16:08:15.313Z" }, - { url = "https://files.pythonhosted.org/packages/52/e5/975d330602020eb51f7f3d71e9138886a67bb0f49da490d52324efb5449a/raylib-5.5.0.3-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:02ed5e8aceea781a9b1ef7d2cbef5e972e471ac094da2d9d2ee0d659b6f8a3d3", size = 2187927, upload-time = "2025-09-03T16:08:18.035Z" }, - { url = "https://files.pythonhosted.org/packages/91/ca/f64a6e4f5a8231e80f6378fa495ed5f31cd60f9d09bc24adbc00553fd9b7/raylib-5.5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee9c6d5c1c10fd2a5969f3bbffa959ffa350d2ba4ff09e9aa46abc1bc0e610df", size = 2187545, upload-time = "2025-09-03T16:02:38.7Z" }, - { url = "https://files.pythonhosted.org/packages/36/46/d9957fcb5755aeb8f391db5147b126564f7f0242b1c780014d48cf7b71c8/raylib-5.5.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:995661b7987cac76e4b06f2561d465416c0232210aa3a13e721f018ad8be35df", size = 1705384, upload-time = "2025-09-03T16:02:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/c3/12/8f4f08416de3a01c6c6c4ee9fd29a298a4f0c1de602f00e4add91a4b2bd3/raylib-5.5.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0efb1feac28512fc1097a360ef9b7f17625088bcbeeeb5275913f93358d41241", size = 1644996, upload-time = "2025-09-03T16:02:43.667Z" }, - { url = "https://files.pythonhosted.org/packages/8f/37/37f1a5e8f778f9d6715932a497136ff0bba06e3a304543c82503ad6be2dd/raylib-5.5.0.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:d620275ffaa279e4f6bb88be4fa1a50a5ebc6a94c74e411cf15c7474dfb06027", size = 1257758, upload-time = "2025-09-03T16:08:20.835Z" }, - { url = "https://files.pythonhosted.org/packages/38/9b/f79aaa3a3e043d2661cdea073cedd6ce9961aef59d9a7ad6a913d8b7bf30/raylib-5.5.0.3-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:7b1a7b2f5739407889bc38a68c9bcc91a30591f6d2f0837caad708e4113d9e0f", size = 2198841, upload-time = "2025-09-03T16:08:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5c/ee254f409700dc2d0ffd417f01c4c0bc14a6c50f5fa8ffefa3259e06f5b0/raylib-5.5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:791beba74c173ea856cbefaa9e7c5ad94948b81c532c68eaea32ad225285cff6", size = 2205544, upload-time = "2025-09-03T16:02:45.978Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ed/5932409cb851039df540e3cd888a498b37b43fc7286c080f15af299de877/raylib-5.5.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:cf2f89735591c5f1a983e35ff0018b6a11c32b9a7e088d529e562e81ff52dd3c", size = 1708191, upload-time = "2025-09-03T16:02:48.452Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ee/f3078da7168789840cae2e8af1c5c57519941c9fc561db7ff6def6c9131b/raylib-5.5.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6bc7563fbe0b53aba8d79b9437921bd63e2b1a706434d56ec18c4e7dd59d431", size = 1644917, upload-time = "2025-09-03T16:02:50.509Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5d/1ce67531e6b08c34c5f6c1747bca6f77f539cb22a542d840592c719fdd83/raylib-5.5.0.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:0866d065ae99de7ad90f6fb5511b00cae1dc93aea8d0045bd3564d60774a3a79", size = 1257694, upload-time = "2025-09-03T16:08:26.525Z" }, - { url = "https://files.pythonhosted.org/packages/85/eb/b80df6971cf8c0dcbcb25fd878610dd9392f8dd9be4436f24ce3268e301b/raylib-5.5.0.3-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:30fce3d12ac14647ef88ce3300244b03e9a30cb0c88f5406565b5ff791b9e315", size = 2199253, upload-time = "2025-09-03T16:08:28.864Z" }, - { url = "https://files.pythonhosted.org/packages/2b/5e/c872ab1fb732740b115a40cda48e2e8b72153ef6c06f133c75adcbff4e36/raylib-5.5.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bcdd119fd87618ce3c1f1ae29345061bc2c3d97ec3063d2f575f26586b1e4fd7", size = 2205557, upload-time = "2025-09-03T16:02:52.874Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/39916a71cb323be16074040146a53222d33542002f4f7b970f570a14c7d8/raylib-5.5.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:afdafe2316dd2c2fd734968475a19211420c44353eda3a35b7b688bb38e3a9bf", size = 1708193, upload-time = "2025-09-03T16:02:54.992Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e5/12c210a1247bf0aa206ce6058fc72a687b61df6c543656a16cb9aaacbb20/raylib-5.5.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:176f3d8a2e448ff1a486ac1ac10c831dde488c97c0063ba8226f7c3c1b507127", size = 1645374, upload-time = "2025-09-03T16:02:58.8Z" }, - { url = "https://files.pythonhosted.org/packages/ee/3b/297c0bc2eb551fa7e4aaf62ab5401af19d725bc3f2a3ad8ba7072d230aca/raylib-5.5.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3bd382a6ee56f253b9d1a228612158f3c95417c7e9bdb53c5508b06420967a18", size = 1649665, upload-time = "2025-10-29T14:11:19.093Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3e/b5f41b2666c2955a4ad7f5a68c2f5e32ff6a6430f8bf0cfc914db5b1f6bb/raylib-5.5.0.3-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:0d2cbe2c8b3503836935281205218f17ef2c1769da23c210c3bf3259b02fe20d", size = 2168765, upload-time = "2025-10-29T14:03:42.641Z" }, - { url = "https://files.pythonhosted.org/packages/fc/04/cd00eb6089fd2184f597d641a95a35d4ad0727f4697c4cd080924865f443/raylib-5.5.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3cf42d877279aba309ae2b0f81843f106f91baa04afa66f2902ac28caab28cb9", size = 2201220, upload-time = "2025-10-27T19:08:30.933Z" }, - { url = "https://files.pythonhosted.org/packages/34/48/29d16231b65b1a897c82ecabecc0683b6ee591dcec4ff44a7d469c21978d/raylib-5.5.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:ffe8c02f2c206d5f75bd87a7920a182bbb4cf4957d1ce0fb92900a51cdc58f73", size = 1763072, upload-time = "2025-09-03T16:03:01.465Z" }, - { url = "https://files.pythonhosted.org/packages/8d/47/a6661a5ef715966f740e05b52e5b9d6381de8077b64642315de9d0e4b80c/raylib-5.5.0.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8778876dcc5895d5f360ad95c15231c8d0a0af43baf436c052f2a50cb4fb9788", size = 1218941, upload-time = "2025-09-03T16:03:10.234Z" }, - { url = "https://files.pythonhosted.org/packages/3e/74/8c28931789a4670da3389e270f92b27adef41a9b84504ae0a9d3940cee55/raylib-5.5.0.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9eeedc8e6d7ad94f62ec33b89dd95e6873c4c0ed61173cb12e68938e66e68ae", size = 1433740, upload-time = "2025-09-03T16:03:12.288Z" }, - { url = "https://files.pythonhosted.org/packages/d7/35/dbc962cdb5cff97f111df58fa0a45f4c9af223409defb9e66034ec7aa007/raylib-5.5.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:415f5f0a0105ba32d3e433b7c1be8181bc4eec1a9af7ecead1af133bf9436af0", size = 1572295, upload-time = "2025-09-03T16:03:14.646Z" }, + { url = "https://files.pythonhosted.org/packages/c2/14/98a78b819d7374dab309525ce45cd591d0d62db7f6ed2d5ed32b8f55d62b/raylib-5.5.0.4-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:09717ed32c9ec1c574370e2e2d30e9bc13876f7e2f2dd6e04dc366dae23e0994", size = 1632797, upload-time = "2025-12-11T15:27:15.429Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/ec949f45274cf266875b30b67f8cb7243ecced05080cec54bf65ec73a8b2/raylib-5.5.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cef7b0e238eafc80a3be7e3c656a3ddc94cc523790758b7130df1957ba4ad4ad", size = 1550301, upload-time = "2025-12-11T15:27:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8f60d367147019acef342746f20121b2341ec6596acd5c7941cb36bda02e/raylib-5.5.0.4-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.whl", hash = "sha256:bdaa119b767f380caf6dd4f9d42ab3bf8596d8fb98737d2951b36924a5a83ac0", size = 2036797, upload-time = "2025-12-11T15:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ad/97dd93c389263c61a3057065f0f70db5fdc3c5768fa383a9b3e989ddb6a7/raylib-5.5.0.4-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:6a5cdeeb803d081342961eb1f7c4161af27e951d9ecf2b56d469d5730fcc6213", size = 2188009, upload-time = "2025-12-11T18:50:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/42/6a/55be04012f3459842389689326910204f985cffcb8989a92475221f5660a/raylib-5.5.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4067fa8a6ed3eb78a1162fc2d40ce8c26c26c5ee544019d1902accf21ec22add", size = 2187633, upload-time = "2025-12-11T15:27:22.345Z" }, + { url = "https://files.pythonhosted.org/packages/6b/18/b69d9ad9f4064785ad29c73672d40b36c59c3b3efd1dee264cdff4b48bf6/raylib-5.5.0.4-cp311-cp311-win32.whl", hash = "sha256:f01a769bb0797ab4f6e1efc950d5d8aca53548e97da7f527190a1ca5f671c389", size = 1456775, upload-time = "2025-12-11T15:27:26.776Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7a/4025d9ceeee8e3ae4748b0f6c356c5ce97628bd5da8a056b6782c87f7e65/raylib-5.5.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:34771dea34a30fa4657f35b344d5ebf9eb11d9b62b23d9349742db5c5f3992bd", size = 1705555, upload-time = "2025-12-11T15:27:28.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/9117d7013997a65f6d51c6f56145b2c583eeba8f7c1af71a60776eaae9b9/raylib-5.5.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f64f71e42fed10e8f3629028c9f5700906e0e573b915cfc2244d7a3f3b2ed9", size = 1635486, upload-time = "2025-12-11T15:27:31.05Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/e55039c8f49856c5a194f2b81f27ca6ba2d5900024f09435587e177bfaf2/raylib-5.5.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:80bfa053e765d47a9f58d59e321a999184b5a5190e369dd015c12fcfd08d6217", size = 1554132, upload-time = "2025-12-11T15:27:33.291Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/86bee75ecaa577214da16b374f8de70b45885452703f622c63e06baa0b8e/raylib-5.5.0.4-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.whl", hash = "sha256:033240c61c1a1fc06fecff747a183671431a4ce63a0c8aafec59217845f86888", size = 2039888, upload-time = "2025-12-11T15:27:36.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/00763899bb8a178a927b5dda90aca692c80ff6cec5f51e6fee88db3f45c2/raylib-5.5.0.4-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:ba87ca50c5748cab75de37a991b7f3f836ce500efbb2d737a923a5f464169088", size = 2198926, upload-time = "2025-12-11T18:50:08.813Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e9/0123385e369904335985ebd59157f7a10c89c3a706dffcf6dace863a1fa2/raylib-5.5.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:788830bc371ce067c4930ff46a1b6eca0c9cf27bac88f81b035e4b73cc6bf197", size = 2205629, upload-time = "2025-12-11T15:27:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/c25087b39d2db2d833a52b4056ae62db74e64b4be677f816e0b368e65453/raylib-5.5.0.4-cp312-cp312-win32.whl", hash = "sha256:e09f395035484337776c90e6c9955c5876b988db7e13168dcadb6ed11974f8ee", size = 1457266, upload-time = "2025-12-11T15:27:43.798Z" }, + { url = "https://files.pythonhosted.org/packages/2c/66/a307e61c953ace906ba68ba1174ed8f1e90e68d5fc3e3af9fb7dc46d68d1/raylib-5.5.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:553043a050a31f2ef072f26d3a70373f838a04733f7c5b26a4e9ee3f8caf06ec", size = 1708354, upload-time = "2025-12-11T15:27:45.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5d/8aa9b91f736d7b3fc4086cd8c7e7f172882b7e4b6b6a564b51c63bd80fd2/raylib-5.5.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:24581f487952386926d5ebde7e2fefc29c5fcd9b3721b65494ef535ac984f867", size = 1635537, upload-time = "2025-12-11T15:27:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/7276ff621f9976fce6d25820df86c8aed3435a882830fb319f925249f662/raylib-5.5.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:31234b890dc5b4502cc8d31a5598d587a6d52a34c24c13a89a6f6a7e97bb3666", size = 1553997, upload-time = "2025-12-11T15:27:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/34/9a/66e8a56efbcb5fd185a84a2f9614167208e2dc659d401cf621cdfe8e0ed9/raylib-5.5.0.4-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.whl", hash = "sha256:be5f32d4b0a4c5b771d47e14571ae8a9107327b425b17e22a65a3e22a85d6505", size = 2039880, upload-time = "2025-12-11T15:27:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1b/d8c39c7930212576a59db549a6ec01588e95e01b34641fd6109b3077307b/raylib-5.5.0.4-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:a4c59f50ea4ac41d4a8ba7a85b573dcf2423b7ad276804a000ff4380c3c94ac9", size = 2199336, upload-time = "2025-12-11T18:50:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/26/6e/09dc5130270d9961b2f2d17f20a92a9c144b0ab40646106c2ef6d3dd2e2e/raylib-5.5.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42c14158f48bf926eaacbc98c3742a9f156a3b52380eeb81fabc2525b2525659", size = 2205634, upload-time = "2025-12-11T15:27:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9e/df967eba58041b9cde4690145ff4117bc1d1932b40ab02c1ff12fdb500ec/raylib-5.5.0.4-cp313-cp313-win32.whl", hash = "sha256:70e2b792375db2df628c5fcee40221a0af920ef0460ad3d058196c85327a9c70", size = 1457270, upload-time = "2025-12-11T15:27:59.855Z" }, + { url = "https://files.pythonhosted.org/packages/2c/dd/baa9fbcc36d771b89cf34706754593fedf6ffb27969d2d40907752fbc1ca/raylib-5.5.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:2115a548fcdd72ad5cf2b4a40e5a551b34e7cacb782779a88eaab624efbd00f2", size = 1708358, upload-time = "2025-12-11T15:28:02.295Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8a/dcfd730b31c80eba733a7dbb8a2ac8666fd6787fbfe441a766484d220fcd/raylib-5.5.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d1cce93ad4af5fb297c21f20b39a03aef431f70a44acc1da96dda249c248d5b5", size = 1636380, upload-time = "2025-12-11T15:28:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/1d2cfc16ee69e3b15f49006c188feb47ed3b867a2e4c8a92e44e5490675d/raylib-5.5.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b00d2f4df5d8978781ae806dc709086fdeddbfb4981cc8634bdb0064bc57865a", size = 1554754, upload-time = "2025-12-11T15:28:06.575Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cb/6d4eb57c9c2afff3f458296f7859dd7480e5e982ab3bfbdf5032cd6b304a/raylib-5.5.0.4-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.whl", hash = "sha256:ba4c85d0688266074c31dd956bb87419d2caf86a518e7d3b04a4305b9c9ed4d5", size = 2039886, upload-time = "2025-12-11T15:28:08.93Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/9999b3ce29c8e1d2cd299a2e27b5e9b67c5c24023dcd005f0e20dd47fb53/raylib-5.5.0.4-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:d229d94e35e1575c82e47a0fc16e7c3059a2cd3692f82da116afdcf7f162fc08", size = 2168723, upload-time = "2025-12-11T18:50:13.609Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b4/817944e9cbf8a3f6a8a0d1914fa68a28effb3b455dd57a244684b79bfac7/raylib-5.5.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9784976d71ef1e95ed4e652c410052c22dcc3f59fee752784fdb6e1d702b2e97", size = 2201283, upload-time = "2025-12-11T15:28:11.357Z" }, + { url = "https://files.pythonhosted.org/packages/95/ea/02914524f5a4f774f9965e9865283260b7c0a733da7a6e6c3627287f4ffa/raylib-5.5.0.4-cp314-cp314-win32.whl", hash = "sha256:ca0a19c251cc1f3574e1f9fe33a4b0a6a661479b3d5d593487072485e8b14eee", size = 1495571, upload-time = "2025-12-11T15:28:16.015Z" }, + { url = "https://files.pythonhosted.org/packages/38/7f/542019daf0e13e2f5c178f7f37a76e8493d6a0952332ffd1a55f34738ff1/raylib-5.5.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:cb1453fc68ee2ad5848ee41123ec1aab97d5a553e55aeb1071a332e1c8626a48", size = 1763211, upload-time = "2025-12-11T15:28:18.261Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ba/fee7e6ae0be850f6581d4084ea97825b7895c8866fa8b2df347d408c8293/raylib-5.5.0.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c318357ce721c62a6848b6d84b26574cd77267e5758cfa2dbc01d4deb2a2b0b8", size = 1211520, upload-time = "2025-12-11T15:28:30.266Z" }, + { url = "https://files.pythonhosted.org/packages/80/a0/847066c6d824f535068112ed362d41c499f9a4aca52b82b74d9dfb1bdfc7/raylib-5.5.0.4-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82a0ea2859d04f3b5b441910881ec48789484463856168fa8f35c7165e11d44c", size = 1433828, upload-time = "2025-12-11T15:28:32.204Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/a2cfb01d63246602ce49111f08d8716e1c7c2994efe4e14d87450176393c/raylib-5.5.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:871e77547cd3f78d98a47bef491821cd25c879b3b3b79f1973d8fb3f8841cdfb", size = 1572456, upload-time = "2025-12-11T15:28:34.333Z" }, ] [[package]]