Source code for wijjit.core.overlay

"""Overlay and Z-index management for Wijjit.

This module provides a layered overlay system for managing modals, context menus,
dropdowns, tooltips, and other UI elements that need to appear above the base UI.

Design Philosophy
-----------------
- Layer-based categories (Modal, Dropdown, Tooltip) for structure
- Auto-incrementing z-index within each layer for simplicity
- Event routing from top to bottom (highest z-index first)
- Focus management with automatic trapping/restoration
- Clean separation from base layout system
"""

from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import TYPE_CHECKING, Any

from wijjit.elements.menu import ContextMenu, DropdownMenu
from wijjit.terminal.size import get_terminal_size

if TYPE_CHECKING:
    from wijjit.core.app import Wijjit
    from wijjit.elements.base import Element

from wijjit.layout.bounds import Bounds


[docs] class LayerType(IntEnum): """Layer types for z-index management. Each layer has a base z-index, and elements within that layer get auto-incremented z-indexes to handle stacking. Attributes ---------- BASE : int Normal UI (not an overlay) - z-index 0 MODAL : int Modal dialogs - z-index base 100 DROPDOWN : int Dropdowns, context menus - z-index base 200 TOOLTIP : int Tooltips, popovers (highest) - z-index base 300 """ BASE = 0 MODAL = 100 DROPDOWN = 200 TOOLTIP = 300
[docs] @dataclass class Overlay: """Represents a UI overlay (modal, dropdown, tooltip, etc.). Parameters ---------- element : Element The element to render as an overlay layer_type : LayerType Which layer this overlay belongs to z_index : int Computed z-index (layer_base + stack_position) close_on_click_outside : bool Whether clicking outside closes this overlay close_on_escape : bool Whether ESC key closes this overlay trap_focus : bool Whether to trap focus within this overlay on_close : Callable or None Callback when overlay is closed dimmed_background : bool Whether to dim the background behind this overlay previous_focus : Element or None Element that had focus before this overlay opened previous_focus_state : tuple or None Saved focus manager state (elements list, current index) """ element: "Element" layer_type: LayerType z_index: int close_on_click_outside: bool = True close_on_escape: bool = True trap_focus: bool = False on_close: Callable[[], None] | None = None dimmed_background: bool = False previous_focus: "Element | None" = None previous_focus_state: tuple[Any, ...] | None = None
[docs] class OverlayManager: """Manages overlay rendering, z-indexing, and event routing. The OverlayManager maintains a stack of overlays and handles: - Automatic z-index assignment - Event routing (highest z-index first) - Focus management and trapping - Rendering order - Background dimming Examples -------- Basic modal usage:: modal = Modal(title="Confirm", content="Delete file?") overlay = app.overlay_manager.push( modal, LayerType.MODAL, trap_focus=True, dimmed_background=True ) Context menu usage:: menu = ContextMenu(items=[...]) overlay = app.overlay_manager.push( menu, LayerType.DROPDOWN, close_on_click_outside=True ) Tooltip usage:: tooltip = Tooltip(text="Help text") overlay = app.overlay_manager.push( tooltip, LayerType.TOOLTIP, close_on_click_outside=False, close_on_escape=False ) """
[docs] def __init__(self, app: "Wijjit") -> None: """Initialize the overlay manager. Parameters ---------- app : Wijjit Reference to the main app for focus/event management """ self.app = app self.overlays: list[Overlay] = [] # Track next available z-index for each layer # This auto-increments to handle stacking within a layer self._next_z_index: dict[LayerType, int] = { LayerType.MODAL: LayerType.MODAL.value, LayerType.DROPDOWN: LayerType.DROPDOWN.value, LayerType.TOOLTIP: LayerType.TOOLTIP.value, }
[docs] def push( self, element: "Element", layer_type: LayerType = LayerType.MODAL, close_on_click_outside: bool = True, close_on_escape: bool = True, trap_focus: bool = False, dimmed_background: bool = False, on_close: Callable[[], None] | None = None, ) -> Overlay: """Add an overlay to the stack. Parameters ---------- element : Element The element to display as an overlay layer_type : LayerType Which layer to place this overlay in (default: MODAL) close_on_click_outside : bool Close when clicking outside the overlay (default: True) close_on_escape : bool Close when pressing ESC (default: True) trap_focus : bool Trap focus within this overlay (default: False) dimmed_background : bool Dim the background behind this overlay (default: False) on_close : Callable or None Callback to invoke when overlay closes Returns ------- Overlay The created overlay object """ # Assign z-index z_index = self._next_z_index[layer_type] self._next_z_index[layer_type] += 1 # Save current focus state if we're trapping focus previous_focus = None previous_focus_state = None if trap_focus and hasattr(self.app, "focus_manager"): previous_focus = self.app.focus_manager.get_focused_element() # Save the complete focus manager state previous_focus_state = self.app.focus_manager.save_state() # Create overlay overlay = Overlay( element=element, layer_type=layer_type, z_index=z_index, close_on_click_outside=close_on_click_outside, close_on_escape=close_on_escape, trap_focus=trap_focus, on_close=on_close, dimmed_background=dimmed_background, previous_focus=previous_focus, previous_focus_state=previous_focus_state, ) # Auto-calculate bounds for centered overlays that don't have bounds if element.bounds is None: self.center_element(element) # Auto-position menus (dropdown and context menus) if element.bounds is None: if isinstance(element, (DropdownMenu, ContextMenu)): element.bounds = self._calculate_menu_position(element) # Add to stack (maintain sorted order by z-index) self.overlays.append(overlay) self.overlays.sort(key=lambda o: o.z_index) # If trapping focus, update focus manager with overlay's focusable elements if trap_focus and hasattr(self.app, "focus_manager"): # Collect focusable elements from the overlay focusable_elements = self._collect_focusable(element) if focusable_elements: # Update focus manager with only overlay elements self.app.focus_manager.set_elements(focusable_elements) # Focus the first focusable element in the overlay self.app.focus_manager.focus_first() # Trigger re-render if hasattr(self.app, "needs_render"): self.app.needs_render = True return overlay
[docs] def center_element(self, element: "Element") -> bool: """Center a ``centered`` overlay element on the current terminal. Computes bounds from the element's ``width``/``height`` attributes and the live terminal size and assigns them to ``element.bounds``. Parameters ---------- element : Element The overlay element to place. Returns ------- bool ``True`` if the element opts into centering (``element.centered``) and was placed, ``False`` otherwise (bounds left untouched). Notes ----- Shared by :meth:`add_overlay` (initial placement) and the per-render template-overlay sync in ``Wijjit._process_template_overlays``. A template rebuilds each overlay element on every render with ``bounds=None``; without re-centering on the update path a centered modal loses its bounds on the first re-render and silently drops out of compositing (review item 2.13). """ if not getattr(element, "centered", False): return False term_size = get_terminal_size() elem_width = getattr(element, "width", 50) elem_height = getattr(element, "height", 10) x = max(0, (term_size.columns - elem_width) // 2) y = max(0, (term_size.lines - elem_height) // 2) element.bounds = Bounds(x=x, y=y, width=elem_width, height=elem_height) return True
[docs] def pop(self, overlay: Overlay | None = None) -> Overlay | None: """Remove an overlay from the stack. Parameters ---------- overlay : Overlay or None Specific overlay to remove, or None to remove the topmost Returns ------- Overlay or None The removed overlay, or None if stack was empty """ if not self.overlays: return None # Remove specific overlay or topmost if overlay: if overlay in self.overlays: self.overlays.remove(overlay) else: return None else: overlay = self.overlays.pop() # Mark the overlay's screen area as dirty to ensure it gets redrawn # This prevents ghost remnants when the overlay is dismissed if ( overlay.element.bounds and hasattr(self.app, "renderer") and hasattr(self.app.renderer, "dirty_manager") ): self.app.renderer.dirty_manager.mark_dirty_bounds(overlay.element.bounds) # Restore focus state if this overlay was trapping it if overlay.trap_focus and hasattr(self.app, "focus_manager"): if overlay.previous_focus_state: # Restore the complete focus manager state self.app.focus_manager.restore_state(overlay.previous_focus_state) elif overlay.previous_focus: # Fallback: just focus the previous element self.app.focus_manager.focus_element(overlay.previous_focus) # Call close callback if overlay.on_close: overlay.on_close() # Trigger re-render if hasattr(self.app, "needs_render"): self.app.needs_render = True return overlay
[docs] def pop_layer(self, layer_type: LayerType) -> list[Overlay]: """Remove all overlays in a specific layer. Useful for closing all dropdowns or all tooltips at once. Parameters ---------- layer_type : LayerType Layer to clear Returns ------- List[Overlay] List of removed overlays """ removed = [] for overlay in list(self.overlays): if overlay.layer_type == layer_type: self.pop(overlay) removed.append(overlay) # Reset z-index if this layer is now empty if not any(o.layer_type == layer_type for o in self.overlays): self._next_z_index[layer_type] = layer_type.value return removed
[docs] def clear(self) -> list[Overlay]: """Remove all overlays. Returns ------- List[Overlay] List of all removed overlays """ removed = list(self.overlays) for overlay in removed: self.pop(overlay) # Reset all z-index counters self._next_z_index = { LayerType.MODAL: LayerType.MODAL.value, LayerType.DROPDOWN: LayerType.DROPDOWN.value, LayerType.TOOLTIP: LayerType.TOOLTIP.value, } return removed
[docs] def get_at_position(self, x: int, y: int) -> Overlay | None: """Get the topmost overlay at the given position. Used for event routing - returns the highest z-index overlay that contains the specified point. Parameters ---------- x : int X coordinate y : int Y coordinate Returns ------- Overlay or None Topmost overlay at position, or None """ # Iterate from highest to lowest z-index for overlay in reversed(self.overlays): if overlay.element.bounds: if overlay.element.bounds.contains(x, y): return overlay return None
[docs] def get_top_overlay(self) -> Overlay | None: """Get the overlay with highest z-index. Returns ------- Overlay or None Topmost overlay, or None if stack is empty """ return self.overlays[-1] if self.overlays else None
[docs] def handle_click_outside(self, x: int, y: int) -> bool: """Handle a click outside any overlay. Closes overlays that have close_on_click_outside=True, starting from the topmost overlay. Parameters ---------- x : int Click X coordinate y : int Click Y coordinate Returns ------- bool True if any overlay was closed """ closed_any = False # Check from top to bottom for overlay in reversed(list(self.overlays)): # If click is inside this overlay, stop checking if overlay.element.bounds and overlay.element.bounds.contains(x, y): break # If click is outside and overlay should close, close it if overlay.close_on_click_outside: self.pop(overlay) closed_any = True # Continue to check lower overlays return closed_any
[docs] def handle_escape(self) -> bool: """Handle ESC key press. Closes the topmost overlay that has close_on_escape=True. Returns ------- bool True if an overlay was closed """ for overlay in reversed(list(self.overlays)): if overlay.close_on_escape: self.pop(overlay) return True return False
[docs] def should_trap_focus(self) -> bool: """Check if focus should be trapped in an overlay. Returns ------- bool True if the topmost overlay is trapping focus """ top = self.get_top_overlay() return top.trap_focus if top else False
[docs] def get_focus_trap_elements(self) -> list["Element"]: """Get focusable elements in the focus-trapping overlay. Returns ------- List[Element] Focusable elements in the topmost focus-trapping overlay, or empty list if no overlay is trapping focus """ top = self.get_top_overlay() if not top or not top.trap_focus: return [] # Recursively collect focusable elements from overlay element return self._collect_focusable(top.element)
def _collect_focusable(self, element: "Element") -> list["Element"]: """Recursively collect focusable elements. Parameters ---------- element : Element Root element to search Returns ------- List[Element] All focusable descendant elements """ focusable = [] if hasattr(element, "focusable") and element.focusable: focusable.append(element) # Check if element is a container with children if hasattr(element, "children"): for child in element.children: focusable.extend(self._collect_focusable(child)) return focusable
[docs] def get_overlay_elements(self) -> list["Element"]: """Get all overlay elements in z-order. Returns ------- List[Element] All overlay elements sorted by z-index (lowest to highest) """ return [overlay.element for overlay in self.overlays]
[docs] def has_dimmed_overlay(self) -> bool: """Check if any overlay requests background dimming. Returns ------- bool True if at least one visible overlay has dimmed_background=True """ return any(overlay.dimmed_background for overlay in self.overlays)
[docs] def recalculate_overlay_positions(self, term_width: int, term_height: int) -> None: """Recalculate positions for all overlays after terminal resize. This method: 1. Repositions centered overlays to remain centered 2. Clamps all overlays within terminal bounds to prevent off-screen positioning Parameters ---------- term_width : int New terminal width in columns term_height : int New terminal height in lines """ for overlay in self.overlays: element = overlay.element if not element.bounds: continue # Get element dimensions elem_width = getattr(element, "width", element.bounds.width) elem_height = getattr(element, "height", element.bounds.height) # Recalculate for centered elements if hasattr(element, "centered") and element.centered: # Center on screen x = max(0, (term_width - elem_width) // 2) y = max(0, (term_height - elem_height) // 2) # Update bounds element.bounds = Bounds(x=x, y=y, width=elem_width, height=elem_height) else: # For non-centered overlays, clamp to terminal bounds x = element.bounds.x y = element.bounds.y # Clamp to ensure overlay stays within terminal bounds # First, ensure the overlay isn't wider/taller than the terminal elem_width = min(elem_width, term_width) elem_height = min(elem_height, term_height) # Then clamp position so overlay is fully visible x = max(0, min(x, term_width - elem_width)) y = max(0, min(y, term_height - elem_height)) # Only update if position changed if ( x != element.bounds.x or y != element.bounds.y or elem_width != element.bounds.width or elem_height != element.bounds.height ): element.bounds = Bounds( x=x, y=y, width=elem_width, height=elem_height )
# Backwards compatibility alias
[docs] def recalculate_centered_overlays(self, term_width: int, term_height: int) -> None: """Alias for recalculate_overlay_positions for backwards compatibility.""" self.recalculate_overlay_positions(term_width, term_height)
def _calculate_menu_position(self, menu_element: "Element") -> Bounds: """Calculate position for a menu element (dropdown or context menu). This method auto-positions menus relative to their trigger element or mouse cursor, ensuring they stay within screen bounds. Parameters ---------- menu_element : Element The menu element to position (DropdownMenu or ContextMenu) Returns ------- Bounds Calculated bounds for the menu Notes ----- Dropdown positioning: - Default: Below trigger element - If off-screen bottom: Flip above trigger - If off-screen right: Shift left - If off-screen left: Shift right Context menu positioning: - Default: At mouse cursor - Adjust to stay fully on-screen """ from wijjit.elements.menu import ContextMenu, DropdownMenu from wijjit.layout.bounds import Bounds term_size = get_terminal_size() term_width = term_size.columns term_height = term_size.lines # Get menu dimensions # Note: menu_element.width is the INNER width (content area) # The actual rendered width includes 2 chars for borders (left + right) inner_width = getattr(menu_element, "width", 30) menu_width = inner_width + 2 # Add borders menu_height = getattr(menu_element, "height", 10) if isinstance(menu_element, DropdownMenu): # Dropdown menu - position relative to trigger trigger_bounds = menu_element.trigger_bounds if trigger_bounds: # Position below trigger by default x = trigger_bounds.x y = trigger_bounds.y + trigger_bounds.height # Check if menu would go off bottom of screen if y + menu_height > term_height: # Flip above trigger y = trigger_bounds.y - menu_height # Still off-screen? Clamp to top if y < 0: y = 0 # Check if menu would go off right of screen if x + menu_width > term_width: # Shift left to fit x = term_width - menu_width # Check if menu would go off left of screen if x < 0: x = 0 else: # No trigger bounds, center on screen as fallback x = max(0, (term_width - menu_width) // 2) y = max(0, (term_height - menu_height) // 2) elif isinstance(menu_element, ContextMenu): # Context menu - position at mouse cursor mouse_pos = menu_element.mouse_position if mouse_pos: x, y = mouse_pos # Check if menu would go off bottom of screen if y + menu_height > term_height: # Shift up to fit y = term_height - menu_height # Check if menu would go off right of screen if x + menu_width > term_width: # Shift left to fit x = term_width - menu_width # Clamp to screen bounds x = max(0, min(x, term_width - menu_width)) y = max(0, min(y, term_height - menu_height)) else: # No mouse position, center on screen as fallback x = max(0, (term_width - menu_width) // 2) y = max(0, (term_height - menu_height) // 2) else: # Unknown menu type, center on screen x = max(0, (term_width - menu_width) // 2) y = max(0, (term_height - menu_height) // 2) return Bounds(x=x, y=y, width=menu_width, height=menu_height)