diff --git a/src/browser/chrome.py b/src/browser/chrome.py index 3e6e1c0..d977b95 100644 --- a/src/browser/chrome.py +++ b/src/browser/chrome.py @@ -149,7 +149,7 @@ class Chrome: self.drawing_area.set_can_focus(True) # Allow focus for keyboard events self.drawing_area.set_focusable(True) content_box.append(self.drawing_area) - + # Set up redraw callback for async image loading self.render_pipeline.set_redraw_callback(self._request_redraw) @@ -412,7 +412,7 @@ class Chrome: # Sync debug mode with render pipeline self.render_pipeline.debug_mode = self.debug_mode - + # Set base URL for resolving relative image paths if self.browser.active_tab and self.browser.active_tab.current_url: self.render_pipeline.base_url = str(self.browser.active_tab.current_url) @@ -561,7 +561,7 @@ class Chrome: """Trigger redraw of the drawing area.""" if self.drawing_area and self.window: self.drawing_area.queue_draw() - + def _request_redraw(self): """Request a redraw, called when async images finish loading.""" # This is called from the main thread via GLib.idle_add @@ -736,11 +736,32 @@ class Chrome: self.drawing_area.grab_focus() def _on_mouse_released(self, gesture, n_press, x, y): - """Handle mouse button release for text selection.""" + """Handle mouse button release for text selection or link clicks.""" + click_x = x + click_y = y + self.scroll_y + if self.is_selecting: - self.selection_end = (x, y + self.scroll_y) + self.selection_end = (click_x, click_y) self.is_selecting = False - # Extract selected text + + # Check if this is a click (not a drag) + if self.selection_start: + dx = abs(click_x - self.selection_start[0]) + dy = abs(click_y - self.selection_start[1]) + is_click = dx < 5 and dy < 5 + + if is_click: + # Check if we clicked on a link + href = self._get_link_at_position(click_x, click_y) + if href: + self.logger.info(f"Link clicked: {href}") + self._navigate_to_link(href) + # Clear selection since we're navigating + self.selection_start = None + self.selection_end = None + return + + # Extract selected text (for drag selection) selected_text = self._get_selected_text() if selected_text: self.logger.info(f"Selected text: {selected_text[:100]}...") @@ -748,6 +769,46 @@ class Chrome: self._copy_to_clipboard(selected_text) self.paint() + def _get_link_at_position(self, x: float, y: float) -> str | None: + """Get the href of a link at the given position, or None.""" + for line_info in self.text_layout: + line_top = line_info["y"] + line_bottom = line_info["y"] + line_info["height"] + line_left = line_info["x"] + line_right = line_info["x"] + line_info["width"] + + # Check if click is within this line's bounding box + if line_top <= y <= line_bottom and line_left <= x <= line_right: + href = line_info.get("href") + if href: + return href + return None + + def _navigate_to_link(self, href: str): + """Navigate to a link, handling relative URLs.""" + if not href: + return + + # Handle special URLs + if href.startswith("#"): + # Anchor link - for now just ignore (future: scroll to anchor) + self.logger.debug(f"Ignoring anchor link: {href}") + return + + if href.startswith("javascript:"): + # JavaScript URLs - ignore for security + self.logger.debug(f"Ignoring javascript link: {href}") + return + + # Resolve relative URLs against current page URL + if self.browser.active_tab and self.browser.active_tab.current_url: + base_url = self.browser.active_tab.current_url + resolved_url = base_url.resolve(href) + self.browser.navigate_to(str(resolved_url)) + else: + # No current URL, treat href as absolute + self.browser.navigate_to(href) + def _on_mouse_motion(self, controller, x, y): """Handle mouse motion for drag selection.""" if self.is_selecting: diff --git a/src/layout/document.py b/src/layout/document.py index f73b733..682e98e 100644 --- a/src/layout/document.py +++ b/src/layout/document.py @@ -8,12 +8,15 @@ from .embed import ImageLayout class LayoutLine: """A laid-out line ready for rendering.""" - def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None, font_family: str = ""): + def __init__(self, text: str, x: float, y: float, font_size: int, + char_positions: list = None, font_family: str = "", color: str = None, href: str = None): self.text = text self.x = x self.y = y # Top of line self.font_size = font_size self.font_family = font_family + self.color = color # Text color (e.g., "#0066cc" for links) + self.href = href # Link target URL if this is a link self.height = linespace(font_size) self.width = 0 self.char_positions = char_positions or [] @@ -34,12 +37,12 @@ class LayoutImage: # Store initial dimensions but also provide dynamic access self._initial_width = image_layout.width self._initial_height = image_layout.height - + @property def width(self) -> float: """Get current width (may update after async image load).""" return self.image_layout.width if self.image_layout.width > 0 else self._initial_width - + @property def height(self) -> float: """Get current height (may update after async image load).""" @@ -104,18 +107,18 @@ class DocumentLayout: margin_top = block_info.get("margin_top", 6) margin_bottom = block_info.get("margin_bottom", 10) y += margin_top - + # Position the image image_layout.x = x_margin image_layout.y = y - + # Add to images list for rendering layout_image = LayoutImage(image_layout, x_margin, y) self.images.append(layout_image) - + y += image_layout.height + margin_bottom continue - + font_size = block_info.get("font_size", 14) font_family = block_info.get("font_family", "") text = block_info.get("text", "") @@ -123,6 +126,8 @@ class DocumentLayout: margin_bottom = block_info.get("margin_bottom", 10) block_type = block_info.get("block_type", "block") tag = block_info.get("tag", "") + color = block_info.get("color") # Text color from style + href = block_info.get("href") # Link target URL if not text: y += font_size * 0.6 @@ -172,7 +177,9 @@ class DocumentLayout: y=y, # Top of line, baseline is y + font_size font_size=font_size, char_positions=char_positions, - font_family=font_family + font_family=font_family, + color=color, + href=href ) layout_block.lines.append(layout_line) @@ -228,13 +235,13 @@ class DocumentLayout: # Skip style and script tags - they shouldn't be rendered if tag in {"style", "script", "head", "title", "meta", "link"}: continue - + # Handle img tags if tag == "img": image_layout = ImageLayout(child) image_layout.load(self.base_url, async_load=self.async_images) image_layout.layout(max_width=self.width - 40 if self.width > 40 else 800) - + # Get computed style for margins style = getattr(child, "computed_style", None) if style: @@ -243,7 +250,7 @@ class DocumentLayout: else: margin_top = 6 margin_bottom = 10 - + blocks.append({ "is_image": True, "image_layout": image_layout, @@ -257,10 +264,57 @@ class DocumentLayout: blocks.extend(self._collect_blocks(child)) continue - # For other elements (p, h1, etc), first collect any embedded images + # Inline elements inside block elements are handled by _text_of + # Only create separate blocks for inline elements if they're direct + # children of container elements (handled above via recursion) + if tag in {"span", "strong", "em", "b", "i", "code"}: + # Skip - these are handled as part of parent's text + continue + + # Handle anchor elements - they can be inline or standalone + if tag == "a": + # Get the href and treat this as a clickable block + href = child.attributes.get("href") + content = self._text_of(child) + if not content: + continue + + style = getattr(child, "computed_style", None) + if style: + font_size = style.get_int("font-size", 14) + color = style.get("color") + font_family = style.get("font-family", "") + else: + font_size = 14 + color = None + font_family = "" + + # Default link color + if not color: + color = "#0066cc" + + blocks.append({ + "text": content, + "font_size": font_size, + "font_family": font_family, + "margin_top": 0, + "margin_bottom": 0, + "block_type": "inline", + "tag": tag, + "bullet": False, + "style": style, + "color": color, + "href": href + }) + continue + + # For block elements (p, h1, etc), first collect any embedded images embedded_images = self._collect_images(child) blocks.extend(embedded_images) - + + # Check if this element contains only a link + link_info = self._extract_single_link(child) + content = self._text_of(child) if not content: continue @@ -275,6 +329,7 @@ class DocumentLayout: margin_bottom = style.get_int("margin-bottom", 10) display = style.get("display", "block") font_family = style.get("font-family", "") + color = style.get("color") # Get text color from style else: # Fallback to hardcoded defaults font_size = self._get_default_font_size(tag) @@ -282,6 +337,14 @@ class DocumentLayout: margin_bottom = self._get_default_margin_bottom(tag) display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block" font_family = "" + color = None + + # If block contains only a link, use link info for href and color + href = None + if link_info: + href = link_info.get("href") + if not color: + color = link_info.get("color", "#0066cc") # Determine block type block_type = "inline" if display == "inline" else "block" @@ -300,7 +363,9 @@ class DocumentLayout: "block_type": block_type, "tag": tag, "bullet": bullet, - "style": style + "style": style, + "color": color, + "href": href }) return blocks @@ -326,20 +391,56 @@ class DocumentLayout: } return margins.get(tag, 0) + def _extract_single_link(self, node) -> dict | None: + """Extract link info if node contains only a single link. + + Returns dict with href and color if the element contains only + a link (possibly with some whitespace text), None otherwise. + """ + if not isinstance(node, Element): + return None + + links = [] + has_other_content = False + + for child in node.children: + if isinstance(child, Text): + # Whitespace-only text is okay + if child.text.strip(): + has_other_content = True + elif isinstance(child, Element): + if child.tag.lower() == "a": + links.append(child) + else: + # Has other elements besides links + has_other_content = True + + # Return link info only if there's exactly one link and no other content + if len(links) == 1 and not has_other_content: + link = links[0] + style = getattr(link, "computed_style", None) + color = style.get("color") if style else None + return { + "href": link.attributes.get("href"), + "color": color or "#0066cc" + } + + return None + def _collect_images(self, node) -> list: """Recursively collect all img elements from a node.""" images = [] - + if not isinstance(node, Element): return images - + for child in getattr(node, "children", []): if isinstance(child, Element): if child.tag.lower() == "img": image_layout = ImageLayout(child) image_layout.load(self.base_url, async_load=self.async_images) image_layout.layout(max_width=self.width - 40 if self.width > 40 else 800) - + style = getattr(child, "computed_style", None) if style: margin_top = style.get_int("margin-top", 6) @@ -347,7 +448,7 @@ class DocumentLayout: else: margin_top = 6 margin_bottom = 10 - + images.append({ "is_image": True, "image_layout": image_layout, @@ -357,9 +458,9 @@ class DocumentLayout: else: # Recurse into children images.extend(self._collect_images(child)) - + return images - + def _text_of(self, node) -> str: """Extract text content from a node.""" if isinstance(node, Text): diff --git a/src/layout/embed.py b/src/layout/embed.py index 136e03c..4c88ca5 100644 --- a/src/layout/embed.py +++ b/src/layout/embed.py @@ -4,7 +4,7 @@ import logging from typing import Optional, Callable import skia -from ..network.images import load_image, load_image_async, ImageCache +from ..network.images import load_image, load_image_async logger = logging.getLogger("bowser.layout.embed") @@ -16,10 +16,10 @@ OnImageLoadedCallback = Callable[["ImageLayout"], None] class ImageLayout: """Layout for an element.""" - + # Global callback for image load completion (set by render pipeline) _on_any_image_loaded: Optional[Callable[[], None]] = None - + def __init__(self, node, parent=None, previous=None, frame=None): self.node = node self.parent = parent @@ -36,38 +36,38 @@ class ImageLayout: self._load_task_id: Optional[int] = None self._src = "" self._base_url: Optional[str] = None - + def load(self, base_url: Optional[str] = None, async_load: bool = False): """ Load the image from the src attribute. - + Args: base_url: Base URL for resolving relative paths async_load: If True, load in background thread (non-blocking) """ if not hasattr(self.node, 'attributes'): return - + src = self.node.attributes.get('src', '') if not src: logger.warning("Image element has no src attribute") return - + # Get alt text self.alt_text = self.node.attributes.get('alt', '') self._src = src self._base_url = base_url - + if async_load: self._load_async(src, base_url) else: # Synchronous load (for tests or cached images) self.image = load_image(src, base_url) - + def _load_async(self, src: str, base_url: Optional[str]): """Load image asynchronously.""" self._loading = True - + def on_complete(image: Optional[skia.Image]): self._loading = False self.image = image @@ -78,25 +78,25 @@ class ImageLayout: # Trigger re-render if ImageLayout._on_any_image_loaded: ImageLayout._on_any_image_loaded() - + def on_error(e: Exception): self._loading = False logger.error(f"Async image load failed: {src}: {e}") - + self._load_task_id = load_image_async(src, base_url, on_complete, on_error) - + def _update_dimensions(self): """Update dimensions based on loaded image.""" if not self.image: return - + # Get explicit width/height attributes width_attr = self.node.attributes.get('width', '') if hasattr(self.node, 'attributes') else '' height_attr = self.node.attributes.get('height', '') if hasattr(self.node, 'attributes') else '' - + intrinsic_width = self.image.width() intrinsic_height = self.image.height() - + # Calculate dimensions based on attributes or intrinsic size if width_attr and height_attr: # Both specified - use them @@ -134,12 +134,12 @@ class ImageLayout: # No explicit dimensions - use intrinsic size self.width = intrinsic_width self.height = intrinsic_height - + @property def is_loading(self) -> bool: """True if image is currently being loaded.""" return self._loading - + def cancel_load(self): """Cancel any pending async load.""" if self._load_task_id is not None: @@ -147,11 +147,11 @@ class ImageLayout: cancel_task(self._load_task_id) self._load_task_id = None self._loading = False - + def layout(self, max_width: Optional[float] = None): """ Calculate the layout dimensions for this image. - + Returns: Width of the image (for inline layout) """ @@ -161,15 +161,15 @@ class ImageLayout: self.width = 100 self.height = 100 return self.width - + # Get explicit width/height attributes width_attr = self.node.attributes.get('width', '') if hasattr(self.node, 'attributes') else '' height_attr = self.node.attributes.get('height', '') if hasattr(self.node, 'attributes') else '' - + # Get intrinsic dimensions intrinsic_width = self.image.width() intrinsic_height = self.image.height() - + # Calculate display dimensions if width_attr and height_attr: # Both specified @@ -207,13 +207,13 @@ class ImageLayout: # No dimensions specified - use intrinsic size self.width = intrinsic_width self.height = intrinsic_height - + # Constrain to max_width if specified if max_width and self.width > max_width: aspect_ratio = intrinsic_height / intrinsic_width if intrinsic_width > 0 else 1 self.width = max_width self.height = self.width * aspect_ratio - + return self.width diff --git a/src/network/images.py b/src/network/images.py index a562567..f6ccb7b 100644 --- a/src/network/images.py +++ b/src/network/images.py @@ -18,10 +18,10 @@ ASSETS_DIR = Path(__file__).parent.parent.parent / "assets" class ImageCache: """Thread-safe global cache for loaded images.""" - + _instance = None _lock = threading.Lock() - + def __new__(cls): with cls._lock: if cls._instance is None: @@ -29,22 +29,22 @@ class ImageCache: cls._instance._cache = {} cls._instance._cache_lock = threading.Lock() return cls._instance - + def get(self, url: str) -> Optional[skia.Image]: """Get a cached image by URL.""" with self._cache_lock: return self._cache.get(url) - + def set(self, url: str, image: skia.Image): """Cache an image by URL.""" with self._cache_lock: self._cache[url] = image - + def has(self, url: str) -> bool: """Check if URL is cached.""" with self._cache_lock: return url in self._cache - + def clear(self): """Clear all cached images.""" with self._cache_lock: @@ -60,40 +60,40 @@ BytesCallback = Callable[[Optional[bytes], str], None] def load_image(url: str, base_url: Optional[str] = None) -> Optional[skia.Image]: """ Load an image from a URL or file path (synchronous). - + Args: url: Image URL or file path base_url: Base URL for resolving relative URLs - + Returns: Skia Image object, or None if loading failed """ try: # Resolve the full URL first full_url = _resolve_url(url, base_url) - + # Check cache with resolved URL cache = ImageCache() cached = cache.get(full_url) if cached is not None: logger.debug(f"Image cache hit: {full_url}") return cached - + logger.info(f"Loading image: {full_url}") - + # Load raw bytes data = _load_image_bytes(full_url) if data is None: return None - + # Decode with Skia image = skia.Image.MakeFromEncoded(data) if image: cache.set(full_url, image) logger.debug(f"Loaded image: {full_url} ({image.width()}x{image.height()})") - + return image - + except Exception as e: logger.error(f"Failed to load image {url}: {e}") return None @@ -105,19 +105,19 @@ def _load_image_bytes(full_url: str) -> Optional[bytes]: # Handle data URLs if full_url.startswith('data:'): return _load_data_url_bytes(full_url) - + # Handle file URLs if full_url.startswith('file://'): file_path = full_url[7:] # Remove 'file://' return _load_file_bytes(file_path) - + # Handle HTTP/HTTPS URLs if full_url.startswith(('http://', 'https://')): return _load_http_bytes(full_url) - + # Try as local file path return _load_file_bytes(full_url) - + except Exception as e: logger.error(f"Failed to load image bytes from {full_url}: {e}") return None @@ -131,16 +131,16 @@ def load_image_async( ) -> int: """ Load an image asynchronously in a background thread. - + Bytes are loaded in background, but Skia decoding happens on main thread to avoid threading issues with Skia objects. - + Args: url: Image URL or file path base_url: Base URL for resolving relative URLs on_complete: Callback with loaded image (or None if failed), called on main thread on_error: Callback with exception if loading failed, called on main thread - + Returns: Task ID that can be used to cancel the load """ @@ -148,10 +148,10 @@ def load_image_async( import gi gi.require_version("GLib", "2.0") from gi.repository import GLib - + # Resolve URL synchronously (fast operation) full_url = _resolve_url(url, base_url) - + # Check cache first (avoid thread overhead) cache = ImageCache() cached = cache.get(full_url) @@ -161,18 +161,18 @@ def load_image_async( # Use GLib to call on main thread GLib.idle_add(lambda: on_complete(cached) or False) return -1 # No task needed - + def do_load_bytes(): """Load raw bytes in background thread.""" return _load_image_bytes(full_url) - + def on_bytes_loaded(data: Optional[bytes]): """Decode image on main thread and call user callback.""" if data is None: if on_complete: on_complete(None) return - + try: # Decode image on main thread (Skia thread safety) decoded = skia.Image.MakeFromEncoded(data) @@ -183,7 +183,7 @@ def load_image_async( canvas = surface.getCanvas() canvas.drawImage(decoded, 0, 0) image = surface.makeImageSnapshot() - + cache.set(full_url, image) logger.debug(f"Async loaded image: {full_url} ({image.width()}x{image.height()})") if on_complete: @@ -197,7 +197,7 @@ def load_image_async( on_complete(None) if on_complete: on_complete(None) - + # Always use on_bytes_loaded to ensure caching happens return submit_task(do_load_bytes, on_bytes_loaded, on_error) @@ -207,7 +207,7 @@ def _resolve_url(url: str, base_url: Optional[str]) -> str: # Already absolute if url.startswith(('http://', 'https://', 'data:', 'file://')): return url - + # Handle about: pages - resolve relative to assets directory if base_url and base_url.startswith('about:'): # For about: pages, resolve relative to the assets/pages directory @@ -222,11 +222,11 @@ def _resolve_url(url: str, base_url: Optional[str]) -> str: return str(asset_path) # Return resolved path even if not found (will fail later with proper error) return str(resolved_path) - + # No base URL - treat as local file if not base_url: return url - + # Use URL class for proper resolution try: base = URL(base_url) @@ -261,14 +261,14 @@ def _load_http_bytes(url: str) -> Optional[bytes]: try: url_obj = URL(url) status, content_type, body = request(url_obj) - + if status != 200: logger.warning(f"HTTP {status} when loading image: {url}") return None - + logger.debug(f"Loaded {len(body)} bytes from HTTP: {url}") return body - + except Exception as e: logger.error(f"Failed to load from HTTP {url}: {e}") return None @@ -280,16 +280,16 @@ def _load_data_url_bytes(data_url: str) -> Optional[bytes]: # Parse data URL: data:[][;base64], if not data_url.startswith('data:'): return None - + # Split off the 'data:' prefix _, rest = data_url.split(':', 1) - + # Split metadata from data if ',' not in rest: return None - + metadata, data = rest.split(',', 1) - + # Check if base64 encoded if ';base64' in metadata: import base64 @@ -298,10 +298,10 @@ def _load_data_url_bytes(data_url: str) -> Optional[bytes]: # URL-encoded data import urllib.parse decoded = urllib.parse.unquote(data).encode('utf-8') - + logger.debug(f"Extracted {len(decoded)} bytes from data URL") return decoded - + except Exception as e: logger.error(f"Failed to parse data URL: {e}") return None @@ -312,22 +312,22 @@ def _load_from_http(url: str) -> Optional[skia.Image]: try: url_obj = URL(url) status, content_type, body = request(url_obj) - + if status != 200: logger.warning(f"HTTP {status} when loading image: {url}") return None - + # Decode image from bytes image = skia.Image.MakeFromEncoded(body) - + if image: # Cache it cache = ImageCache() cache.set(url, image) logger.debug(f"Loaded image from HTTP: {url} ({image.width()}x{image.height()})") - + return image - + except Exception as e: logger.error(f"Failed to load image from HTTP {url}: {e}") return None @@ -338,17 +338,17 @@ def _load_from_file(file_path: str) -> Optional[skia.Image]: try: with open(file_path, 'rb') as f: data = f.read() - + image = skia.Image.MakeFromEncoded(data) - + if image: # Cache it cache = ImageCache() cache.set(file_path, image) logger.debug(f"Loaded image from file: {file_path} ({image.width()}x{image.height()})") - + return image - + except Exception as e: logger.error(f"Failed to load image from file {file_path}: {e}") return None @@ -360,16 +360,16 @@ def _load_data_url(data_url: str) -> Optional[skia.Image]: # Parse data URL: data:[][;base64], if not data_url.startswith('data:'): return None - + # Split off the 'data:' prefix _, rest = data_url.split(':', 1) - + # Split metadata from data if ',' not in rest: return None - + metadata, data = rest.split(',', 1) - + # Check if base64 encoded if ';base64' in metadata: import base64 @@ -378,15 +378,15 @@ def _load_data_url(data_url: str) -> Optional[skia.Image]: # URL-encoded data import urllib.parse decoded = urllib.parse.unquote(data).encode('utf-8') - + image = skia.Image.MakeFromEncoded(decoded) - + if image: # Don't cache data URLs (they're already embedded) logger.debug(f"Loaded image from data URL ({image.width()}x{image.height()})") - + return image - + except Exception as e: logger.error(f"Failed to load image from data URL: {e}") return None diff --git a/src/network/tasks.py b/src/network/tasks.py index 9cd855b..0b0e37c 100644 --- a/src/network/tasks.py +++ b/src/network/tasks.py @@ -3,13 +3,12 @@ import logging import threading from concurrent.futures import ThreadPoolExecutor, Future -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Callable, Optional, Any -from queue import Queue import gi gi.require_version("GLib", "2.0") -from gi.repository import GLib +from gi.repository import GLib # noqa: E402 logger = logging.getLogger("bowser.tasks") @@ -17,12 +16,12 @@ logger = logging.getLogger("bowser.tasks") @dataclass class Task: """A task to be executed in the background.""" - + func: Callable[[], Any] on_complete: Optional[Callable[[Any], None]] = None on_error: Optional[Callable[[Exception], None]] = None priority: int = 0 # Lower = higher priority - + def __lt__(self, other): return self.priority < other.priority @@ -30,24 +29,24 @@ class Task: class TaskQueue: """ Background task queue using a thread pool. - + Uses GTK's GLib.idle_add for thread-safe UI updates. """ - + _instance: Optional["TaskQueue"] = None _lock = threading.Lock() - + def __new__(cls) -> "TaskQueue": with cls._lock: if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance - + def __init__(self, max_workers: int = 4): if self._initialized: return - + self._executor = ThreadPoolExecutor( max_workers=max_workers, thread_name_prefix="bowser-task" @@ -57,9 +56,9 @@ class TaskQueue: self._task_lock = threading.Lock() self._initialized = True self._shutdown = False - + logger.debug(f"TaskQueue initialized with {max_workers} workers") - + def submit( self, func: Callable[[], Any], @@ -68,23 +67,23 @@ class TaskQueue: ) -> int: """ Submit a task for background execution. - + Args: func: Function to run in background (no arguments) on_complete: Callback with result (runs on main thread) on_error: Callback with exception (runs on main thread) - + Returns: Task ID that can be used to cancel """ if self._shutdown: logger.warning("TaskQueue is shutdown, ignoring task") return -1 - + with self._task_lock: task_id = self._task_id self._task_id += 1 - + def wrapped(): try: result = func() @@ -100,15 +99,15 @@ class TaskQueue: finally: with self._task_lock: self._pending.pop(task_id, None) - + future = self._executor.submit(wrapped) - + with self._task_lock: self._pending[task_id] = future - + logger.debug(f"Submitted task {task_id}") return task_id - + def _call_on_main(self, callback: Callable, arg: Any) -> bool: """Execute a callback on the main thread. Returns False to remove from idle.""" try: @@ -116,7 +115,7 @@ class TaskQueue: except Exception as e: logger.error(f"Callback error: {e}") return False # Don't repeat - + def cancel(self, task_id: int) -> bool: """Cancel a pending task. Returns True if cancelled.""" with self._task_lock: @@ -128,7 +127,7 @@ class TaskQueue: logger.debug(f"Cancelled task {task_id}") return cancelled return False - + def cancel_all(self): """Cancel all pending tasks.""" with self._task_lock: @@ -136,20 +135,20 @@ class TaskQueue: future.cancel() self._pending.clear() logger.debug("Cancelled all tasks") - + @property def pending_count(self) -> int: """Number of pending tasks.""" with self._task_lock: return len(self._pending) - + def shutdown(self, wait: bool = True): """Shutdown the task queue.""" self._shutdown = True self.cancel_all() self._executor.shutdown(wait=wait) logger.debug("TaskQueue shutdown") - + @classmethod def reset_instance(cls): """Reset the singleton (for testing).""" diff --git a/src/parser/css.py b/src/parser/css.py index 014c998..3f7b4e1 100644 --- a/src/parser/css.py +++ b/src/parser/css.py @@ -180,7 +180,7 @@ class CSSParser: # Split multi-selectors by comma selector_parts = [s.strip() for s in selector_text.split(',') if s.strip()] - + if len(selector_parts) == 1: # Single selector return CSSRule(Selector(selector_text), declarations) diff --git a/src/parser/html.py b/src/parser/html.py index 1afc20d..6008594 100644 --- a/src/parser/html.py +++ b/src/parser/html.py @@ -129,6 +129,11 @@ class _DOMBuilder(HTMLParser): if self.current is self.root: self._ensure_body() + # Handle implicit closure for certain elements + # A new

