"""Browser chrome (Adwaita UI).""" import gi from typing import Optional import logging from functools import partial gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Gdk, GdkPixbuf, Adw import skia class Chrome: def __init__(self, browser): self.logger = logging.getLogger("bowser.chrome") self.browser = browser self.window: Optional[Adw.ApplicationWindow] = None self.address_bar: Optional[Gtk.Entry] = None self.back_btn: Optional[Gtk.Button] = None self.forward_btn: Optional[Gtk.Button] = None self.reload_btn: Optional[Gtk.Button] = None self.go_btn: Optional[Gtk.Button] = None self.drawing_area: Optional[Gtk.DrawingArea] = None self.tab_view: Optional[Adw.TabView] = None self.tab_bar: Optional[Adw.TabBar] = None self.skia_surface: Optional[skia.Surface] = None self.tab_pages: dict = {} # Map tab objects to AdwTabPage self._closing_tabs: set = set() # Track tabs being closed to prevent re-entry # Debug mode state self.debug_mode = False # FPS tracking for debug mode self.frame_times = [] # List of recent frame timestamps self.fps = 0.0 # Scroll state self.scroll_y = 0 self.document_height = 0 # Total document height for scroll limits self.viewport_height = 0 # Current viewport height # Scrollbar fade state self.scrollbar_opacity = 0.0 self.scrollbar_fade_timeout = None # Selection state self.selection_start = None # (x, y) of selection start 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, font, char_positions} # char_positions is a list of x offsets for each character self.text_layout = [] def create_window(self): """Initialize the Adwaita application window.""" # Initialize Adwaita application if not hasattr(self.browser.app, '_adw_init'): Adw.init() self.browser.app._adw_init = True # Create Adwaita window instead of standard GTK window self.window = Adw.ApplicationWindow(application=self.browser.app) self.window.set_default_size(1024, 768) self.window.set_title("Bowser") # Main vertical box for the window structure vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) self.window.set_content(vbox) # Header bar with navigation and address bar header_bar = Gtk.HeaderBar() vbox.append(header_bar) # Navigation buttons in header bar nav_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) nav_box.add_css_class("linked") self.back_btn = Gtk.Button(label="◀") self.back_btn.set_tooltip_text("Back") self.forward_btn = Gtk.Button(label="▶") self.forward_btn.set_tooltip_text("Forward") self.reload_btn = Gtk.Button(label="⟳") self.reload_btn.set_tooltip_text("Reload") nav_box.append(self.back_btn) nav_box.append(self.forward_btn) nav_box.append(self.reload_btn) header_bar.pack_start(nav_box) # Address bar - centered in header self.address_bar = Gtk.Entry() self.address_bar.set_placeholder_text("Enter URL...") self.address_bar.set_hexpand(True) self.address_bar.set_max_width_chars(40) header_bar.set_title_widget(self.address_bar) # Go button in header bar end self.go_btn = Gtk.Button(label="Go") self.go_btn.add_css_class("suggested-action") header_bar.pack_end(self.go_btn) # Create TabView for managing tabs self.tab_view = Adw.TabView() # Create TabBar for tab display self.tab_bar = Adw.TabBar() self.tab_bar.set_view(self.tab_view) self.tab_bar.set_autohide(False) # Add New Tab button to the tab bar new_tab_btn = Gtk.Button() new_tab_btn.set_icon_name("list-add-symbolic") new_tab_btn.set_tooltip_text("New Tab") new_tab_btn.connect("clicked", lambda _: self.browser.new_tab("about:startpage")) self.tab_bar.set_end_action_widget(new_tab_btn) vbox.append(self.tab_bar) # Create a container box for content that will hold the drawing area content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) content_box.set_vexpand(True) content_box.set_hexpand(True) # Create the drawing area for rendering page content self.drawing_area = Gtk.DrawingArea() self.drawing_area.set_vexpand(True) self.drawing_area.set_hexpand(True) self.drawing_area.set_draw_func(self.on_draw) self.drawing_area.set_can_focus(True) # Allow focus for keyboard events self.drawing_area.set_focusable(True) content_box.append(self.drawing_area) # Add scroll controller for mouse wheel scroll_controller = Gtk.EventControllerScroll.new( Gtk.EventControllerScrollFlags.VERTICAL ) scroll_controller.connect("scroll", self._on_scroll) self.drawing_area.add_controller(scroll_controller) # Add mouse button controller for selection click_controller = Gtk.GestureClick.new() click_controller.connect("pressed", self._on_mouse_pressed) click_controller.connect("released", self._on_mouse_released) self.drawing_area.add_controller(click_controller) # Add motion controller for drag selection motion_controller = Gtk.EventControllerMotion.new() motion_controller.connect("motion", self._on_mouse_motion) self.drawing_area.add_controller(motion_controller) # Add content box to vbox (not to TabView - we use a single drawing area for all tabs) vbox.append(content_box) # Status bar with Adwaita styling status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) status_box.add_css_class("toolbar") status_bar = Gtk.Label(label="Ready") status_bar.set_xalign(0) status_bar.set_margin_start(8) status_bar.set_margin_end(8) status_box.append(status_bar) vbox.append(status_box) # Wire handlers if self.address_bar: self.address_bar.connect("activate", self._on_addressbar_activate) if self.go_btn: self.go_btn.connect("clicked", self._on_go_clicked) if self.back_btn: self.back_btn.connect("clicked", lambda _b: self.browser.go_back()) if self.forward_btn: self.forward_btn.connect("clicked", lambda _b: self.browser.go_forward()) if self.reload_btn: self.reload_btn.connect("clicked", lambda _b: self.browser.reload()) # Connect TabView signals if self.tab_view: self.tab_view.connect("page-attached", self._on_page_attached) self.tab_view.connect("close-page", self._on_close_page) self.tab_view.connect("notify::selected-page", self._on_selected_page_changed) # Setup keyboard shortcuts self._setup_keyboard_shortcuts() # Add any tabs that were created before the window for tab in self.browser.tabs: if tab not in self.tab_pages: self._add_tab_to_ui(tab) # Set the active tab in the UI if self.browser.active_tab and self.browser.active_tab in self.tab_pages: page = self.tab_pages[self.browser.active_tab] self.tab_view.set_selected_page(page) # Show the window self.window.present() def _add_tab_to_ui(self, tab): """Internal method to add a tab to the TabView UI.""" # Create a simple placeholder for the tab # (Actual rendering happens in the shared drawing_area) placeholder = Gtk.Box() # Create the tab page page = self.tab_view.append(placeholder) page.set_title(tab.title) # Store mapping self.tab_pages[tab] = page # Select this tab self.tab_view.set_selected_page(page) def add_tab(self, tab): """Add a tab to the TabView.""" if not self.tab_view: # Window not created yet, tab will be added when window is created return self._add_tab_to_ui(tab) def update_tab(self, tab): """Update tab title and other properties.""" if tab in self.tab_pages: page = self.tab_pages[tab] page.set_title(tab.title) def remove_tab(self, tab): """Remove a tab from the TabView programmatically.""" if tab not in self.tab_pages: return page = self.tab_pages[tab] del self.tab_pages[tab] # Directly close the page - this triggers _on_close_page but we've already removed from tab_pages self.tab_view.close_page(page) def set_active_tab(self, tab): """Set the active tab in the TabView.""" if tab in self.tab_pages: page = self.tab_pages[tab] self.tab_view.set_selected_page(page) def _on_page_attached(self, tab_view, page, position): """Handle when a page is attached to the TabView.""" self.logger.debug(f"Page attached at position {position}") def _on_close_page(self, tab_view, page): """Handle tab close request from UI. This is called when close_page() is invoked. We must call close_page_finish() to actually complete the page removal. """ # Find the tab associated with this page tab_to_close = None for tab, tab_page in list(self.tab_pages.items()): if tab_page == page: tab_to_close = tab break if tab_to_close: # Remove from our tracking del self.tab_pages[tab_to_close] # Confirm the close - this actually removes the page from TabView self.tab_view.close_page_finish(page, True) # Call browser cleanup (but don't call remove_tab since we already handled it) self.browser.close_tab(tab_to_close) return True else: # Page not in our tracking - just confirm the close self.tab_view.close_page_finish(page, True) return True def _on_selected_page_changed(self, tab_view, pspec): """Handle tab selection change.""" selected_page = tab_view.get_selected_page() if selected_page: # Find the tab associated with this page for tab, page in self.tab_pages.items(): if page == selected_page: self.browser.set_active_tab(tab) break def _on_new_tab_clicked(self, btn: Gtk.Button): """Handle new tab button click.""" self.browser.new_tab("about:startpage") def update_address_bar(self): if not self.address_bar: return url = None if self.browser.active_tab and self.browser.active_tab.current_url: url = str(self.browser.active_tab.current_url) self.address_bar.set_text(url or "") # Handlers def _on_addressbar_activate(self, entry: Gtk.Entry): self.browser.navigate_to(entry.get_text()) def _on_go_clicked(self, _btn: Gtk.Button): if self.address_bar: self.browser.navigate_to(self.address_bar.get_text()) def on_draw(self, drawing_area, context, width, height): """Callback for drawing the content area using Skia.""" import time # Track frame time for FPS calculation current_time = time.time() self.frame_times.append(current_time) # Keep only last 60 frame times (about 1 second at 60fps) self.frame_times = [t for t in self.frame_times if current_time - t < 1.0] if len(self.frame_times) > 1: self.fps = len(self.frame_times) self.logger.debug(f"on_draw start {width}x{height}") # Create Skia surface for this frame self.skia_surface = skia.Surface(width, height) canvas = self.skia_surface.getCanvas() # Store viewport height self.viewport_height = height # White background canvas.clear(skia.ColorWHITE) # Render DOM content frame = self.browser.active_tab.main_frame if self.browser.active_tab else None document = frame.document if frame else None if document: self._render_dom_content(canvas, document, width, height) # Draw scrollbar on top (in screen coordinates, not scrolled) self._draw_scrollbar(canvas, width, height) # Draw FPS counter in debug mode if self.debug_mode: self._draw_fps_counter(canvas, width) else: paint = skia.Paint() paint.setAntiAlias(True) paint.setColor(skia.ColorBLACK) font = skia.Font(skia.Typeface.MakeDefault(), 20) canvas.drawString("Bowser — Enter a URL to browse", 20, 50, font, paint) # Convert Skia surface to GTK Pixbuf and blit to Cairo context image = self.skia_surface.makeImageSnapshot() png_data = image.encodeToData().bytes() # Load PNG data into a Pixbuf from io import BytesIO loader = GdkPixbuf.PixbufLoader.new_with_type("png") loader.write(png_data) loader.close() pixbuf = loader.get_pixbuf() # Render pixbuf to Cairo context Gdk.cairo_set_source_pixbuf(context, pixbuf, 0, 0) context.paint() self.logger.debug("on_draw end") def _render_dom_content(self, canvas, document, width: int, height: int): """Render a basic DOM tree with headings, paragraphs, and lists.""" from ..parser.html import Element, Text body = self._find_body(document) if not body: return # Clear text layout for this render self.text_layout = [] # Apply scroll offset canvas.save() canvas.translate(0, -self.scroll_y) blocks = self._collect_blocks(body) paint = skia.Paint() paint.setAntiAlias(True) paint.setColor(skia.ColorBLACK) x_margin = 20 max_width = max(10, width - 2 * x_margin) y = 30 # Track layout for debug mode layout_rects = [] for block in blocks: font_size = block.get("font_size", 14) font = skia.Font(skia.Typeface.MakeDefault(), font_size) text = block.get("text", "") if not text: y += font_size * 0.6 continue # Optional bullet prefix if block.get("bullet"): text = f"• {text}" # Word wrapping per block words = text.split() lines = [] current_line = [] current_width = 0 for word in words: word_width = font.measureText(word + " ") if current_width + word_width > max_width and current_line: lines.append(" ".join(current_line)) current_line = [word] current_width = word_width else: current_line.append(word) current_width += word_width if current_line: lines.append(" ".join(current_line)) line_height = font_size * 1.4 top_margin = block.get("margin_top", 6) y += top_margin block_start_y = y for line in lines: # Only render if visible (accounting for scroll) visible_y = y - self.scroll_y if visible_y > -50 and visible_y < height + 50: canvas.drawString(line, x_margin, y, font, paint) # Calculate character positions for precise selection char_positions = [0.0] # Start at 0 for i in range(1, len(line) + 1): char_positions.append(font.measureText(line[:i])) # Store text layout for selection line_width = font.measureText(line) self.text_layout.append({ "text": line, "x": x_margin, "y": y - font_size, # Top of line "width": line_width, "height": line_height, "font_size": font_size, "char_positions": char_positions }) y += line_height block_end_y = y y += block.get("margin_bottom", 10) # Store layout for debug mode if self.debug_mode: block_type = block.get("block_type", "block") layout_rects.append({ "x": x_margin - 5, "y": block_start_y - font_size, "width": max_width + 10, "height": block_end_y - block_start_y + 5, "type": block_type }) # Store total document height self.document_height = y + 50 # Add some padding at the bottom # Draw selection highlight based on text layout if self.selection_start and self.selection_end: self._draw_text_selection(canvas) # Draw debug overlays if self.debug_mode: self._draw_debug_overlays(canvas, layout_rects, document) canvas.restore() def _find_body(self, document): from ..parser.html import Element if isinstance(document, Element) and document.tag == "body": return document if hasattr(document, "children"): for child in document.children: if isinstance(child, Element) and child.tag == "body": return child found = self._find_body(child) if found: return found return None def _collect_blocks(self, node): """Flatten DOM into renderable blocks with basic styling.""" from ..parser.html import Element, Text blocks = [] def text_of(n): if isinstance(n, Text): return n.text if isinstance(n, Element): parts = [] for c in n.children: parts.append(text_of(c)) return " ".join([p for p in parts if p]).strip() return "" for child in getattr(node, "children", []): if isinstance(child, Text): txt = child.text.strip() if txt: blocks.append({"text": txt, "font_size": 14}) continue if isinstance(child, Element): tag = child.tag.lower() content = text_of(child) if not content: continue if tag == "h1": blocks.append({"text": content, "font_size": 24, "margin_top": 12, "margin_bottom": 12, "block_type": "block", "tag": "h1"}) elif tag == "h2": blocks.append({"text": content, "font_size": 20, "margin_top": 10, "margin_bottom": 10, "block_type": "block", "tag": "h2"}) elif tag == "h3": blocks.append({"text": content, "font_size": 18, "margin_top": 8, "margin_bottom": 8, "block_type": "block", "tag": "h3"}) elif tag == "p": blocks.append({"text": content, "font_size": 14, "margin_top": 6, "margin_bottom": 12, "block_type": "block", "tag": "p"}) elif tag == "li": blocks.append({"text": content, "font_size": 14, "bullet": True, "margin_top": 4, "margin_bottom": 4, "block_type": "list-item", "tag": "li"}) elif tag in {"ul", "ol"}: blocks.extend(self._collect_blocks(child)) elif tag in {"span", "a", "strong", "em", "b", "i", "code"}: # Inline elements blocks.append({"text": content, "font_size": 14, "block_type": "inline", "tag": tag}) else: # Generic element: render text blocks.append({"text": content, "font_size": 14, "block_type": "block", "tag": tag}) return blocks def _draw_selection_highlight(self, canvas, width: int): """Draw selection highlight rectangle.""" if not self.selection_start or not self.selection_end: return x1, y1 = self.selection_start x2, y2 = self.selection_end # Normalize coordinates left = min(x1, x2) right = max(x1, x2) top = min(y1, y2) bottom = max(y1, y2) paint = skia.Paint() paint.setColor(skia.Color(100, 149, 237, 80)) # Cornflower blue, semi-transparent paint.setStyle(skia.Paint.kFill_Style) 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.""" from ..parser.html import Element, Text # 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 = skia.Font(skia.Typeface.MakeDefault(), 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 in top-right corner.""" # Position in top-right fps_x = width - 80 fps_y = 10 font = skia.Font(skia.Typeface.MakeDefault(), 14) # Background bg_paint = skia.Paint() bg_paint.setColor(skia.Color(0, 0, 0, 180)) bg_paint.setStyle(skia.Paint.kFill_Style) canvas.drawRect(skia.Rect.MakeLTRB(fps_x - 5, fps_y, fps_x + 75, fps_y + 25), bg_paint) # FPS text with color based on performance text_paint = skia.Paint() text_paint.setAntiAlias(True) if self.fps >= 50: text_paint.setColor(skia.Color(100, 255, 100, 255)) # Green elif self.fps >= 30: text_paint.setColor(skia.Color(255, 255, 100, 255)) # Yellow else: text_paint.setColor(skia.Color(255, 100, 100, 255)) # Red fps_text = f"FPS: {self.fps:.0f}" canvas.drawString(fps_text, fps_x, fps_y + 17, font, text_paint) def paint(self): """Trigger redraw of the drawing area.""" if self.drawing_area: self.drawing_area.queue_draw() def _setup_keyboard_shortcuts(self): """Setup keyboard event handling for shortcuts.""" if not self.window: return # Create event controller for key presses key_controller = Gtk.EventControllerKey() key_controller.connect("key-pressed", self._on_key_pressed) self.window.add_controller(key_controller) def _on_key_pressed(self, controller, keyval, keycode, state): """Handle keyboard shortcuts.""" # Check for Ctrl+Shift+D (DOM graph visualization) ctrl_pressed = state & Gdk.ModifierType.CONTROL_MASK shift_pressed = state & Gdk.ModifierType.SHIFT_MASK key_name = Gdk.keyval_name(keyval) # Ctrl+Shift+D: DOM graph visualization if ctrl_pressed and shift_pressed and key_name in ('D', 'd'): self._show_dom_graph() return True # Ctrl+Shift+O: Toggle debug mode (DOM outline visualization) if ctrl_pressed and shift_pressed and key_name in ('O', 'o'): self._toggle_debug_mode() return True # Page scrolling with arrow keys, Page Up/Down, Home/End scroll_amount = 50 if key_name == 'Down': self._scroll_by(scroll_amount) return True elif key_name == 'Up': self._scroll_by(-scroll_amount) return True elif key_name == 'Page_Down': self._scroll_by(400) return True elif key_name == 'Page_Up': self._scroll_by(-400) return True elif key_name == 'Home' and ctrl_pressed: self.scroll_y = 0 self.paint() return True elif key_name == 'End' and ctrl_pressed: self.scroll_y = 10000 # Will be clamped self.paint() return True elif key_name == 'space': # Space scrolls down, Shift+Space scrolls up if shift_pressed: self._scroll_by(-400) else: self._scroll_by(400) return True return False # Event not handled def _toggle_debug_mode(self): """Toggle debug mode for DOM visualization.""" self.debug_mode = not self.debug_mode mode_str = "ON" if self.debug_mode else "OFF" self.logger.info(f"Debug mode: {mode_str}") self.paint() def _scroll_by(self, delta: int): """Scroll the page by the given amount, clamped to document bounds.""" max_scroll = max(0, self.document_height - self.viewport_height) self.scroll_y = max(0, min(max_scroll, self.scroll_y + delta)) self._show_scrollbar() self.paint() def _show_scrollbar(self): """Show scrollbar and schedule fade out.""" from gi.repository import GLib self.scrollbar_opacity = 1.0 # Cancel any existing fade timeout if self.scrollbar_fade_timeout: GLib.source_remove(self.scrollbar_fade_timeout) # Schedule fade out after 1 second self.scrollbar_fade_timeout = GLib.timeout_add(1000, self._fade_scrollbar) def _fade_scrollbar(self): """Gradually fade out the scrollbar.""" from gi.repository import GLib self.scrollbar_opacity -= 0.1 if self.scrollbar_opacity <= 0: self.scrollbar_opacity = 0 self.scrollbar_fade_timeout = None self.paint() return False # Stop the timeout self.paint() # Continue fading self.scrollbar_fade_timeout = GLib.timeout_add(50, self._fade_scrollbar) return False # This instance is done def _draw_scrollbar(self, canvas, width: int, height: int): """Draw the scrollbar overlay.""" if self.scrollbar_opacity <= 0 or self.document_height <= height: return # Calculate scrollbar dimensions scrollbar_width = 8 scrollbar_margin = 4 scrollbar_x = width - scrollbar_width - scrollbar_margin # Track height (full viewport) track_height = height - 2 * scrollbar_margin # Thumb size proportional to viewport/document ratio thumb_ratio = height / self.document_height thumb_height = max(30, track_height * thumb_ratio) # Thumb position based on scroll position max_scroll = max(1, self.document_height - height) scroll_ratio = self.scroll_y / max_scroll thumb_y = scrollbar_margin + scroll_ratio * (track_height - thumb_height) # Draw track (subtle) alpha = int(30 * self.scrollbar_opacity) track_paint = skia.Paint() track_paint.setColor(skia.Color(0, 0, 0, alpha)) track_paint.setStyle(skia.Paint.kFill_Style) track_rect = skia.RRect.MakeRectXY( skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin, scrollbar_x + scrollbar_width, height - scrollbar_margin), scrollbar_width / 2, scrollbar_width / 2 ) canvas.drawRRect(track_rect, track_paint) # Draw thumb alpha = int(150 * self.scrollbar_opacity) thumb_paint = skia.Paint() thumb_paint.setColor(skia.Color(100, 100, 100, alpha)) thumb_paint.setStyle(skia.Paint.kFill_Style) thumb_rect = skia.RRect.MakeRectXY( skia.Rect.MakeLTRB(scrollbar_x, thumb_y, scrollbar_x + scrollbar_width, thumb_y + thumb_height), scrollbar_width / 2, scrollbar_width / 2 ) canvas.drawRRect(thumb_rect, thumb_paint) def _on_scroll(self, controller, dx, dy): """Handle mouse wheel scroll.""" scroll_amount = int(dy * 50) # Scale scroll amount self._scroll_by(scroll_amount) return True def _on_mouse_pressed(self, gesture, n_press, x, y): """Handle mouse button press for text selection.""" self.selection_start = (x, y + self.scroll_y) self.selection_end = None self.is_selecting = True self.drawing_area.grab_focus() def _on_mouse_released(self, gesture, n_press, x, y): """Handle mouse button release for text selection.""" if self.is_selecting: self.selection_end = (x, y + self.scroll_y) self.is_selecting = False # Extract selected text selected_text = self._get_selected_text() if selected_text: self.logger.info(f"Selected text: {selected_text[:100]}...") # Copy to clipboard self._copy_to_clipboard(selected_text) self.paint() def _on_mouse_motion(self, controller, x, y): """Handle mouse motion for drag selection.""" if self.is_selecting: self.selection_end = (x, y + self.scroll_y) self.paint() def _draw_text_selection(self, canvas): """Draw selection highlight for selected text at character level.""" if not self.selection_start or not self.selection_end: return # Normalize selection: start should be before end in reading order if (self.selection_start[1] > self.selection_end[1] or (self.selection_start[1] == self.selection_end[1] and self.selection_start[0] > self.selection_end[0])): sel_start = self.selection_end sel_end = self.selection_start else: sel_start = self.selection_start sel_end = self.selection_end paint = skia.Paint() paint.setColor(skia.Color(100, 149, 237, 100)) # Cornflower blue paint.setStyle(skia.Paint.kFill_Style) 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"] char_positions = line_info.get("char_positions", []) text = line_info["text"] # Skip lines completely outside selection if line_bottom < sel_start[1] or line_top > sel_end[1]: continue # Determine selection bounds for this line hl_left = line_left hl_right = line_left + line_info["width"] # If this line contains the start of selection if line_top <= sel_start[1] < line_bottom: # Find character index at sel_start x start_char_idx = self._x_to_char_index(sel_start[0], line_left, char_positions) hl_left = line_left + char_positions[start_char_idx] if start_char_idx < len(char_positions) else line_left # If this line contains the end of selection if line_top <= sel_end[1] < line_bottom: # Find character index at sel_end x end_char_idx = self._x_to_char_index(sel_end[0], line_left, char_positions) hl_right = line_left + char_positions[end_char_idx] if end_char_idx < len(char_positions) else hl_right # Draw highlight if hl_right > hl_left: rect = skia.Rect.MakeLTRB(hl_left, line_top, hl_right, line_bottom) canvas.drawRect(rect, paint) def _x_to_char_index(self, x: float, line_x: float, char_positions: list) -> int: """Convert x coordinate to character index within a line.""" rel_x = x - line_x if rel_x <= 0: return 0 # Binary search for the character position for i, pos in enumerate(char_positions): if pos >= rel_x: # Check if closer to this char or previous if i > 0 and (pos - rel_x) > (rel_x - char_positions[i-1]): return i - 1 return i return len(char_positions) - 1 def _get_selected_text(self) -> str: """Extract text from the current selection at character level.""" if not self.selection_start or not self.selection_end or not self.text_layout: return "" # Normalize selection: start should be before end in reading order if (self.selection_start[1] > self.selection_end[1] or (self.selection_start[1] == self.selection_end[1] and self.selection_start[0] > self.selection_end[0])): sel_start = self.selection_end sel_end = self.selection_start else: sel_start = self.selection_start sel_end = self.selection_end selected_parts = [] 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"] char_positions = line_info.get("char_positions", []) text = line_info["text"] # Skip lines completely outside selection if line_bottom < sel_start[1] or line_top > sel_end[1]: continue start_idx = 0 end_idx = len(text) # If this line contains the start of selection if line_top <= sel_start[1] < line_bottom: start_idx = self._x_to_char_index(sel_start[0], line_left, char_positions) # If this line contains the end of selection if line_top <= sel_end[1] < line_bottom: end_idx = self._x_to_char_index(sel_end[0], line_left, char_positions) # Extract the selected portion if end_idx > start_idx: selected_parts.append(text[start_idx:end_idx]) return " ".join(selected_parts) def _copy_to_clipboard(self, text: str): """Copy text to system clipboard.""" clipboard = Gdk.Display.get_default().get_clipboard() clipboard.set(text) def _show_dom_graph(self): """Generate and display DOM graph for current tab.""" from ..debug.dom_graph import render_dom_graph_to_svg, save_dom_graph, print_dom_tree import os from pathlib import Path if not self.browser.active_tab: self.logger.warning("No active tab to visualize") return frame = self.browser.active_tab.main_frame if not frame or not frame.document: self.logger.warning("No document to visualize") return # Generate output path output_dir = Path.home() / ".cache" / "bowser" output_dir.mkdir(parents=True, exist_ok=True) # Try SVG first, fallback to DOT svg_path = output_dir / "dom_graph.svg" dot_path = output_dir / "dom_graph.dot" self.logger.info("Generating DOM graph...") # Print tree to console for debugging tree_text = print_dom_tree(frame.document, max_depth=15) print("\n" + "="*60) print("DOM TREE STRUCTURE:") print("="*60) print(tree_text) print("="*60 + "\n") # Try to render as SVG if render_dom_graph_to_svg(frame.document, str(svg_path)): # Open in new browser tab self.logger.info(f"Opening DOM graph in new tab: {svg_path}") self.browser.new_tab(f"about:dom-graph?path={svg_path}") else: # Fallback to DOT file if save_dom_graph(frame.document, str(dot_path)): self.logger.info(f"Opening DOM graph (DOT format) in new tab: {dot_path}") self.browser.new_tab(f"about:dom-graph?path={dot_path}") def _show_info_dialog(self, title: str, message: str): """Show an information dialog.""" dialog = Gtk.MessageDialog( transient_for=self.window, modal=True, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=title ) dialog.set_property("secondary-text", message) dialog.connect("response", lambda d, r: d.destroy()) dialog.present()