diff --git a/src/browser/chrome.py b/src/browser/chrome.py index c010381..e030d1e 100644 --- a/src/browser/chrome.py +++ b/src/browser/chrome.py @@ -13,9 +13,9 @@ gi.require_version("Adw", "1") from gi.repository import Gtk, Gdk, Adw import skia -# Import the render and layout packages +# Import the render pipeline +from ..render.pipeline import RenderPipeline from ..render.fonts import get_font -from ..layout.document import DocumentLayout class Chrome: @@ -35,6 +35,9 @@ class Chrome: self.tab_pages: dict = {} # Map tab objects to AdwTabPage self._closing_tabs: set = set() # Track tabs being closed to prevent re-entry + # Render pipeline - handles layout and painting + self.render_pipeline = RenderPipeline() + # Debug mode state self.debug_mode = False @@ -60,19 +63,9 @@ class Chrome: self.selection_end = None # (x, y) of selection end self.is_selecting = False # True while mouse is dragging - # Layout information for text selection - # Each entry: {text, x, y, width, height, font_size, char_positions} - # char_positions is a list of x offsets for each character + # Layout information for text selection (populated from render pipeline) self.text_layout = [] - # Layout cache to avoid recalculation on scroll - self._layout_cache_width = 0 - self._layout_cache_doc_id = None - self._layout_rects = [] # Cached debug rects - - # Paint cache - self._text_paint = None - # Sub-timings for detailed profiling self._render_sub_timings = {} self._visible_line_count = 0 @@ -373,7 +366,7 @@ class Chrome: paint = skia.Paint() paint.setAntiAlias(True) paint.setColor(skia.ColorBLACK) - font = self._get_font(20) + font = get_font(20) canvas.drawString("Bowser — Enter a URL to browse", 20, 50, font, paint) # Get raw pixel data from Skia surface @@ -410,118 +403,38 @@ class Chrome: self._last_profile_total = total_time def _render_dom_content(self, canvas, document, width: int, height: int): - """Render a basic DOM tree with headings, paragraphs, and lists.""" + """Render the DOM content using the render pipeline.""" sub_timings = {} - # Check if we need to rebuild layout cache + # Sync debug mode with render pipeline + self.render_pipeline.debug_mode = self.debug_mode + + # Use render pipeline for layout and rendering t0 = time.perf_counter() - doc_id = id(document) - needs_rebuild = ( - self._layout_cache_doc_id != doc_id or - self._layout_cache_width != width or - not self.text_layout - ) + self.render_pipeline.render(canvas, document, width, height, self.scroll_y) + sub_timings['render'] = time.perf_counter() - t0 - if needs_rebuild: - self._rebuild_layout(document, width) - self._layout_cache_doc_id = doc_id - self._layout_cache_width = width - self.logger.debug(f"Layout rebuilt: {len(self.text_layout)} lines") - sub_timings['layout_check'] = time.perf_counter() - t0 - - if not self.text_layout: - return - - # Apply scroll offset + # Get text layout for selection t0 = time.perf_counter() - canvas.save() - canvas.translate(0, -self.scroll_y) - sub_timings['transform'] = time.perf_counter() - t0 + self.text_layout = self.render_pipeline.get_text_layout() + self.document_height = self.render_pipeline.get_document_height() + sub_timings['get_layout'] = time.perf_counter() - t0 - # Get or create cached paint - if self._text_paint is None: - self._text_paint = skia.Paint() - self._text_paint.setAntiAlias(True) - self._text_paint.setColor(skia.ColorBLACK) - - # Only draw visible lines - t0 = time.perf_counter() - visible_top = self.scroll_y - 50 - visible_bottom = self.scroll_y + height + 50 - - visible_count = 0 - for line_info in self.text_layout: - line_y = line_info["y"] + line_info["font_size"] # Baseline y - if line_y < visible_top or line_y - line_info["height"] > visible_bottom: - continue - - visible_count += 1 - font = self._get_font(line_info["font_size"]) - canvas.drawString(line_info["text"], line_info["x"], line_y, font, self._text_paint) - sub_timings['draw_text'] = time.perf_counter() - t0 - - # Draw selection highlight + # Draw selection highlight (still in chrome as it's UI interaction) t0 = time.perf_counter() if self.selection_start and self.selection_end: + canvas.save() + canvas.translate(0, -self.scroll_y) self._draw_text_selection(canvas) + canvas.restore() sub_timings['selection'] = time.perf_counter() - t0 - # Draw debug overlays - t0 = time.perf_counter() - if self.debug_mode: - self._draw_debug_overlays(canvas, self._layout_rects, document) - sub_timings['debug_overlay'] = time.perf_counter() - t0 - - t0 = time.perf_counter() - canvas.restore() - sub_timings['restore'] = time.perf_counter() - t0 - # Store sub-timings for display if self.debug_mode: self._render_sub_timings = sub_timings - self._visible_line_count = visible_count - - def _get_font(self, size: int): - """Get a cached font for the given size.""" - return get_font(size) - - def _rebuild_layout(self, body, width: int): - """Rebuild the layout cache for text positioning using DocumentLayout.""" - self.text_layout = [] - self._layout_rects = [] - - # Use the new DocumentLayout for layout calculation - doc_layout = DocumentLayout(body) - layout_lines = doc_layout.layout(width) - - # Convert LayoutLine objects to text_layout format - x_margin = 20 - max_width = max(10, width - 2 * x_margin) - - for line in layout_lines: - self.text_layout.append({ - "text": line.text, - "x": line.x, - "y": line.y, # Top of line - "width": line.width, - "height": line.height, - "font_size": line.font_size, - "char_positions": line.char_positions - }) - - # Build layout rects for debug mode from blocks - for block in doc_layout.blocks: - self._layout_rects.append({ - "x": block.x - 5, - "y": block.y - block.lines[0].font_size if block.lines else block.y, - "width": block.width + 10, - "height": block.height + 5, - "type": block.block_type - }) - - # Store total document height - self.document_height = doc_layout.height + self._visible_line_count = len([l for l in self.text_layout + if self.scroll_y - 50 <= l["y"] + l["font_size"] <= self.scroll_y + height + 50]) def _draw_selection_highlight(self, canvas, width: int): """Draw selection highlight rectangle.""" @@ -544,87 +457,10 @@ class Chrome: rect = skia.Rect.MakeLTRB(left, top, right, bottom) canvas.drawRect(rect, paint) - def _draw_debug_overlays(self, canvas, layout_rects: list, document): - """Draw debug overlays showing element boxes.""" - # Color scheme for different element types - colors = { - "block": skia.Color(255, 0, 0, 60), # Red - block elements - "inline": skia.Color(0, 0, 255, 60), # Blue - inline elements - "list-item": skia.Color(0, 255, 0, 60), # Green - list items - "text": skia.Color(255, 255, 0, 60), # Yellow - text nodes - } - - border_colors = { - "block": skia.Color(255, 0, 0, 180), - "inline": skia.Color(0, 0, 255, 180), - "list-item": skia.Color(0, 255, 0, 180), - "text": skia.Color(255, 255, 0, 180), - } - - for rect_info in layout_rects: - block_type = rect_info.get("type", "block") - - # Fill - fill_paint = skia.Paint() - fill_paint.setColor(colors.get(block_type, colors["block"])) - fill_paint.setStyle(skia.Paint.kFill_Style) - - rect = skia.Rect.MakeLTRB( - rect_info["x"], - rect_info["y"], - rect_info["x"] + rect_info["width"], - rect_info["y"] + rect_info["height"] - ) - canvas.drawRect(rect, fill_paint) - - # Border - border_paint = skia.Paint() - border_paint.setColor(border_colors.get(block_type, border_colors["block"])) - border_paint.setStyle(skia.Paint.kStroke_Style) - border_paint.setStrokeWidth(1) - canvas.drawRect(rect, border_paint) - - # Draw legend in top-right corner - self._draw_debug_legend(canvas) - - def _draw_debug_legend(self, canvas): - """Draw debug mode legend.""" - # Position in screen coordinates (add scroll offset back) - legend_x = 10 - legend_y = self.scroll_y + 10 - - font = self._get_font(11) - - # Background - bg_paint = skia.Paint() - bg_paint.setColor(skia.Color(0, 0, 0, 200)) - bg_paint.setStyle(skia.Paint.kFill_Style) - canvas.drawRect(skia.Rect.MakeLTRB(legend_x, legend_y, legend_x + 150, legend_y + 85), bg_paint) - - text_paint = skia.Paint() - text_paint.setColor(skia.ColorWHITE) - text_paint.setAntiAlias(True) - - canvas.drawString("DEBUG MODE (Ctrl+Shift+O)", legend_x + 5, legend_y + 15, font, text_paint) - - items = [ - ("Red", "Block elements", skia.Color(255, 100, 100, 255)), - ("Blue", "Inline elements", skia.Color(100, 100, 255, 255)), - ("Green", "List items", skia.Color(100, 255, 100, 255)), - ] - - y_offset = 30 - for label, desc, color in items: - color_paint = skia.Paint() - color_paint.setColor(color) - canvas.drawRect(skia.Rect.MakeLTRB(legend_x + 5, legend_y + y_offset, legend_x + 15, legend_y + y_offset + 10), color_paint) - canvas.drawString(f"{label}: {desc}", legend_x + 20, legend_y + y_offset + 10, font, text_paint) - y_offset += 18 - def _draw_fps_counter(self, canvas, width: int): """Draw FPS counter and profiling info in top-right corner.""" - font = self._get_font(11) - small_font = self._get_font(9) + font = get_font(11) + small_font = get_font(9) # Calculate panel size based on profile data panel_width = 200 diff --git a/src/parser/html.py b/src/parser/html.py index 2f3863b..294c3c8 100644 --- a/src/parser/html.py +++ b/src/parser/html.py @@ -9,6 +9,8 @@ class Text: def __init__(self, text, parent=None): self.text = text self.parent = parent + # Layout reference (set by layout engine) + self.layout = None def __repr__(self): # pragma: no cover - debug helper return f"Text({self.text!r})" @@ -20,9 +22,20 @@ class Element: self.attributes = attributes or {} self.children = [] self.parent = parent + # Layout reference (set by layout engine) + self.layout = None def __repr__(self): # pragma: no cover - debug helper return f"Element({self.tag!r}, {self.attributes!r})" + + @property + def bounding_box(self): + """Get bounding box from layout if available.""" + if self.layout: + return (self.layout.x, self.layout.y, + self.layout.x + self.layout.width, + self.layout.y + self.layout.height) + return None def print_tree(node, indent=0): diff --git a/src/render/pipeline.py b/src/render/pipeline.py new file mode 100644 index 0000000..3652d60 --- /dev/null +++ b/src/render/pipeline.py @@ -0,0 +1,171 @@ +"""Render pipeline - coordinates layout and painting.""" + +import skia +from typing import Optional +from ..parser.html import Element +from ..layout.document import DocumentLayout +from .fonts import get_font +from .paint import DisplayList, DrawText, DrawRect + + +class RenderPipeline: + """Coordinates layout calculation and rendering to a Skia canvas.""" + + def __init__(self): + # Layout cache + self._layout: Optional[DocumentLayout] = None + self._layout_width = 0 + self._layout_doc_id = None + + # Paint cache + self._text_paint: Optional[skia.Paint] = None + self._display_list: Optional[DisplayList] = None + + # Debug mode + self.debug_mode = False + + def layout(self, document: Element, width: int) -> DocumentLayout: + """ + Calculate layout for the document. + Returns the DocumentLayout with all positioned elements. + """ + doc_id = id(document) + + # Check cache + if (self._layout_doc_id == doc_id and + self._layout_width == width and + self._layout is not None): + return self._layout + + # Build new layout + self._layout = DocumentLayout(document) + self._layout.layout(width) + self._layout_doc_id = doc_id + self._layout_width = width + + return self._layout + + def render(self, canvas: skia.Canvas, document: Element, + width: int, height: int, scroll_y: float = 0): + """ + Render the document to the canvas. + + Args: + canvas: Skia canvas to draw on + document: DOM document root + width: Viewport width + height: Viewport height + scroll_y: Vertical scroll offset + """ + # Get or update layout + layout = self.layout(document, width) + + if not layout.lines: + return + + # Apply scroll transform + canvas.save() + canvas.translate(0, -scroll_y) + + # Get paint + if self._text_paint is None: + self._text_paint = skia.Paint() + self._text_paint.setAntiAlias(True) + self._text_paint.setColor(skia.ColorBLACK) + + # Render visible lines only + visible_top = scroll_y - 50 + visible_bottom = scroll_y + height + 50 + + for line in layout.lines: + baseline_y = line.y + line.font_size + if baseline_y < visible_top or line.y > visible_bottom: + continue + + font = get_font(line.font_size) + canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint) + + # Draw debug overlays if enabled + if self.debug_mode: + self._render_debug_overlays(canvas, layout) + + canvas.restore() + + def _render_debug_overlays(self, canvas: skia.Canvas, layout: DocumentLayout): + """Render debug bounding boxes for layout blocks.""" + # Color scheme for different block types + colors = { + "block": (255, 0, 0, 60), # Red + "inline": (0, 0, 255, 60), # Blue + "list-item": (0, 255, 0, 60), # Green + "text": (255, 255, 0, 60), # Yellow + } + + border_colors = { + "block": (255, 0, 0, 180), + "inline": (0, 0, 255, 180), + "list-item": (0, 255, 0, 180), + "text": (255, 255, 0, 180), + } + + for block in layout.blocks: + block_type = block.block_type + + # Calculate block bounds from lines + if not block.lines: + continue + + x = block.x - 5 + y = block.y - block.lines[0].font_size if block.lines else block.y + w = block.width + 10 + h = block.height + 5 + + # Fill + fill_paint = skia.Paint() + c = colors.get(block_type, colors["block"]) + fill_paint.setColor(skia.Color(*c)) + fill_paint.setStyle(skia.Paint.kFill_Style) + + rect = skia.Rect.MakeLTRB(x, y, x + w, y + h) + canvas.drawRect(rect, fill_paint) + + # Border + border_paint = skia.Paint() + bc = border_colors.get(block_type, border_colors["block"]) + border_paint.setColor(skia.Color(*bc)) + border_paint.setStyle(skia.Paint.kStroke_Style) + border_paint.setStrokeWidth(1) + canvas.drawRect(rect, border_paint) + + def get_text_layout(self) -> list: + """ + Get the text layout for text selection. + Returns list of line info dicts with char_positions. + """ + if self._layout is None: + return [] + + result = [] + for line in self._layout.lines: + result.append({ + "text": line.text, + "x": line.x, + "y": line.y, + "width": line.width, + "height": line.height, + "font_size": line.font_size, + "char_positions": line.char_positions + }) + return result + + def get_document_height(self) -> float: + """Get the total document height for scrolling.""" + if self._layout is None: + return 0 + return self._layout.height + + def invalidate(self): + """Invalidate the layout cache, forcing recalculation.""" + self._layout = None + self._layout_doc_id = None + self._display_list = None