tag closes any open

tag (HTML5 implicit paragraph closure) + if tag == "p" and self.current.tag == "p": + self._pop("p") + self._push(el) def handle_endtag(self, tag): @@ -193,7 +198,6 @@ def parse_html_with_styles(html_text: str, apply_styles: bool = True) -> Element """ from .css import parse as parse_css from .style import StyleResolver - import os from pathlib import Path # Parse HTML diff --git a/src/render/fonts.py b/src/render/fonts.py index d8c92f2..0ce0b83 100644 --- a/src/render/fonts.py +++ b/src/render/fonts.py @@ -23,7 +23,7 @@ class FontCache: """Cache for Skia fonts and typefaces.""" _instance = None - + # Common emoji/symbol fonts to try as last resort before showing tofu _EMOJI_FALLBACK_FONTS = ( 'Noto Color Emoji', @@ -51,7 +51,7 @@ class FontCache: # This dramatically reduces cache entries and font lookups is_emoji = text and self._is_emoji_char(text[0]) cache_key = (families, is_emoji) - + if cache_key in self._typeface_cache: return self._typeface_cache[cache_key] @@ -60,7 +60,7 @@ class FontCache: # Skip generic families that won't resolve to specific fonts if family.lower() in ('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'): continue - + typeface = skia.Typeface.MakeFromName(family, skia.FontStyle.Normal()) if typeface and typeface.getFamilyName() == family: # Font was actually found - check if it has glyphs for sample text diff --git a/src/render/paint.py b/src/render/paint.py index bb8bbc2..3ef91f6 100644 --- a/src/render/paint.py +++ b/src/render/paint.py @@ -61,7 +61,7 @@ class DrawRect(PaintCommand): class DrawImage(PaintCommand): """Command to draw an image.""" - def __init__(self, x: float, y: float, width: float, height: float, + def __init__(self, x: float, y: float, width: float, height: float, image: skia.Image, alt_text: str = ""): super().__init__((x, y, x + width, y + height)) self.x = x @@ -82,11 +82,11 @@ class DrawImage(PaintCommand): if paint is None: paint = skia.Paint() paint.setAntiAlias(True) - + # Calculate scale factor scale_x = self.width / self.image.width() scale_y = self.height / self.image.height() - + # Use canvas transform for scaling canvas.save() canvas.translate(self.x, self.y) @@ -99,7 +99,7 @@ class DrawImage(PaintCommand): logger.error(f"Failed to draw image: {e}") # If drawing fails, fall back to placeholder self._draw_placeholder(canvas, paint) - + def _draw_placeholder(self, canvas: skia.Canvas, paint: skia.Paint = None): """Draw a placeholder for a missing or failed image.""" if paint is None: @@ -108,14 +108,14 @@ class DrawImage(PaintCommand): paint.setStyle(skia.Paint.kFill_Style) rect = skia.Rect.MakeLTRB(self.x, self.y, self.x + self.width, self.y + self.height) canvas.drawRect(rect, paint) - + # Draw border border_paint = skia.Paint() border_paint.setColor(skia.ColorGRAY) border_paint.setStyle(skia.Paint.kStroke_Style) border_paint.setStrokeWidth(1) canvas.drawRect(rect, border_paint) - + # Draw alt text if available if self.alt_text: text_paint = skia.Paint() diff --git a/src/render/pipeline.py b/src/render/pipeline.py index 86d8f50..1d3afb5 100644 --- a/src/render/pipeline.py +++ b/src/render/pipeline.py @@ -22,26 +22,26 @@ class RenderPipeline: # Paint cache self._text_paint: Optional[skia.Paint] = None self._display_list: Optional[DisplayList] = None - + # Base URL for resolving relative paths self.base_url: Optional[str] = None # Debug mode self.debug_mode = False - + # Async image loading self.async_images = True # Enable async image loading by default self._on_needs_redraw: Optional[Callable[[], None]] = None - + def set_redraw_callback(self, callback: Callable[[], None]): """Set a callback to be called when async images finish loading.""" self._on_needs_redraw = callback - + # Also set on ImageLayout class for global notification def on_image_loaded(): if self._on_needs_redraw: self._on_needs_redraw() - + ImageLayout._on_any_image_loaded = on_image_loaded def layout(self, document: Element, width: int) -> DocumentLayout: @@ -60,7 +60,7 @@ class RenderPipeline: # Build new layout with base_url for resolving image paths self._layout = DocumentLayout( - document, + document, base_url=self.base_url, async_images=self.async_images ) @@ -110,7 +110,25 @@ class RenderPipeline: continue font = get_font(line.font_size, getattr(line, "font_family", ""), text=line.text) - canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint) + + # Use line color if specified (for links), otherwise black + paint = skia.Paint() + paint.setAntiAlias(True) + if line.color: + paint.setColor(self._parse_color(line.color)) + else: + paint.setColor(skia.ColorBLACK) + + canvas.drawString(line.text, line.x, baseline_y, font, paint) + + # Draw underline for links + if line.href: + underline_paint = skia.Paint() + underline_paint.setColor(paint.getColor()) + underline_paint.setStyle(skia.Paint.kStroke_Style) + underline_paint.setStrokeWidth(1) + underline_y = baseline_y + 2 + canvas.drawLine(line.x, underline_y, line.x + line.width, underline_y, underline_paint) # Render visible images (both loaded and placeholder) for layout_image in layout.images: @@ -122,12 +140,12 @@ class RenderPipeline: # Use image_layout dimensions directly for accurate sizing after async load img_width = image_layout.width if image_layout.width > 0 else layout_image.width img_height = image_layout.height if image_layout.height > 0 else layout_image.height - + # Always create DrawImage command - it handles None images as placeholders draw_cmd = DrawImage( - layout_image.x, + layout_image.x, layout_image.y, - img_width, + img_width, img_height, image_layout.image, # May be None, DrawImage handles this image_layout.alt_text @@ -188,8 +206,8 @@ class RenderPipeline: def get_text_layout(self) -> list: """ - Get the text layout for text selection. - Returns list of line info dicts with char_positions. + Get the text layout for text selection and link hit testing. + Returns list of line info dicts with char_positions and href. """ if self._layout is None: return [] @@ -203,7 +221,8 @@ class RenderPipeline: "width": line.width, "height": line.height, "font_size": line.font_size, - "char_positions": line.char_positions + "char_positions": line.char_positions, + "href": getattr(line, "href", None) }) return result @@ -218,3 +237,63 @@ class RenderPipeline: self._layout = None self._layout_doc_id = None self._display_list = None + + def _parse_color(self, color_str: str) -> int: + """Parse a CSS color string to a Skia color value. + + Supports: + - Hex colors: #rgb, #rrggbb + - Named colors (limited set) + + Note: Very light colors (like white) that would be invisible on + our white background are converted to black. + """ + if not color_str: + return skia.ColorBLACK + + color_str = color_str.strip().lower() + + # Named colors + named_colors = { + "black": skia.ColorBLACK, + "white": skia.ColorBLACK, # White is invisible on white bg, use black + "red": skia.ColorRED, + "green": skia.ColorGREEN, + "blue": skia.ColorBLUE, + "yellow": skia.ColorYELLOW, + "cyan": skia.ColorCYAN, + "magenta": skia.ColorMAGENTA, + "gray": skia.ColorGRAY, + "grey": skia.ColorGRAY, + } + + if color_str in named_colors: + return named_colors[color_str] + + # Hex colors + if color_str.startswith("#"): + hex_str = color_str[1:] + try: + if len(hex_str) == 3: + # #rgb -> #rrggbb + r = int(hex_str[0] * 2, 16) + g = int(hex_str[1] * 2, 16) + b = int(hex_str[2] * 2, 16) + elif len(hex_str) == 6: + r = int(hex_str[0:2], 16) + g = int(hex_str[2:4], 16) + b = int(hex_str[4:6], 16) + else: + return skia.ColorBLACK + + # Check if color is too light (would be invisible on white) + # Use relative luminance approximation + if r > 240 and g > 240 and b > 240: + return skia.ColorBLACK + + return skia.Color(r, g, b, 255) + except ValueError: + pass + + # Fallback to black + return skia.ColorBLACK diff --git a/tests/test_css.py b/tests/test_css.py index 4d94b9f..d527d07 100644 --- a/tests/test_css.py +++ b/tests/test_css.py @@ -1,94 +1,93 @@ """Tests for CSS parsing and style computation.""" -import pytest from src.parser.css import ( - Selector, CSSRule, CSSParser, parse, parse_inline_style + Selector, parse, parse_inline_style ) from src.parser.html import Element, Text from src.parser.style import ( - ComputedStyle, StyleResolver, DEFAULT_STYLES, INHERITED_PROPERTIES + ComputedStyle, StyleResolver ) class TestSelector: """Test CSS selector parsing and matching.""" - + def test_tag_selector(self): sel = Selector("p") assert sel.tag == "p" assert sel.id is None assert sel.classes == [] - + def test_class_selector(self): sel = Selector(".container") assert sel.tag is None assert sel.classes == ["container"] - + def test_id_selector(self): sel = Selector("#header") assert sel.id == "header" assert sel.tag is None - + def test_compound_selector(self): sel = Selector("div.container") assert sel.tag == "div" assert sel.classes == ["container"] - + def test_complex_compound_selector(self): sel = Selector("div#main.container.active") assert sel.tag == "div" assert sel.id == "main" assert set(sel.classes) == {"container", "active"} - + def test_specificity_tag_only(self): sel = Selector("p") assert sel.specificity() == (0, 0, 1) - + def test_specificity_class_only(self): sel = Selector(".container") assert sel.specificity() == (0, 1, 0) - + def test_specificity_id_only(self): sel = Selector("#header") assert sel.specificity() == (1, 0, 0) - + def test_specificity_compound(self): sel = Selector("div#main.container.active") assert sel.specificity() == (1, 2, 1) - + def test_matches_tag(self): sel = Selector("p") elem = Element("p") assert sel.matches(elem) is True - + elem2 = Element("div") assert sel.matches(elem2) is False - + def test_matches_class(self): sel = Selector(".container") elem = Element("div", {"class": "container sidebar"}) assert sel.matches(elem) is True - + elem2 = Element("div", {"class": "sidebar"}) assert sel.matches(elem2) is False - + def test_matches_id(self): sel = Selector("#header") elem = Element("div", {"id": "header"}) assert sel.matches(elem) is True - + elem2 = Element("div", {"id": "footer"}) assert sel.matches(elem2) is False - + def test_matches_compound(self): sel = Selector("div.container") elem = Element("div", {"class": "container"}) assert sel.matches(elem) is True - + # Wrong tag elem2 = Element("p", {"class": "container"}) assert sel.matches(elem2) is False - + # Wrong class elem3 = Element("div", {"class": "sidebar"}) assert sel.matches(elem3) is False @@ -96,18 +95,18 @@ class TestSelector: class TestCSSParser: """Test CSS stylesheet parsing.""" - + def test_empty_stylesheet(self): rules = parse("") assert rules == [] - + def test_single_rule(self): css = "p { color: red; }" rules = parse(css) assert len(rules) == 1 assert rules[0].selector.tag == "p" assert rules[0].declarations == {"color": "red"} - + def test_multiple_rules(self): css = """ p { color: red; } @@ -117,7 +116,7 @@ class TestCSSParser: assert len(rules) == 2 assert rules[0].selector.tag == "p" assert rules[1].selector.tag == "div" - + def test_multiple_declarations(self): css = "p { color: red; font-size: 14px; margin: 10px; }" rules = parse(css) @@ -127,7 +126,7 @@ class TestCSSParser: "font-size": "14px", "margin": "10px" } - + def test_multiline_declarations(self): css = """ p { @@ -143,7 +142,7 @@ class TestCSSParser: "font-size": "14px", "margin": "10px" } - + def test_no_semicolon_on_last_declaration(self): css = "p { color: red; font-size: 14px }" rules = parse(css) @@ -151,34 +150,34 @@ class TestCSSParser: "color": "red", "font-size": "14px" } - + def test_class_selector_rule(self): css = ".container { width: 100%; }" rules = parse(css) assert len(rules) == 1 assert rules[0].selector.classes == ["container"] assert rules[0].declarations == {"width": "100%"} - + def test_id_selector_rule(self): css = "#header { height: 50px; }" rules = parse(css) assert len(rules) == 1 assert rules[0].selector.id == "header" assert rules[0].declarations == {"height": "50px"} - + def test_compound_selector_rule(self): css = "div.container { padding: 20px; }" rules = parse(css) assert len(rules) == 1 assert rules[0].selector.tag == "div" assert rules[0].selector.classes == ["container"] - + def test_whitespace_handling(self): css = " p { color : red ; } " rules = parse(css) assert len(rules) == 1 assert rules[0].declarations == {"color": "red"} - + def test_comments(self): css = """ /* This is a comment */ @@ -190,38 +189,38 @@ class TestCSSParser: assert len(rules) == 2 assert rules[0].selector.tag == "p" assert rules[1].selector.tag == "div" - + def test_property_values_with_spaces(self): css = "p { font-family: Arial, sans-serif; }" rules = parse(css) assert rules[0].declarations == {"font-family": "Arial, sans-serif"} - + def test_complex_stylesheet(self): css = """ /* Reset */ * { margin: 0; padding: 0; } - + body { font-family: Arial, sans-serif; font-size: 16px; color: #333; } - + h1 { font-size: 32px; margin-bottom: 20px; } - + .container { width: 960px; margin: 0 auto; } - + #header { background: #f0f0f0; padding: 10px; } - + div.highlight { background: yellow; font-weight: bold; @@ -229,7 +228,7 @@ class TestCSSParser: """ rules = parse(css) assert len(rules) == 6 - + # Check body rule body_rule = next(r for r in rules if r.selector.tag == "body") assert "font-family" in body_rule.declarations @@ -238,34 +237,34 @@ class TestCSSParser: class TestInlineStyleParser: """Test inline style attribute parsing.""" - + def test_empty_style(self): decls = parse_inline_style("") assert decls == {} - + def test_single_declaration(self): decls = parse_inline_style("color: red") assert decls == {"color": "red"} - + def test_multiple_declarations(self): decls = parse_inline_style("color: red; font-size: 14px") assert decls == {"color": "red", "font-size": "14px"} - + def test_trailing_semicolon(self): decls = parse_inline_style("color: red; font-size: 14px;") assert decls == {"color": "red", "font-size": "14px"} - + def test_whitespace_handling(self): decls = parse_inline_style(" color : red ; font-size : 14px ") assert decls == {"color": "red", "font-size": "14px"} - + def test_complex_values(self): decls = parse_inline_style("font-family: Arial, sans-serif; margin: 10px 20px") assert decls == { "font-family": "Arial, sans-serif", "margin": "10px 20px" } - + def test_malformed_ignored(self): # Missing colon decls = parse_inline_style("color red; font-size: 14px") @@ -274,36 +273,36 @@ class TestInlineStyleParser: class TestComputedStyle: """Test computed style value accessors.""" - + def test_empty_style(self): style = ComputedStyle() assert style.get("color") == "" assert style.get("color", "black") == "black" - + def test_get_set(self): style = ComputedStyle() style.set("color", "red") assert style.get("color") == "red" - + def test_get_int(self): style = ComputedStyle() style.set("font-size", "16px") assert style.get_int("font-size") == 16 - + def test_get_int_no_unit(self): style = ComputedStyle() style.set("font-size", "16") assert style.get_int("font-size") == 16 - + def test_get_int_default(self): style = ComputedStyle() assert style.get_int("font-size", 14) == 14 - + def test_get_float(self): style = ComputedStyle() style.set("margin", "10.5px") assert style.get_float("margin") == 10.5 - + def test_get_float_default(self): style = ComputedStyle() assert style.get_float("margin", 5.5) == 5.5 @@ -311,44 +310,44 @@ class TestComputedStyle: class TestStyleResolver: """Test style resolution with cascade and inheritance.""" - + def test_default_styles(self): resolver = StyleResolver() elem = Element("p") style = resolver.resolve_style(elem) - + assert style.get("display") == "block" assert style.get("margin-top") == "16px" assert style.get("margin-bottom") == "16px" - + def test_no_default_for_unknown_tag(self): resolver = StyleResolver() elem = Element("unknown") style = resolver.resolve_style(elem) - + # Should have empty properties (no defaults) assert style.get("display") == "" - + def test_stylesheet_overrides_default(self): rules = parse("p { margin-top: 20px; }") resolver = StyleResolver(rules) elem = Element("p") style = resolver.resolve_style(elem) - + # Stylesheet should override default assert style.get("margin-top") == "20px" # But default not overridden should remain assert style.get("margin-bottom") == "16px" - + def test_inline_overrides_stylesheet(self): rules = parse("p { color: blue; }") resolver = StyleResolver(rules) elem = Element("p", {"style": "color: red"}) style = resolver.resolve_style(elem) - + # Inline should win assert style.get("color") == "red" - + def test_specificity_class_over_tag(self): rules = parse(""" p { color: blue; } @@ -357,10 +356,10 @@ class TestStyleResolver: resolver = StyleResolver(rules) elem = Element("p", {"class": "highlight"}) style = resolver.resolve_style(elem) - + # Class selector has higher specificity assert style.get("color") == "red" - + def test_specificity_id_over_class(self): rules = parse(""" p { color: blue; } @@ -370,53 +369,53 @@ class TestStyleResolver: resolver = StyleResolver(rules) elem = Element("p", {"class": "highlight", "id": "main"}) style = resolver.resolve_style(elem) - + # ID selector has highest specificity assert style.get("color") == "green" - + def test_inheritance_from_parent(self): rules = parse("body { color: blue; font-size: 16px; }") resolver = StyleResolver(rules) - + parent = Element("body") parent_style = resolver.resolve_style(parent) - + child = Element("div") child_style = resolver.resolve_style(child, parent_style) - + # Should inherit color and font-size assert child_style.get("color") == "blue" assert child_style.get("font-size") == "16px" - + def test_non_inherited_properties(self): rules = parse("body { margin: 10px; }") resolver = StyleResolver(rules) - + parent = Element("body") parent_style = resolver.resolve_style(parent) - + child = Element("div") child_style = resolver.resolve_style(child, parent_style) - + # Margin should not inherit assert child_style.get("margin") == "" - + def test_child_overrides_inherited(self): rules = parse(""" body { color: blue; } p { color: red; } """) resolver = StyleResolver(rules) - + parent = Element("body") parent_style = resolver.resolve_style(parent) - + child = Element("p") child_style = resolver.resolve_style(child, parent_style) - + # Child's own style should override inherited assert child_style.get("color") == "red" - + def test_resolve_tree(self): css = """ body { color: blue; font-size: 16px; } @@ -425,7 +424,7 @@ class TestStyleResolver: """ rules = parse(css) resolver = StyleResolver(rules) - + # Build tree root = Element("body") p1 = Element("p", parent=root) @@ -433,46 +432,46 @@ class TestStyleResolver: text = Text("Hello", parent=p1) root.children = [p1, p2] p1.children = [text] - + # Resolve entire tree resolver.resolve_tree(root) - + # Check root assert root.computed_style.get("color") == "blue" assert root.computed_style.get("font-size") == "16px" - + # Check p1 (inherits color) assert p1.computed_style.get("color") == "blue" assert p1.computed_style.get("margin") == "10px" - + # Check p2 (inherits + has class) assert p2.computed_style.get("color") == "blue" assert p2.computed_style.get("background") == "yellow" - + # Check text (has parent style) assert text.computed_style.get("color") == "blue" - + def test_heading_defaults(self): resolver = StyleResolver() - + h1 = Element("h1") h1_style = resolver.resolve_style(h1) assert h1_style.get("font-size") == "32px" assert h1_style.get("font-weight") == "bold" - + h2 = Element("h2") h2_style = resolver.resolve_style(h2) assert h2_style.get("font-size") == "24px" - + def test_inline_elements(self): resolver = StyleResolver() - + a = Element("a") a_style = resolver.resolve_style(a) assert a_style.get("display") == "inline" assert a_style.get("color") == "blue" assert a_style.get("text-decoration") == "underline" - + span = Element("span") span_style = resolver.resolve_style(span) assert span_style.get("display") == "inline" diff --git a/tests/test_html_parsing.py b/tests/test_html_parsing.py index d0392b3..dd625c9 100644 --- a/tests/test_html_parsing.py +++ b/tests/test_html_parsing.py @@ -60,7 +60,7 @@ class TestParseHTML: if hasattr(child, "tag") and child.tag == "style": style_elem = child break - + assert style_elem is not None # Style content should be in the element joined = " ".join(collect_text(style_elem)) diff --git a/tests/test_images.py b/tests/test_images.py index d0ff902..ef9add2 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -1,13 +1,11 @@ """Tests for image loading and rendering.""" -import pytest import skia -from src.network.images import load_image, ImageCache, _load_data_url +from src.network.images import ImageCache, _load_data_url from src.layout.embed import ImageLayout from src.parser.html import Element, parse_html from src.render.paint import DrawImage from src.layout.document import DocumentLayout, LayoutImage -from io import BytesIO def create_test_image(width=100, height=100): @@ -21,47 +19,47 @@ def create_test_image(width=100, height=100): class TestImageCache: """Test image caching.""" - + def test_cache_singleton(self): """ImageCache should be a singleton.""" cache1 = ImageCache() cache2 = ImageCache() assert cache1 is cache2 - + def test_cache_get_set(self): """Test basic cache operations.""" cache = ImageCache() cache.clear() - + # Create a simple test image image = create_test_image(100, 100) - + # Initially empty assert cache.get("test_url") is None - + # Set and get cache.set("test_url", image) cached = cache.get("test_url") assert cached is not None assert cached.width() == 100 assert cached.height() == 100 - + def test_cache_clear(self): """Test cache clearing.""" cache = ImageCache() cache.clear() - + image = create_test_image(100, 100) cache.set("test_url", image) assert cache.get("test_url") is not None - + cache.clear() assert cache.get("test_url") is None class TestDataURLLoading: """Test data URL image loading.""" - + def test_load_base64_png(self): """Test loading a base64-encoded PNG data URL.""" # Simple 1x1 red PNG @@ -69,29 +67,29 @@ class TestDataURLLoading: "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" ) - + image = _load_data_url(data_url) assert image is not None assert image.width() == 1 assert image.height() == 1 - + def test_load_invalid_data_url(self): """Test loading an invalid data URL.""" image = _load_data_url("data:invalid") assert image is None - + image = _load_data_url("not_a_data_url") assert image is None class TestImageLayout: """Test ImageLayout class.""" - + def test_image_layout_init(self): """Test ImageLayout initialization.""" node = Element("img", {"src": "test.png"}) layout = ImageLayout(node) - + assert layout.node == node assert layout.x == 0 assert layout.y == 0 @@ -99,109 +97,109 @@ class TestImageLayout: assert layout.height == 0 assert layout.image is None assert layout.is_inline is True - + def test_layout_with_intrinsic_size(self): """Test layout calculation with intrinsic image size.""" node = Element("img", {"src": "test.png"}) layout = ImageLayout(node) - + # Create a test image layout.image = create_test_image(200, 150) - + width = layout.layout() - + assert layout.width == 200 assert layout.height == 150 assert width == 200 - + def test_layout_with_explicit_width(self): """Test layout with explicit width attribute.""" node = Element("img", {"src": "test.png", "width": "100"}) layout = ImageLayout(node) - + # Create a test image (200x150) layout.image = create_test_image(200, 150) - + layout.layout() - + # Should maintain aspect ratio assert layout.width == 100 assert layout.height == 75 # 100 * (150/200) - + def test_layout_with_explicit_height(self): """Test layout with explicit height attribute.""" node = Element("img", {"src": "test.png", "height": "100"}) layout = ImageLayout(node) - + # Create a test image (200x150) layout.image = create_test_image(200, 150) - + layout.layout() - + # Should maintain aspect ratio assert layout.height == 100 assert abs(layout.width - 133.33) < 1 # 100 * (200/150) - + def test_layout_with_both_dimensions(self): """Test layout with both width and height specified.""" node = Element("img", {"src": "test.png", "width": "100", "height": "50"}) layout = ImageLayout(node) - + # Create a test image layout.image = create_test_image(200, 150) - + layout.layout() - + # Should use explicit dimensions (no aspect ratio preservation) assert layout.width == 100 assert layout.height == 50 - + def test_layout_with_max_width(self): """Test layout constrained by max_width.""" node = Element("img", {"src": "test.png"}) layout = ImageLayout(node) - + # Create a large test image layout.image = create_test_image(1000, 500) - + layout.layout(max_width=400) - + # Should constrain to max_width and maintain aspect ratio assert layout.width == 400 assert layout.height == 200 # 400 * (500/1000) - + def test_layout_no_image(self): """Test layout when image fails to load.""" node = Element("img", {"src": "test.png", "alt": "Test image"}) layout = ImageLayout(node) - + # Don't set an image (simulating load failure) layout.alt_text = "Test image" layout.layout() - + # Should use placeholder dimensions assert layout.width == 100 assert layout.height == 100 - + def test_alt_text_extraction(self): """Test alt text extraction.""" node = Element("img", {"src": "test.png", "alt": "Description"}) layout = ImageLayout(node) - + layout.load() - + assert layout.alt_text == "Description" class TestDrawImage: """Test DrawImage paint command.""" - + def test_draw_image_init(self): """Test DrawImage initialization.""" image = create_test_image(100, 100) - + cmd = DrawImage(10, 20, 100, 100, image, "Test") - + assert cmd.x == 10 assert cmd.y == 20 assert cmd.width == 100 @@ -209,75 +207,75 @@ class TestDrawImage: assert cmd.image is image assert cmd.alt_text == "Test" assert cmd.rect == (10, 20, 110, 120) - + def test_draw_image_with_valid_image(self): """Test drawing a valid image.""" image = create_test_image(100, 100) - + # Create a surface to draw on surface = skia.Surface(200, 200) canvas = surface.getCanvas() - + cmd = DrawImage(10, 20, 100, 100, image) cmd.execute(canvas) - + # If it doesn't throw, it worked assert True - + def test_draw_image_with_null_image(self): """Test drawing when image is None (placeholder).""" # Create a surface to draw on surface = skia.Surface(200, 200) canvas = surface.getCanvas() - + cmd = DrawImage(10, 20, 100, 100, None, "Failed to load") cmd.execute(canvas) - + # Should draw placeholder without error assert True class TestDocumentLayoutImages: """Test image integration in DocumentLayout.""" - + def test_parse_img_element(self): """Test that img elements are parsed correctly.""" html = 'Test image' root = parse_html(html) - + # Find the img element body = root.children[0] img = body.children[0] - + assert img.tag == "img" assert img.attributes["src"] == "test.png" assert img.attributes["alt"] == "Test image" assert img.attributes["width"] == "100" - + def test_layout_with_image(self): """Test document layout with an image.""" html = '

