This commit is contained in:
@@ -3,3 +3,4 @@ from .page import Container, Page
|
||||
from .text import Text, Line
|
||||
from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField
|
||||
from .image import RenderableImage
|
||||
from .viewport import Viewport, ScrollablePageContent
|
||||
|
||||
@@ -542,7 +542,7 @@ class Page(Container):
|
||||
break
|
||||
|
||||
# Add the line if it has any words
|
||||
if len(line.renderable_words) > 0:
|
||||
if len(line._text_objects) > 0:
|
||||
lines.append(line)
|
||||
line_y_offset += line_height
|
||||
else:
|
||||
|
||||
@@ -144,11 +144,11 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
||||
|
||||
if num_spaces > 0:
|
||||
projected_spacing = available_space // num_spaces
|
||||
# Be more conservative about hyphenation - only suggest it if spacing would be very large
|
||||
# Use a higher threshold to avoid unnecessary hyphenation
|
||||
max_acceptable_spacing = spacing * 3 # Allow up to 3x normal spacing before hyphenating
|
||||
# Also ensure we have a minimum threshold to avoid hyphenating for tiny improvements
|
||||
min_threshold_for_hyphenation = spacing + 10 # At least 10 pixels above min spacing
|
||||
# Be much more conservative about hyphenation - only suggest it if spacing would be extremely large
|
||||
# Increase the threshold significantly to avoid mid-sentence hyphenation
|
||||
max_acceptable_spacing = spacing * 5 # Allow up to 5x normal spacing before hyphenating
|
||||
# Increase minimum threshold to make hyphenation much less likely
|
||||
min_threshold_for_hyphenation = spacing + 20 # At least 20 pixels above min spacing
|
||||
return projected_spacing > max(max_acceptable_spacing, min_threshold_for_hyphenation)
|
||||
|
||||
return False
|
||||
@@ -402,6 +402,54 @@ class Line(Box):
|
||||
"""Set the next line in sequence"""
|
||||
self._next = line
|
||||
|
||||
def _calculate_available_width(self, font: Font) -> int:
|
||||
"""Calculate available width for adding a word."""
|
||||
min_spacing = self._spacing[0]
|
||||
spacing_needed = min_spacing if self._text_objects else 0
|
||||
safety_margin = self._get_safety_margin(font)
|
||||
return int(self._size[0] - self._current_width - spacing_needed - safety_margin)
|
||||
|
||||
def _get_safety_margin(self, font: Font) -> int:
|
||||
"""Calculate safety margin to prevent text cropping."""
|
||||
return max(1, int(font.font_size * 0.05)) # 5% of font size
|
||||
|
||||
def _fits_with_normal_spacing(self, word_width: int, available_width: int, font: Font) -> bool:
|
||||
"""Check if word fits with normal spacing."""
|
||||
if word_width > available_width:
|
||||
return False
|
||||
|
||||
# Check if alignment handler suggests hyphenation anyway
|
||||
should_hyphenate = self._alignment_handler.should_try_hyphenation(
|
||||
self._text_objects, word_width, available_width, self._spacing[0], font)
|
||||
return not should_hyphenate
|
||||
|
||||
def _add_word_with_normal_spacing(self, text: str, font: Font, word_width: int) -> None:
|
||||
"""Add word to line with normal spacing."""
|
||||
spacing_needed = self._spacing[0] if self._text_objects else 0
|
||||
|
||||
text_obj = Text(text, font)
|
||||
text_obj.add_to_line(self)
|
||||
self._text_objects.append(text_obj)
|
||||
|
||||
self._current_width += spacing_needed + word_width
|
||||
return None
|
||||
|
||||
def _try_hyphenation(self, text: str, font: Font, available_width: int) -> Union[str, None]:
|
||||
"""Try hyphenation to fit part of the word."""
|
||||
spacing_needed = self._spacing[0] if self._text_objects else 0
|
||||
safety_margin = self._get_safety_margin(font)
|
||||
return self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin)
|
||||
|
||||
def _handle_word_overflow(self, text: str, font: Font, available_width: int) -> str:
|
||||
"""Handle case where word doesn't fit."""
|
||||
if self._text_objects:
|
||||
# Line already has words, move this word to the next line
|
||||
return text
|
||||
else:
|
||||
# Empty line with word that's too long - force fit as last resort
|
||||
safety_margin = self._get_safety_margin(font)
|
||||
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
||||
|
||||
def _try_reduced_spacing_fit(self, text: str, font: Font, word_width: int, safety_margin: int) -> Union[None, str]:
|
||||
"""
|
||||
Try to fit the word by reducing spacing between existing words.
|
||||
@@ -490,14 +538,7 @@ class Line(Box):
|
||||
|
||||
def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]:
|
||||
"""
|
||||
Add a word to this line as a Text object using intelligent word fitting strategies.
|
||||
|
||||
This method implements a comprehensive word fitting algorithm that:
|
||||
1. First tries to fit the word with normal spacing
|
||||
2. If that fails, tries reducing spacing to minimize gaps
|
||||
3. Uses hyphenation when beneficial for spacing quality
|
||||
4. Falls back to moving the word to the next line
|
||||
5. As a last resort, force-fits long words
|
||||
Add a word to this line using intelligent word fitting strategies.
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
@@ -509,63 +550,26 @@ class Line(Box):
|
||||
if not font:
|
||||
font = self._font
|
||||
|
||||
# Create a Text object to measure the word
|
||||
text_obj = Text(text, font)
|
||||
word_width = text_obj.width
|
||||
available_width = self._calculate_available_width(font)
|
||||
word_width = Text(text, font).width
|
||||
|
||||
# If this is the first word, no spacing is needed
|
||||
min_spacing, max_spacing = self._spacing
|
||||
spacing_needed = min_spacing if self._text_objects else 0
|
||||
# Strategy 1: Try normal spacing first
|
||||
if self._fits_with_normal_spacing(word_width, available_width, font):
|
||||
return self._add_word_with_normal_spacing(text, font, word_width)
|
||||
|
||||
# Add a small margin to prevent edge cases where words appear to fit but get cropped
|
||||
safety_margin = max(1, int(font.font_size * 0.05)) # 5% of font size as safety margin
|
||||
|
||||
# Check if word fits in the line with safety margin
|
||||
available_width = self._size[0] - self._current_width - spacing_needed - safety_margin
|
||||
|
||||
# Strategy 1: Try to fit with normal spacing
|
||||
if word_width <= available_width:
|
||||
# Check if alignment handler suggests hyphenation for better spacing quality
|
||||
should_hyphenate = self._alignment_handler.should_try_hyphenation(
|
||||
self._text_objects, word_width, available_width, min_spacing, font)
|
||||
|
||||
if not should_hyphenate:
|
||||
# Word fits with normal spacing and no hyphenation needed - add it
|
||||
text_obj.add_to_line(self)
|
||||
self._text_objects.append(text_obj)
|
||||
self._current_width += spacing_needed + word_width
|
||||
return None
|
||||
else:
|
||||
# Word fits but hyphenation might improve spacing - try it
|
||||
hyphen_result = self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin)
|
||||
if hyphen_result is None:
|
||||
# Hyphenation worked and improved spacing
|
||||
return None
|
||||
# If hyphenation didn't work or didn't improve things, fall through to add the whole word
|
||||
text_obj.add_to_line(self)
|
||||
self._text_objects.append(text_obj)
|
||||
self._current_width += spacing_needed + word_width
|
||||
# Strategy 2: Try reduced spacing
|
||||
if self._text_objects:
|
||||
result = self._try_reduced_spacing_fit(text, font, word_width, self._get_safety_margin(font))
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
# Strategy 2: Try reducing spacing to maximize fit
|
||||
if self._text_objects and word_width > available_width:
|
||||
reduced_spacing_result = self._try_reduced_spacing_fit(text, font, word_width, safety_margin)
|
||||
if reduced_spacing_result is None:
|
||||
# Word fitted by reducing spacing
|
||||
return None
|
||||
|
||||
# Strategy 3: Try hyphenation to fit part of the word
|
||||
hyphen_result = self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin)
|
||||
if hyphen_result != text: # Some progress was made with hyphenation
|
||||
# Strategy 3: Try hyphenation
|
||||
hyphen_result = self._try_hyphenation(text, font, available_width)
|
||||
if hyphen_result != text:
|
||||
return hyphen_result
|
||||
|
||||
# Strategy 4: Word doesn't fit and no hyphenation helped
|
||||
if self._text_objects:
|
||||
# Line already has words, move this word to the next line
|
||||
return text
|
||||
else:
|
||||
# Empty line with word that's too long - force fit as last resort
|
||||
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
||||
# Strategy 4: Handle overflow
|
||||
return self._handle_word_overflow(text, font, available_width)
|
||||
|
||||
def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int,
|
||||
spacing_needed: int, safety_margin: int) -> Union[None, str]:
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable
|
||||
from .box import Box
|
||||
from .page import Container
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class Viewport(Box, Layoutable):
|
||||
"""
|
||||
A viewport that provides a movable window into a larger content area.
|
||||
|
||||
This class allows you to have a large layout containing many elements,
|
||||
but only render the portion that's currently visible in the viewport.
|
||||
This enables efficient scrolling and memory usage for large documents.
|
||||
"""
|
||||
|
||||
def __init__(self, viewport_size: Tuple[int, int], content_size: Optional[Tuple[int, int]] = None,
|
||||
origin=(0, 0), callback=None, sheet=None, mode='RGBA',
|
||||
background_color=(255, 255, 255)):
|
||||
"""
|
||||
Initialize a viewport.
|
||||
|
||||
Args:
|
||||
viewport_size: The size of the visible viewport window (width, height)
|
||||
content_size: The total size of the content area (None for auto-sizing)
|
||||
origin: The origin of the viewport in its parent container
|
||||
callback: Optional callback function
|
||||
sheet: Optional image sheet
|
||||
mode: Image mode
|
||||
background_color: Background color for the viewport
|
||||
"""
|
||||
super().__init__(origin, viewport_size, callback, sheet, mode)
|
||||
|
||||
self._viewport_size = np.array(viewport_size)
|
||||
self._content_size = np.array(content_size) if content_size else None
|
||||
self._background_color = background_color
|
||||
|
||||
# Viewport position within the content (scroll offset)
|
||||
self._viewport_offset = np.array([0, 0])
|
||||
|
||||
# Content container that holds all the actual content
|
||||
self._content_container = Container(
|
||||
origin=(0, 0),
|
||||
size=content_size or viewport_size,
|
||||
direction='vertical',
|
||||
spacing=0,
|
||||
padding=(0, 0, 0, 0)
|
||||
)
|
||||
|
||||
# Cached content bounds for optimization
|
||||
self._content_bounds_cache = None
|
||||
self._cache_dirty = True
|
||||
|
||||
@property
|
||||
def viewport_size(self) -> Tuple[int, int]:
|
||||
"""Get the viewport size"""
|
||||
return tuple(self._viewport_size)
|
||||
|
||||
@property
|
||||
def content_size(self) -> Tuple[int, int]:
|
||||
"""Get the total content size"""
|
||||
if self._content_size is not None:
|
||||
return tuple(self._content_size)
|
||||
else:
|
||||
# Auto-calculate from content
|
||||
self._update_content_size()
|
||||
return tuple(self._content_size)
|
||||
|
||||
@property
|
||||
def viewport_offset(self) -> Tuple[int, int]:
|
||||
"""Get the current viewport offset (scroll position)"""
|
||||
return tuple(self._viewport_offset)
|
||||
|
||||
@property
|
||||
def max_scroll_x(self) -> int:
|
||||
"""Get the maximum horizontal scroll position"""
|
||||
content_w, content_h = self.content_size
|
||||
viewport_w, viewport_h = self.viewport_size
|
||||
return max(0, content_w - viewport_w)
|
||||
|
||||
@property
|
||||
def max_scroll_y(self) -> int:
|
||||
"""Get the maximum vertical scroll position"""
|
||||
content_w, content_h = self.content_size
|
||||
viewport_w, viewport_h = self.viewport_size
|
||||
return max(0, content_h - viewport_h)
|
||||
|
||||
def add_content(self, renderable: Renderable) -> 'Viewport':
|
||||
"""Add content to the viewport's content area"""
|
||||
self._content_container.add_child(renderable)
|
||||
self._cache_dirty = True
|
||||
return self
|
||||
|
||||
def clear_content(self) -> 'Viewport':
|
||||
"""Clear all content from the viewport"""
|
||||
self._content_container._children.clear()
|
||||
self._cache_dirty = True
|
||||
return self
|
||||
|
||||
def set_content_size(self, size: Tuple[int, int]) -> 'Viewport':
|
||||
"""Set the total content size explicitly"""
|
||||
self._content_size = np.array(size)
|
||||
self._content_container._size = self._content_size
|
||||
self._cache_dirty = True
|
||||
return self
|
||||
|
||||
def _update_content_size(self):
|
||||
"""Auto-calculate content size from children"""
|
||||
if not self._content_container._children:
|
||||
self._content_size = self._viewport_size.copy()
|
||||
return
|
||||
|
||||
# Layout children to get their positions
|
||||
self._content_container.layout()
|
||||
|
||||
# Find the bounds of all children
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
|
||||
for child in self._content_container._children:
|
||||
if hasattr(child, '_origin') and hasattr(child, '_size'):
|
||||
child_origin = np.array(child._origin)
|
||||
child_size = np.array(child._size)
|
||||
child_end = child_origin + child_size
|
||||
|
||||
max_x = max(max_x, child_end[0])
|
||||
max_y = max(max_y, child_end[1])
|
||||
|
||||
# Ensure content size is at least as large as viewport
|
||||
self._content_size = np.array([
|
||||
max(max_x, self._viewport_size[0]),
|
||||
max(max_y, self._viewport_size[1])
|
||||
])
|
||||
|
||||
self._content_container._size = self._content_size
|
||||
|
||||
def _get_content_bounds(self) -> List[Tuple]:
|
||||
"""Get bounds of all content elements for efficient intersection testing"""
|
||||
if not self._cache_dirty and self._content_bounds_cache is not None:
|
||||
return self._content_bounds_cache
|
||||
|
||||
bounds = []
|
||||
self._collect_element_bounds(self._content_container, np.array([0, 0]), bounds)
|
||||
|
||||
self._content_bounds_cache = bounds
|
||||
self._cache_dirty = False
|
||||
return bounds
|
||||
|
||||
def _collect_element_bounds(self, container, offset: np.ndarray, bounds: List[Tuple]):
|
||||
"""Recursively collect bounds of all renderable elements"""
|
||||
if not hasattr(container, '_children'):
|
||||
return
|
||||
|
||||
for child in container._children:
|
||||
if hasattr(child, '_origin') and hasattr(child, '_size'):
|
||||
child_origin = np.array(child._origin)
|
||||
child_size = np.array(child._size)
|
||||
|
||||
# Calculate absolute position
|
||||
abs_origin = offset + child_origin
|
||||
abs_end = abs_origin + child_size
|
||||
|
||||
# Store bounds info
|
||||
bounds.append((child, abs_origin, abs_end, child_size))
|
||||
|
||||
# Recursively process children
|
||||
if hasattr(child, '_children'):
|
||||
self._collect_element_bounds(child, abs_origin, bounds)
|
||||
|
||||
def scroll_to(self, x: int, y: int) -> 'Viewport':
|
||||
"""
|
||||
Scroll the viewport to a specific position.
|
||||
|
||||
Args:
|
||||
x: Horizontal scroll position
|
||||
y: Vertical scroll position
|
||||
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
# Clamp scroll position to valid range
|
||||
max_x = self.max_scroll_x
|
||||
max_y = self.max_scroll_y
|
||||
|
||||
self._viewport_offset[0] = max(0, min(x, max_x))
|
||||
self._viewport_offset[1] = max(0, min(y, max_y))
|
||||
|
||||
return self
|
||||
|
||||
def scroll_by(self, dx: int, dy: int) -> 'Viewport':
|
||||
"""
|
||||
Scroll the viewport by a relative amount.
|
||||
|
||||
Args:
|
||||
dx: Horizontal scroll delta
|
||||
dy: Vertical scroll delta
|
||||
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
current_x, current_y = self.viewport_offset
|
||||
return self.scroll_to(current_x + dx, current_y + dy)
|
||||
|
||||
def scroll_to_top(self) -> 'Viewport':
|
||||
"""Scroll to the top of the content"""
|
||||
return self.scroll_to(self._viewport_offset[0], 0)
|
||||
|
||||
def scroll_to_bottom(self) -> 'Viewport':
|
||||
"""Scroll to the bottom of the content"""
|
||||
return self.scroll_to(self._viewport_offset[0], self.max_scroll_y)
|
||||
|
||||
def scroll_page_up(self) -> 'Viewport':
|
||||
"""Scroll up by one viewport height"""
|
||||
return self.scroll_by(0, -self._viewport_size[1])
|
||||
|
||||
def scroll_page_down(self) -> 'Viewport':
|
||||
"""Scroll down by one viewport height"""
|
||||
return self.scroll_by(0, self._viewport_size[1])
|
||||
|
||||
def scroll_line_up(self, line_height: int = 20) -> 'Viewport':
|
||||
"""Scroll up by one line"""
|
||||
return self.scroll_by(0, -line_height)
|
||||
|
||||
def scroll_line_down(self, line_height: int = 20) -> 'Viewport':
|
||||
"""Scroll down by one line"""
|
||||
return self.scroll_by(0, line_height)
|
||||
|
||||
def get_visible_elements(self) -> List[Tuple]:
|
||||
"""
|
||||
Get all elements that are currently visible in the viewport.
|
||||
|
||||
Returns:
|
||||
List of tuples (element, visible_origin, visible_size) for each visible element
|
||||
"""
|
||||
# Define viewport bounds
|
||||
viewport_left = self._viewport_offset[0]
|
||||
viewport_top = self._viewport_offset[1]
|
||||
viewport_right = viewport_left + self._viewport_size[0]
|
||||
viewport_bottom = viewport_top + self._viewport_size[1]
|
||||
|
||||
visible_elements = []
|
||||
content_bounds = self._get_content_bounds()
|
||||
|
||||
for element, abs_origin, abs_end, element_size in content_bounds:
|
||||
# Check if element intersects with viewport
|
||||
if (abs_origin[0] < viewport_right and abs_end[0] > viewport_left and
|
||||
abs_origin[1] < viewport_bottom and abs_end[1] > viewport_top):
|
||||
|
||||
# Calculate visible portion of the element
|
||||
visible_left = max(abs_origin[0], viewport_left)
|
||||
visible_top = max(abs_origin[1], viewport_top)
|
||||
visible_right = min(abs_end[0], viewport_right)
|
||||
visible_bottom = min(abs_end[1], viewport_bottom)
|
||||
|
||||
# Calculate visible origin relative to viewport
|
||||
visible_origin = np.array([
|
||||
visible_left - viewport_left,
|
||||
visible_top - viewport_top
|
||||
])
|
||||
|
||||
# Calculate visible size
|
||||
visible_size = np.array([
|
||||
visible_right - visible_left,
|
||||
visible_bottom - visible_top
|
||||
])
|
||||
|
||||
# Calculate clipping info for the element
|
||||
element_clip_x = visible_left - abs_origin[0]
|
||||
element_clip_y = visible_top - abs_origin[1]
|
||||
element_clip_w = visible_size[0]
|
||||
element_clip_h = visible_size[1]
|
||||
|
||||
visible_elements.append((
|
||||
element,
|
||||
visible_origin,
|
||||
visible_size,
|
||||
(element_clip_x, element_clip_y, element_clip_w, element_clip_h)
|
||||
))
|
||||
|
||||
return visible_elements
|
||||
|
||||
def layout(self):
|
||||
"""Layout the content within the viewport"""
|
||||
# Update content size if needed
|
||||
if self._content_size is None:
|
||||
self._update_content_size()
|
||||
|
||||
# Layout all content
|
||||
self._content_container.layout()
|
||||
self._cache_dirty = True
|
||||
|
||||
def render(self) -> Image.Image:
|
||||
"""
|
||||
Render only the visible portion of the content.
|
||||
|
||||
Returns:
|
||||
A PIL Image containing the rendered viewport
|
||||
"""
|
||||
# Ensure content is laid out
|
||||
self.layout()
|
||||
|
||||
# Create viewport canvas
|
||||
canvas = Image.new(self._mode, tuple(self._viewport_size), self._background_color)
|
||||
|
||||
# Get visible elements
|
||||
visible_elements = self.get_visible_elements()
|
||||
|
||||
# Render each visible element
|
||||
for element, visible_origin, visible_size, clip_info in visible_elements:
|
||||
try:
|
||||
# Render the full element
|
||||
element_img = element.render()
|
||||
|
||||
# Extract the visible portion
|
||||
clip_x, clip_y, clip_w, clip_h = clip_info
|
||||
if clip_x >= 0 and clip_y >= 0 and clip_w > 0 and clip_h > 0:
|
||||
# Ensure clipping bounds are within element image
|
||||
elem_w, elem_h = element_img.size
|
||||
clip_x = min(clip_x, elem_w)
|
||||
clip_y = min(clip_y, elem_h)
|
||||
clip_w = min(clip_w, elem_w - clip_x)
|
||||
clip_h = min(clip_h, elem_h - clip_y)
|
||||
|
||||
if clip_w > 0 and clip_h > 0:
|
||||
# Crop the visible portion
|
||||
visible_img = element_img.crop((
|
||||
clip_x, clip_y,
|
||||
clip_x + clip_w, clip_y + clip_h
|
||||
))
|
||||
|
||||
# Paste onto viewport canvas
|
||||
paste_pos = tuple(visible_origin.astype(int))
|
||||
if visible_img.mode == 'RGBA' and canvas.mode == 'RGBA':
|
||||
canvas.paste(visible_img, paste_pos, visible_img)
|
||||
else:
|
||||
canvas.paste(visible_img, paste_pos)
|
||||
|
||||
except Exception as e:
|
||||
# Skip elements that fail to render
|
||||
continue
|
||||
|
||||
return canvas
|
||||
|
||||
def hit_test(self, point: Tuple[int, int]) -> Optional[Renderable]:
|
||||
"""
|
||||
Find the topmost element at the given viewport coordinates.
|
||||
|
||||
Args:
|
||||
point: Coordinates within the viewport
|
||||
|
||||
Returns:
|
||||
The element at the given point, or None
|
||||
"""
|
||||
viewport_x, viewport_y = point
|
||||
|
||||
# Convert viewport coordinates to content coordinates
|
||||
content_x = viewport_x + self._viewport_offset[0]
|
||||
content_y = viewport_y + self._viewport_offset[1]
|
||||
|
||||
# Find elements at this point (in reverse order for top-to-bottom hit testing)
|
||||
content_bounds = self._get_content_bounds()
|
||||
|
||||
for element, abs_origin, abs_end, element_size in reversed(content_bounds):
|
||||
if (abs_origin[0] <= content_x < abs_end[0] and
|
||||
abs_origin[1] <= content_y < abs_end[1]):
|
||||
return element
|
||||
|
||||
return None
|
||||
|
||||
def get_scroll_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the current scroll state.
|
||||
|
||||
Returns:
|
||||
Dictionary with scroll information
|
||||
"""
|
||||
content_w, content_h = self.content_size
|
||||
viewport_w, viewport_h = self.viewport_size
|
||||
offset_x, offset_y = self.viewport_offset
|
||||
|
||||
return {
|
||||
'content_size': (content_w, content_h),
|
||||
'viewport_size': (viewport_w, viewport_h),
|
||||
'offset': (offset_x, offset_y),
|
||||
'max_scroll': (self.max_scroll_x, self.max_scroll_y),
|
||||
'scroll_progress_x': offset_x / max(1, self.max_scroll_x) if self.max_scroll_x > 0 else 0,
|
||||
'scroll_progress_y': offset_y / max(1, self.max_scroll_y) if self.max_scroll_y > 0 else 0,
|
||||
'can_scroll_up': offset_y > 0,
|
||||
'can_scroll_down': offset_y < self.max_scroll_y,
|
||||
'can_scroll_left': offset_x > 0,
|
||||
'can_scroll_right': offset_x < self.max_scroll_x
|
||||
}
|
||||
|
||||
|
||||
class ScrollablePageContent(Container):
|
||||
"""
|
||||
A specialized container for page content that's designed to work with viewports.
|
||||
This extends the regular Page functionality but allows for much larger content areas.
|
||||
"""
|
||||
|
||||
def __init__(self, content_width: int = 800, initial_height: int = 1000,
|
||||
direction='vertical', spacing=10, padding=(0, 0, 0, 0)):
|
||||
"""
|
||||
Initialize scrollable page content.
|
||||
|
||||
Args:
|
||||
content_width: Width of the content area
|
||||
initial_height: Initial height (will grow as content is added)
|
||||
direction: Layout direction
|
||||
spacing: Spacing between elements
|
||||
padding: Padding around content (no padding to avoid viewport clipping issues)
|
||||
"""
|
||||
super().__init__(
|
||||
origin=(0, 0),
|
||||
size=(content_width, initial_height),
|
||||
direction=direction,
|
||||
spacing=spacing,
|
||||
padding=padding # No padding to avoid any positioning issues with viewport
|
||||
)
|
||||
|
||||
self._content_width = content_width
|
||||
self._auto_height = True
|
||||
|
||||
def add_child(self, child: Renderable):
|
||||
"""Add a child and update content height if needed"""
|
||||
super().add_child(child)
|
||||
|
||||
if self._auto_height:
|
||||
self._update_content_height()
|
||||
|
||||
return self
|
||||
|
||||
def _update_content_height(self):
|
||||
"""Update the content height based on children"""
|
||||
if not self._children:
|
||||
return
|
||||
|
||||
# Layout children to get accurate positions
|
||||
super().layout()
|
||||
|
||||
# Find the bottom-most child
|
||||
max_bottom = 0
|
||||
for child in self._children:
|
||||
if hasattr(child, '_origin') and hasattr(child, '_size'):
|
||||
child_bottom = child._origin[1] + child._size[1]
|
||||
max_bottom = max(max_bottom, child_bottom)
|
||||
|
||||
# Add some bottom padding
|
||||
new_height = max_bottom + self._padding[2] + self._spacing
|
||||
|
||||
# Update size if needed
|
||||
if new_height > self._size[1]:
|
||||
self._size = np.array([self._content_width, new_height])
|
||||
|
||||
def get_content_height(self) -> int:
|
||||
"""Get the total content height"""
|
||||
self._update_content_height()
|
||||
return self._size[1]
|
||||
Reference in New Issue
Block a user