Text before

Text after

' root = parse_html(html) - + layout = DocumentLayout(root) - + # Mock the image loading by creating the images manually # This would normally happen in _collect_blocks # For now, just verify the structure is created lines = layout.layout(800) - + # Should have lines and potentially images assert isinstance(lines, list) - + def test_layout_image_class(self): """Test LayoutImage class.""" node = Element("img", {"src": "test.png"}) image_layout = ImageLayout(node) image_layout.image = create_test_image(100, 100) image_layout.layout() - + layout_image = LayoutImage(image_layout, 10, 20) - + assert layout_image.x == 10 assert layout_image.y == 20 assert layout_image.width == 100 @@ -287,7 +285,7 @@ class TestDocumentLayoutImages: class TestImageIntegration: """Integration tests for the complete image pipeline.""" - + def test_html_with_data_url_image(self): """Test parsing and layout of HTML with data URL image.""" # 1x1 red PNG @@ -295,10 +293,10 @@ class TestImageIntegration: "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" ) - + html = f'

Before

After

' root = parse_html(html) - + # Verify structure body = root.children[0] # The img tag is self-closing, so the second p tag becomes a child of img @@ -306,7 +304,7 @@ class TestImageIntegration: assert len(body.children) >= 2 assert body.children[0].tag == "p" assert body.children[1].tag == "img" - + def test_nested_image_in_paragraph(self): """Test that images inside paragraphs are collected.""" # 1x1 red PNG @@ -314,28 +312,28 @@ class TestImageIntegration: "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" ) - + html = f'

Text before text after

' root = parse_html(html) - + # Create layout and verify images are collected layout = DocumentLayout(root) layout.layout(800) - + # Should have at least one image collected assert len(layout.images) >= 1 - + def test_image_with_alt_text_placeholder(self): """Test that failed images show placeholder with alt text.""" html = 'Image failed' root = parse_html(html) - + layout = DocumentLayout(root) layout.layout(800) - + # Should have image layout even though load failed assert len(layout.images) >= 1 - + # Check alt text is set if layout.images: img = layout.images[0] @@ -344,41 +342,41 @@ class TestImageIntegration: class TestURLResolution: """Test URL resolution for images.""" - + def test_resolve_about_page_relative_url(self): """Test resolving relative URLs for about: pages.""" from src.network.images import _resolve_url, ASSETS_DIR - + # Relative URL from about:startpage should resolve to assets directory resolved = _resolve_url("../WebBowserLogo.jpeg", "about:startpage") - + # Should be an absolute path to the assets directory assert "WebBowserLogo.jpeg" in resolved assert str(ASSETS_DIR) in resolved or resolved.endswith("WebBowserLogo.jpeg") - + def test_resolve_http_relative_url(self): """Test resolving relative URLs for HTTP pages.""" from src.network.images import _resolve_url - + # Relative URL from HTTP page resolved = _resolve_url("images/photo.jpg", "https://example.com/page/index.html") - + assert resolved == "https://example.com/page/images/photo.jpg" - + def test_resolve_absolute_url(self): """Test that absolute URLs are returned unchanged.""" from src.network.images import _resolve_url - + url = "https://example.com/image.png" resolved = _resolve_url(url, "https://other.com/page.html") - + assert resolved == url - + def test_resolve_data_url(self): """Test that data URLs are returned unchanged.""" from src.network.images import _resolve_url - + url = "data:image/png;base64,abc123" resolved = _resolve_url(url, "https://example.com/") - + assert resolved == url diff --git a/tests/test_links.py b/tests/test_links.py new file mode 100644 index 0000000..013cd20 --- /dev/null +++ b/tests/test_links.py @@ -0,0 +1,341 @@ +"""Tests for link parsing, rendering, and navigation.""" + +import pytest + +from src.parser.html import parse_html, parse_html_with_styles, Element, Text +from src.layout.document import DocumentLayout, LayoutLine +from src.network.url import URL + + +class TestLinkParsing: + """Tests for parsing anchor elements from HTML.""" + + def test_parse_simple_link(self): + """Test parsing a simple anchor tag.""" + html = "Click here" + root = parse_html(html) + + # Find the anchor element + body = root.children[0] + assert body.tag == "body" + anchor = body.children[0] + assert anchor.tag == "a" + assert anchor.attributes.get("href") == "https://example.com" + + def test_parse_link_with_text(self): + """Test that link text content is preserved.""" + html = "Link Text" + root = parse_html(html) + + body = root.children[0] + anchor = body.children[0] + assert len(anchor.children) == 1 + assert isinstance(anchor.children[0], Text) + assert anchor.children[0].text.strip() == "Link Text" + + def test_parse_link_in_paragraph(self): + """Test parsing a link inside a paragraph.""" + html = "

Visit our site today!

" + root = parse_html(html) + + body = root.children[0] + # The parser may flatten this - check for anchor presence + found_anchor = False + + def find_anchor(node): + nonlocal found_anchor + if isinstance(node, Element) and node.tag == "a": + found_anchor = True + assert node.attributes.get("href") == "https://test.com" + if hasattr(node, "children"): + for child in node.children: + find_anchor(child) + + find_anchor(body) + assert found_anchor, "Anchor element not found" + + def test_parse_link_with_relative_href(self): + """Test parsing a link with a relative URL.""" + html = "About" + root = parse_html(html) + + body = root.children[0] + anchor = body.children[0] + assert anchor.attributes.get("href") == "/about" + + def test_parse_link_with_anchor_href(self): + """Test parsing a link with an anchor reference.""" + html = "Jump" + root = parse_html(html) + + body = root.children[0] + anchor = body.children[0] + assert anchor.attributes.get("href") == "#section" + + +class TestLinkLayout: + """Tests for link layout and styling.""" + + def test_link_layout_has_href(self): + """Test that layout lines for links include href.""" + html = "Link" + root = parse_html_with_styles(html) + + layout = DocumentLayout(root) + layout.layout(800) + + # Find line with href + link_lines = [line for line in layout.lines if line.href] + assert len(link_lines) > 0, "No link lines found" + assert link_lines[0].href == "https://example.com" + + def test_link_layout_has_color(self): + """Test that layout lines for links have a color.""" + html = "Link" + root = parse_html_with_styles(html) + + layout = DocumentLayout(root) + layout.layout(800) + + # Find line with color + link_lines = [line for line in layout.lines if line.href] + assert len(link_lines) > 0 + # Should have either CSS-specified color or default link color + assert link_lines[0].color is not None + + def test_non_link_has_no_href(self): + """Test that non-link elements don't have href.""" + html = "

Regular paragraph

" + root = parse_html_with_styles(html) + + layout = DocumentLayout(root) + layout.layout(800) + + # All lines should have no href + for line in layout.lines: + assert line.href is None + + def test_layout_line_constructor(self): + """Test LayoutLine constructor with color and href.""" + line = LayoutLine( + text="Click me", + x=10, + y=20, + font_size=14, + color="#0066cc", + href="https://example.com" + ) + + assert line.text == "Click me" + assert line.color == "#0066cc" + assert line.href == "https://example.com" + + def test_layout_line_default_values(self): + """Test LayoutLine defaults for color and href.""" + line = LayoutLine( + text="Normal text", + x=10, + y=20, + font_size=14 + ) + + assert line.color is None + assert line.href is None + + +class TestLinkURLResolution: + """Tests for URL resolution of links.""" + + def test_resolve_absolute_url(self): + """Test that absolute URLs are preserved.""" + base = URL("https://example.com/page") + resolved = base.resolve("https://other.com/path") + assert str(resolved) == "https://other.com/path" + + def test_resolve_relative_url(self): + """Test resolving a relative URL.""" + base = URL("https://example.com/page") + resolved = base.resolve("/about") + assert str(resolved) == "https://example.com/about" + + def test_resolve_relative_path(self): + """Test resolving a relative path.""" + base = URL("https://example.com/dir/page") + resolved = base.resolve("other") + assert str(resolved) == "https://example.com/dir/other" + + def test_resolve_parent_relative(self): + """Test resolving a parent-relative path.""" + base = URL("https://example.com/dir/subdir/page") + resolved = base.resolve("../other") + assert str(resolved) == "https://example.com/dir/other" + + def test_resolve_anchor_only(self): + """Test resolving an anchor-only URL.""" + base = URL("https://example.com/page") + resolved = base.resolve("#section") + assert str(resolved) == "https://example.com/page#section" + + def test_resolve_query_string(self): + """Test resolving a URL with query string.""" + base = URL("https://example.com/page") + resolved = base.resolve("?query=value") + assert str(resolved) == "https://example.com/page?query=value" + + +class TestRenderPipelineColorParsing: + """Tests for color parsing in the render pipeline. + + Note: These tests only run when skia is NOT mocked (i.e., when run in isolation). + When run after test_render.py, skia becomes a MagicMock and these tests are skipped. + """ + + def test_parse_hex_color_6digit(self): + """Test parsing 6-digit hex colors.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.Color, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + color = pipeline._parse_color("#0066cc") + + # Extract RGB components (Skia color is ARGB) + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + + assert r == 0x00 + assert g == 0x66 + assert b == 0xcc + + def test_parse_hex_color_3digit(self): + """Test parsing 3-digit hex colors.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.Color, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + color = pipeline._parse_color("#abc") + + # #abc should expand to #aabbcc + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + + # Each digit is doubled: a->aa, b->bb, c->cc + # But our implementation uses int("a" * 2, 16) which is int("aa", 16) = 170 + assert r == 0xaa + assert g == 0xbb + assert b == 0xcc + + def test_parse_named_color(self): + """Test parsing named colors.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.ColorBLACK, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + + # Test that named colors return a valid integer color value + black = pipeline._parse_color("black") + white = pipeline._parse_color("white") + red = pipeline._parse_color("red") + + # Black should be 0xFF000000 (opaque black in ARGB) + assert isinstance(black, int) + assert (black & 0xFFFFFF) == 0x000000 # RGB is 0 + + # White is converted to black because it would be invisible on white bg + assert isinstance(white, int) + assert (white & 0xFFFFFF) == 0x000000 # Converted to black + + # Red should have R=255, G=0, B=0 + assert isinstance(red, int) + r = (red >> 16) & 0xFF + assert r == 0xFF # Red component should be 255 + + def test_parse_invalid_color_returns_black(self): + """Test that invalid colors return black.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.ColorBLACK, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + + invalid = pipeline._parse_color("invalid") + invalid2 = pipeline._parse_color("#xyz") + invalid3 = pipeline._parse_color("") + + # All should return black (integer value) + assert isinstance(invalid, int) + assert isinstance(invalid2, int) + assert isinstance(invalid3, int) + + # RGB components should be 0 (black) + assert (invalid & 0xFFFFFF) == 0x000000 + + +class TestGetTextLayoutWithHref: + """Tests for text layout including href information.""" + + def test_get_text_layout_includes_href(self): + """Test that get_text_layout includes href for links.""" + from src.render.pipeline import RenderPipeline + + html = "Click" + root = parse_html_with_styles(html) + + pipeline = RenderPipeline() + pipeline.layout(root, 800) + + text_layout = pipeline.get_text_layout() + + # Find the link line + link_entries = [entry for entry in text_layout if entry.get("href")] + assert len(link_entries) > 0 + assert link_entries[0]["href"] == "https://example.com" + + def test_get_text_layout_normal_text_no_href(self): + """Test that normal text has no href in layout.""" + from src.render.pipeline import RenderPipeline + + html = "

Normal text

" + root = parse_html_with_styles(html) + + pipeline = RenderPipeline() + pipeline.layout(root, 800) + + text_layout = pipeline.get_text_layout() + + # All entries should have href=None + for entry in text_layout: + assert entry.get("href") is None + + +class TestLinkDefaultStyling: + """Tests for default link styling from CSS.""" + + def test_link_default_color_in_css(self): + """Test that default.css defines link color.""" + from pathlib import Path + + css_path = Path(__file__).parent.parent / "assets" / "default.css" + assert css_path.exists(), "default.css should exist" + + css_content = css_path.read_text() + + # Check that 'a' selector is defined with a color + assert "a {" in css_content or "a{" in css_content.replace(" ", "") + assert "color:" in css_content diff --git a/tests/test_styling_integration.py b/tests/test_styling_integration.py index 1bf2231..a6aa051 100644 --- a/tests/test_styling_integration.py +++ b/tests/test_styling_integration.py @@ -1,13 +1,13 @@ """Integration tests for CSS styling system.""" import pytest -from src.parser.html import parse_html_with_styles, Element +from src.parser.html import parse_html_with_styles from src.layout.document import DocumentLayout class TestStyleIntegration: """Test end-to-end CSS parsing and layout integration.""" - + def test_parse_with_style_tag(self): html = """ @@ -22,7 +22,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the p element p_elem = None for child in root.children: @@ -31,12 +31,12 @@ class TestStyleIntegration: if hasattr(grandchild, "tag") and grandchild.tag == "p": p_elem = grandchild break - + assert p_elem is not None assert hasattr(p_elem, "computed_style") assert p_elem.computed_style.get("color") == "red" assert p_elem.computed_style.get("font-size") == "18px" - + def test_inline_style_override(self): html = """ @@ -46,7 +46,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -56,9 +56,9 @@ class TestStyleIntegration: assert p_elem.computed_style.get("color") == "blue" assert p_elem.computed_style.get("font-size") == "20px" return - + pytest.fail("P element not found") - + def test_cascade_priority(self): html = """ @@ -78,24 +78,24 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find body body = None for child in root.children: if hasattr(child, "tag") and child.tag == "body": body = child break - + assert body is not None paragraphs = [c for c in body.children if hasattr(c, "tag") and c.tag == "p"] assert len(paragraphs) == 4 - + # Check cascade assert paragraphs[0].computed_style.get("color") == "red" # Tag only assert paragraphs[1].computed_style.get("color") == "green" # Class wins assert paragraphs[2].computed_style.get("color") == "blue" # ID wins assert paragraphs[3].computed_style.get("color") == "purple" # Inline wins - + def test_inheritance(self): html = """ @@ -112,7 +112,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the nested p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -150,10 +150,10 @@ class TestStyleIntegration: lines = layout.layout(800) # H1 should use custom font size assert lines[0].font_size == 40 - + # P should use custom font size assert lines[1].font_size == 20 - + def test_multiple_classes(self): html = """ @@ -169,7 +169,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -179,9 +179,9 @@ class TestStyleIntegration: assert grandchild.computed_style.get("font-size") == "24px" assert grandchild.computed_style.get("color") == "red" return - + pytest.fail("P element not found") - + def test_default_styles_applied(self): html = """ @@ -193,20 +193,20 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find elements body = None for child in root.children: if hasattr(child, "tag") and child.tag == "body": body = child break - + assert body is not None - + h1 = next((c for c in body.children if hasattr(c, "tag") and c.tag == "h1"), None) p = next((c for c in body.children if hasattr(c, "tag") and c.tag == "p"), None) a = next((c for c in body.children if hasattr(c, "tag") and c.tag == "a"), None) - + # Check default styles from default.css assert h1 is not None # Font-size from default.css is 2.5rem @@ -220,7 +220,7 @@ class TestStyleIntegration: # Link color from default.css assert a.computed_style.get("color") == "#0066cc" assert a.computed_style.get("text-decoration") == "none" - + def test_no_styles_when_disabled(self): html = """ @@ -235,7 +235,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html, apply_styles=False) - + # Find the p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -244,5 +244,5 @@ class TestStyleIntegration: # Should not have computed_style when disabled assert not hasattr(grandchild, "computed_style") return - + pytest.fail("P element not found") diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 3f07b69..daa78dd 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,190 +1,189 @@ """Tests for the async task queue system.""" -import pytest import time import threading -from unittest.mock import Mock, patch +from unittest.mock import patch class TestTaskQueue: """Tests for the TaskQueue class.""" - + def test_task_queue_singleton(self): """Test that TaskQueue is a singleton.""" from src.network.tasks import TaskQueue - + # Reset singleton for clean test TaskQueue.reset_instance() - + q1 = TaskQueue() q2 = TaskQueue() - + assert q1 is q2 - + # Clean up TaskQueue.reset_instance() - + def test_submit_task_returns_id(self): """Test that submit returns a task ID.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + # Mock GLib.idle_add to avoid GTK dependency with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + task_id = queue.submit(lambda: 42) - + # Task ID should be non-negative (or -1 for cached) assert isinstance(task_id, int) - + # Wait for task to complete time.sleep(0.1) TaskQueue.reset_instance() - + def test_task_executes_function(self): """Test that submitted tasks are executed.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + result = [] - event = threading.Event() - + threading.Event() + def task(): result.append("executed") return "done" - + with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + queue.submit(task) - + # Wait for task to complete time.sleep(0.2) - + assert "executed" in result - + TaskQueue.reset_instance() - + def test_on_complete_callback(self): """Test that on_complete callback is called with result.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + results = [] - + def task(): return 42 - + def on_complete(result): results.append(result) - + with patch('src.network.tasks.GLib') as mock_glib: # Make idle_add execute immediately mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + queue.submit(task, on_complete=on_complete) - + # Wait for task to complete (may need more time under load) for _ in range(10): if 42 in results: break time.sleep(0.05) - + assert 42 in results - + TaskQueue.reset_instance() - + def test_on_error_callback(self): """Test that on_error callback is called on exception.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + errors = [] - + def failing_task(): raise ValueError("Test error") - + def on_error(e): errors.append(str(e)) - + with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + queue.submit(failing_task, on_error=on_error) - + # Wait for task to complete (may need more time under load) for _ in range(10): if len(errors) == 1: break time.sleep(0.05) - + assert len(errors) == 1 assert "Test error" in errors[0] - + TaskQueue.reset_instance() - + def test_cancel_task(self): """Test task cancellation.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + result = [] - + def slow_task(): time.sleep(1) result.append("completed") return True - + with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + task_id = queue.submit(slow_task) - + # Cancel immediately cancelled = queue.cancel(task_id) - + # May or may not be cancellable depending on timing assert isinstance(cancelled, bool) - + # Wait briefly time.sleep(0.1) - + TaskQueue.reset_instance() - + def test_pending_count(self): """Test pending task count.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + initial_count = queue.pending_count assert initial_count >= 0 - + TaskQueue.reset_instance() class TestAsyncImageLoading: """Tests for async image loading.""" - + def test_load_image_async_cached(self): """Test that cached images return -1 (no task needed).""" from src.network.images import load_image_async, load_image, ImageCache - + # Clear cache ImageCache().clear() - + # Load an image synchronously first (to cache it) data_url = ( "data:image/png;base64," @@ -192,43 +191,43 @@ class TestAsyncImageLoading: ) image = load_image(data_url) assert image is not None - + # Now load async - should hit cache and return -1 (no task) # We don't need a callback for this test - just checking return value task_id = load_image_async(data_url, on_complete=None) - + # Cached loads return -1 (no task created) assert task_id == -1 - + def test_load_image_async_uncached(self): """Test that uncached images create tasks.""" from src.network.images import load_image_async, ImageCache from src.network.tasks import TaskQueue - + # Clear cache ImageCache().clear() TaskQueue.reset_instance() - + # Use a data URL that's not cached data_url = ( "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAADklEQVR42mP8z8DwHwYAAQYBA/5h2aw4AAAAAElFTkSuQmCC" ) - + # Patch GLib.idle_add to call callbacks immediately (no GTK main loop in tests) with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + # Without a callback, it just submits the task task_id = load_image_async(data_url, on_complete=None) - + # Should create a task (non-negative ID) assert task_id >= 0 - + # Wait for task to complete time.sleep(0.3) - + # Image should now be cached assert ImageCache().has(data_url) - + TaskQueue.reset_instance()