2026-01-12 09:22:34 +00:00
|
|
|
|
# ruff: noqa: E402
|
2026-01-09 13:31:55 +00:00
|
|
|
|
"""Browser chrome (Adwaita UI)."""
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
|
|
|
|
|
import gi
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
import logging
|
2026-01-11 22:02:52 +00:00
|
|
|
|
import cairo
|
2026-01-11 22:17:23 +00:00
|
|
|
|
import time
|
2026-01-11 22:34:27 +00:00
|
|
|
|
from pathlib import Path
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
|
|
|
|
|
gi.require_version("Gtk", "4.0")
|
2026-01-09 13:31:55 +00:00
|
|
|
|
gi.require_version("Adw", "1")
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
from gi.repository import Gtk, Gdk, Adw
|
2026-01-09 12:31:48 +00:00
|
|
|
|
import skia
|
2026-01-09 11:20:46 +00:00
|
|
|
|
|
2026-01-11 22:54:50 +00:00
|
|
|
|
# Import the render pipeline
|
|
|
|
|
|
from ..render.pipeline import RenderPipeline
|
2026-01-11 22:34:27 +00:00
|
|
|
|
from ..render.fonts import get_font
|
|
|
|
|
|
|
2026-01-09 11:20:46 +00:00
|
|
|
|
|
|
|
|
|
|
class Chrome:
|
|
|
|
|
|
def __init__(self, browser):
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.logger = logging.getLogger("bowser.chrome")
|
2026-01-09 11:20:46 +00:00
|
|
|
|
self.browser = browser
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.window: Optional[Adw.ApplicationWindow] = None
|
2026-01-09 12:31:48 +00:00
|
|
|
|
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
|
2026-01-09 23:19:21 +00:00
|
|
|
|
self.tab_view: Optional[Adw.TabView] = None
|
|
|
|
|
|
self.tab_bar: Optional[Adw.TabBar] = None
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.skia_surface: Optional[skia.Surface] = None
|
2026-01-09 23:19:21 +00:00
|
|
|
|
self.tab_pages: dict = {} # Map tab objects to AdwTabPage
|
|
|
|
|
|
self._closing_tabs: set = set() # Track tabs being closed to prevent re-entry
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:54:50 +00:00
|
|
|
|
# Render pipeline - handles layout and painting
|
|
|
|
|
|
self.render_pipeline = RenderPipeline()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Debug mode state
|
|
|
|
|
|
self.debug_mode = False
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# FPS tracking for debug mode
|
|
|
|
|
|
self.frame_times = [] # List of recent frame timestamps
|
|
|
|
|
|
self.fps = 0.0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Profiling data
|
|
|
|
|
|
self._last_profile = {}
|
|
|
|
|
|
self._last_profile_total = 0.0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Scroll state
|
|
|
|
|
|
self.scroll_y = 0
|
|
|
|
|
|
self.document_height = 0 # Total document height for scroll limits
|
|
|
|
|
|
self.viewport_height = 0 # Current viewport height
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Scrollbar fade state
|
|
|
|
|
|
self.scrollbar_opacity = 0.0
|
|
|
|
|
|
self.scrollbar_fade_timeout = None
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:54:50 +00:00
|
|
|
|
# Layout information for text selection (populated from render pipeline)
|
2026-01-11 21:47:42 +00:00
|
|
|
|
self.text_layout = []
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:17:23 +00:00
|
|
|
|
# Sub-timings for detailed profiling
|
|
|
|
|
|
self._render_sub_timings = {}
|
|
|
|
|
|
self._visible_line_count = 0
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
|
|
|
|
|
def create_window(self):
|
2026-01-09 13:31:55 +00:00
|
|
|
|
"""Initialize the Adwaita application window."""
|
|
|
|
|
|
# Initialize Adwaita application
|
|
|
|
|
|
if not hasattr(self.browser.app, '_adw_init'):
|
|
|
|
|
|
Adw.init()
|
|
|
|
|
|
self.browser.app._adw_init = True
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 13:31:55 +00:00
|
|
|
|
# Create Adwaita window instead of standard GTK window
|
|
|
|
|
|
self.window = Adw.ApplicationWindow(application=self.browser.app)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.window.set_default_size(1024, 768)
|
|
|
|
|
|
self.window.set_title("Bowser")
|
|
|
|
|
|
|
2026-01-09 13:31:55 +00:00
|
|
|
|
# Main vertical box for the window structure
|
2026-01-09 12:31:48 +00:00
|
|
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.window.set_content(vbox)
|
|
|
|
|
|
|
|
|
|
|
|
# Header bar with navigation and address bar
|
|
|
|
|
|
header_bar = Gtk.HeaderBar()
|
|
|
|
|
|
vbox.append(header_bar)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 13:31:55 +00:00
|
|
|
|
# Navigation buttons in header bar
|
|
|
|
|
|
nav_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
|
|
|
|
nav_box.add_css_class("linked")
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.back_btn = Gtk.Button(label="◀")
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.back_btn.set_tooltip_text("Back")
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.forward_btn = Gtk.Button(label="▶")
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.forward_btn.set_tooltip_text("Forward")
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.reload_btn = Gtk.Button(label="⟳")
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.reload_btn.set_tooltip_text("Reload")
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 13:31:55 +00:00
|
|
|
|
nav_box.append(self.back_btn)
|
|
|
|
|
|
nav_box.append(self.forward_btn)
|
|
|
|
|
|
nav_box.append(self.reload_btn)
|
|
|
|
|
|
header_bar.pack_start(nav_box)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 13:31:55 +00:00
|
|
|
|
# Address bar - centered in header
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.address_bar = Gtk.Entry()
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.address_bar.set_placeholder_text("Enter URL...")
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.address_bar.set_hexpand(True)
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.address_bar.set_max_width_chars(40)
|
|
|
|
|
|
header_bar.set_title_widget(self.address_bar)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 13:31:55 +00:00
|
|
|
|
# Go button in header bar end
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.go_btn = Gtk.Button(label="Go")
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.go_btn.add_css_class("suggested-action")
|
|
|
|
|
|
header_bar.pack_end(self.go_btn)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Create TabView for managing tabs
|
|
|
|
|
|
self.tab_view = Adw.TabView()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Create TabBar for tab display
|
|
|
|
|
|
self.tab_bar = Adw.TabBar()
|
|
|
|
|
|
self.tab_bar.set_view(self.tab_view)
|
|
|
|
|
|
self.tab_bar.set_autohide(False)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
vbox.append(self.tab_bar)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Create the drawing area for rendering page content
|
2026-01-09 12:31:48 +00:00
|
|
|
|
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)
|
2026-01-11 21:35:56 +00:00
|
|
|
|
self.drawing_area.set_can_focus(True) # Allow focus for keyboard events
|
|
|
|
|
|
self.drawing_area.set_focusable(True)
|
2026-01-09 23:19:21 +00:00
|
|
|
|
content_box.append(self.drawing_area)
|
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.
- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.
- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.
- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.
- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.
- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 12:06:20 +00:00
|
|
|
|
|
2026-01-12 16:36:14 +00:00
|
|
|
|
# Set up redraw callback for async image loading
|
|
|
|
|
|
self.render_pipeline.set_redraw_callback(self._request_redraw)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Add content box to vbox (not to TabView - we use a single drawing area for all tabs)
|
|
|
|
|
|
vbox.append(content_box)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
2026-01-09 13:31:55 +00:00
|
|
|
|
# Status bar with Adwaita styling
|
|
|
|
|
|
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
|
|
|
|
status_box.add_css_class("toolbar")
|
2026-01-09 12:31:48 +00:00
|
|
|
|
status_bar = Gtk.Label(label="Ready")
|
|
|
|
|
|
status_bar.set_xalign(0)
|
2026-01-09 13:31:55 +00:00
|
|
|
|
status_bar.set_margin_start(8)
|
|
|
|
|
|
status_bar.set_margin_end(8)
|
|
|
|
|
|
status_box.append(status_bar)
|
|
|
|
|
|
vbox.append(status_box)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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())
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Setup keyboard shortcuts
|
|
|
|
|
|
self._setup_keyboard_shortcuts()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Show the window
|
|
|
|
|
|
self.window.present()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Create the tab page
|
|
|
|
|
|
page = self.tab_view.append(placeholder)
|
|
|
|
|
|
page.set_title(tab.title)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Store mapping
|
|
|
|
|
|
self.tab_pages[tab] = page
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Select this tab
|
|
|
|
|
|
self.tab_view.set_selected_page(page)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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
|
2026-01-09 12:31:48 +00:00
|
|
|
|
return
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
self._add_tab_to_ui(tab)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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}")
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
def _on_close_page(self, tab_view, page):
|
|
|
|
|
|
"""Handle tab close request from UI.
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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
|
2026-01-09 13:36:49 +00:00
|
|
|
|
|
|
|
|
|
|
def _on_new_tab_clicked(self, btn: Gtk.Button):
|
|
|
|
|
|
"""Handle new tab button click."""
|
|
|
|
|
|
self.browser.new_tab("about:startpage")
|
2026-01-09 13:34:48 +00:00
|
|
|
|
|
2026-01-09 12:31:48 +00:00
|
|
|
|
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):
|
2026-01-12 09:22:34 +00:00
|
|
|
|
"""Callback for drawing the content area using Skia."""
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Profiling timers
|
|
|
|
|
|
profile_start = time.perf_counter()
|
|
|
|
|
|
timings = {}
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 12:31:48 +00:00
|
|
|
|
# Create Skia surface for this frame
|
2026-01-11 22:02:52 +00:00
|
|
|
|
t0 = time.perf_counter()
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self.skia_surface = skia.Surface(width, height)
|
|
|
|
|
|
canvas = self.skia_surface.getCanvas()
|
2026-01-11 22:02:52 +00:00
|
|
|
|
timings['surface_create'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Store viewport height
|
|
|
|
|
|
self.viewport_height = height
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
|
|
|
|
|
# White background
|
2026-01-11 22:02:52 +00:00
|
|
|
|
t0 = time.perf_counter()
|
2026-01-09 12:31:48 +00:00
|
|
|
|
canvas.clear(skia.ColorWHITE)
|
2026-01-11 22:02:52 +00:00
|
|
|
|
timings['clear'] = time.perf_counter() - t0
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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:
|
2026-01-11 22:02:52 +00:00
|
|
|
|
t0 = time.perf_counter()
|
2026-01-09 23:19:21 +00:00
|
|
|
|
self._render_dom_content(canvas, document, width, height)
|
2026-01-11 22:02:52 +00:00
|
|
|
|
timings['render_dom'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
t0 = time.perf_counter()
|
2026-01-11 21:35:56 +00:00
|
|
|
|
self._draw_scrollbar(canvas, width, height)
|
2026-01-11 22:02:52 +00:00
|
|
|
|
timings['scrollbar'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
if self.debug_mode:
|
|
|
|
|
|
self._draw_fps_counter(canvas, width)
|
2026-01-09 13:11:46 +00:00
|
|
|
|
else:
|
|
|
|
|
|
paint = skia.Paint()
|
|
|
|
|
|
paint.setAntiAlias(True)
|
|
|
|
|
|
paint.setColor(skia.ColorBLACK)
|
2026-01-11 22:54:50 +00:00
|
|
|
|
font = get_font(20)
|
2026-01-09 13:11:46 +00:00
|
|
|
|
canvas.drawString("Bowser — Enter a URL to browse", 20, 50, font, paint)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Get raw pixel data from Skia surface
|
|
|
|
|
|
t0 = time.perf_counter()
|
2026-01-09 12:31:48 +00:00
|
|
|
|
image = self.skia_surface.makeImageSnapshot()
|
2026-01-11 22:02:52 +00:00
|
|
|
|
timings['snapshot'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
|
|
pixels = image.tobytes()
|
|
|
|
|
|
timings['tobytes'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Create Cairo ImageSurface from raw pixels
|
|
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
|
|
cairo_surface = cairo.ImageSurface.create_for_data(
|
|
|
|
|
|
bytearray(pixels),
|
|
|
|
|
|
cairo.FORMAT_ARGB32,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
width * 4 # stride
|
|
|
|
|
|
)
|
|
|
|
|
|
timings['cairo_surface'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Blit Cairo surface to context
|
|
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
|
|
context.set_source_surface(cairo_surface, 0, 0)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
context.paint()
|
2026-01-11 22:02:52 +00:00
|
|
|
|
timings['cairo_blit'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
total_time = time.perf_counter() - profile_start
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Store profiling data for debug display
|
|
|
|
|
|
if self.debug_mode:
|
|
|
|
|
|
self._last_profile = timings
|
|
|
|
|
|
self._last_profile_total = total_time
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
def _render_dom_content(self, canvas, document, width: int, height: int):
|
2026-01-11 22:54:50 +00:00
|
|
|
|
"""Render the DOM content using the render pipeline."""
|
2026-01-11 22:17:23 +00:00
|
|
|
|
sub_timings = {}
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:54:50 +00:00
|
|
|
|
# Sync debug mode with render pipeline
|
|
|
|
|
|
self.render_pipeline.debug_mode = self.debug_mode
|
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.
- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.
- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.
- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.
- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.
- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 12:06:20 +00:00
|
|
|
|
|
2026-01-12 16:36:14 +00:00
|
|
|
|
# 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)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.render_pipeline.base_url = None
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:54:50 +00:00
|
|
|
|
# Use render pipeline for layout and rendering
|
2026-01-11 22:17:23 +00:00
|
|
|
|
t0 = time.perf_counter()
|
2026-01-11 22:54:50 +00:00
|
|
|
|
self.render_pipeline.render(canvas, document, width, height, self.scroll_y)
|
|
|
|
|
|
sub_timings['render'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:54:50 +00:00
|
|
|
|
# Get text layout for selection
|
2026-01-11 22:17:23 +00:00
|
|
|
|
t0 = time.perf_counter()
|
2026-01-11 22:54:50 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:54:50 +00:00
|
|
|
|
# Draw selection highlight (still in chrome as it's UI interaction)
|
2026-01-11 22:17:23 +00:00
|
|
|
|
t0 = time.perf_counter()
|
2026-01-11 22:02:52 +00:00
|
|
|
|
if self.selection_start and self.selection_end:
|
2026-01-11 22:54:50 +00:00
|
|
|
|
canvas.save()
|
|
|
|
|
|
canvas.translate(0, -self.scroll_y)
|
2026-01-11 22:02:52 +00:00
|
|
|
|
self._draw_text_selection(canvas)
|
2026-01-11 22:54:50 +00:00
|
|
|
|
canvas.restore()
|
2026-01-11 22:17:23 +00:00
|
|
|
|
sub_timings['selection'] = time.perf_counter() - t0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:17:23 +00:00
|
|
|
|
# Store sub-timings for display
|
|
|
|
|
|
if self.debug_mode:
|
|
|
|
|
|
self._render_sub_timings = sub_timings
|
2026-01-12 09:22:34 +00:00
|
|
|
|
self._visible_line_count = len([
|
|
|
|
|
|
line for line in self.text_layout
|
|
|
|
|
|
if self.scroll_y - 50 <= line["y"] + line["font_size"] <= self.scroll_y + height + 50
|
|
|
|
|
|
])
|
2026-01-09 23:19:21 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _draw_selection_highlight(self, canvas, width: int):
|
|
|
|
|
|
"""Draw selection highlight rectangle."""
|
|
|
|
|
|
if not self.selection_start or not self.selection_end:
|
|
|
|
|
|
return
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
x1, y1 = self.selection_start
|
|
|
|
|
|
x2, y2 = self.selection_end
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Normalize coordinates
|
|
|
|
|
|
left = min(x1, x2)
|
|
|
|
|
|
right = max(x1, x2)
|
|
|
|
|
|
top = min(y1, y2)
|
|
|
|
|
|
bottom = max(y1, y2)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
paint = skia.Paint()
|
|
|
|
|
|
paint.setColor(skia.Color(100, 149, 237, 80)) # Cornflower blue, semi-transparent
|
|
|
|
|
|
paint.setStyle(skia.Paint.kFill_Style)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
rect = skia.Rect.MakeLTRB(left, top, right, bottom)
|
|
|
|
|
|
canvas.drawRect(rect, paint)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
def _draw_fps_counter(self, canvas, width: int):
|
2026-01-11 22:02:52 +00:00
|
|
|
|
"""Draw FPS counter and profiling info in top-right corner."""
|
2026-01-11 22:54:50 +00:00
|
|
|
|
font = get_font(11)
|
|
|
|
|
|
small_font = get_font(9)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Calculate panel size based on profile data
|
2026-01-11 22:17:23 +00:00
|
|
|
|
panel_width = 200
|
2026-01-11 22:02:52 +00:00
|
|
|
|
num_profile_lines = len(self._last_profile) + 2 # +2 for FPS and total
|
2026-01-11 22:17:23 +00:00
|
|
|
|
num_sub_lines = len(self._render_sub_timings) + 1 if self._render_sub_timings else 0
|
|
|
|
|
|
panel_height = 18 + num_profile_lines * 12 + num_sub_lines * 11 + 10
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Position in top-right
|
|
|
|
|
|
panel_x = width - panel_width - 10
|
|
|
|
|
|
panel_y = 10
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# Background
|
|
|
|
|
|
bg_paint = skia.Paint()
|
2026-01-11 22:02:52 +00:00
|
|
|
|
bg_paint.setColor(skia.Color(0, 0, 0, 200))
|
2026-01-11 21:47:42 +00:00
|
|
|
|
bg_paint.setStyle(skia.Paint.kFill_Style)
|
2026-01-11 22:02:52 +00:00
|
|
|
|
canvas.drawRect(skia.Rect.MakeLTRB(
|
2026-01-12 09:22:34 +00:00
|
|
|
|
panel_x, panel_y,
|
2026-01-11 22:02:52 +00:00
|
|
|
|
panel_x + panel_width, panel_y + panel_height
|
|
|
|
|
|
), bg_paint)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
text_paint = skia.Paint()
|
|
|
|
|
|
text_paint.setAntiAlias(True)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# FPS with color
|
2026-01-11 21:47:42 +00:00
|
|
|
|
if self.fps >= 50:
|
2026-01-11 22:02:52 +00:00
|
|
|
|
text_paint.setColor(skia.Color(100, 255, 100, 255))
|
2026-01-11 21:47:42 +00:00
|
|
|
|
elif self.fps >= 30:
|
2026-01-11 22:02:52 +00:00
|
|
|
|
text_paint.setColor(skia.Color(255, 255, 100, 255))
|
2026-01-11 21:47:42 +00:00
|
|
|
|
else:
|
2026-01-11 22:02:52 +00:00
|
|
|
|
text_paint.setColor(skia.Color(255, 100, 100, 255))
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
y = panel_y + 14
|
|
|
|
|
|
canvas.drawString(f"FPS: {self.fps:.0f}", panel_x + 5, y, font, text_paint)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Total frame time
|
|
|
|
|
|
text_paint.setColor(skia.ColorWHITE)
|
|
|
|
|
|
total_ms = self._last_profile_total * 1000
|
|
|
|
|
|
y += 14
|
|
|
|
|
|
canvas.drawString(f"Frame: {total_ms:.1f}ms", panel_x + 5, y, font, text_paint)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
# Profile breakdown
|
2026-01-11 22:17:23 +00:00
|
|
|
|
gray_paint = skia.Paint()
|
|
|
|
|
|
gray_paint.setAntiAlias(True)
|
|
|
|
|
|
gray_paint.setColor(skia.Color(180, 180, 180, 255))
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
if self._last_profile:
|
|
|
|
|
|
# Sort by time descending
|
|
|
|
|
|
sorted_items = sorted(
|
2026-01-12 09:22:34 +00:00
|
|
|
|
self._last_profile.items(),
|
|
|
|
|
|
key=lambda x: x[1],
|
2026-01-11 22:02:52 +00:00
|
|
|
|
reverse=True
|
|
|
|
|
|
)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:02:52 +00:00
|
|
|
|
for name, duration in sorted_items:
|
|
|
|
|
|
y += 12
|
|
|
|
|
|
ms = duration * 1000
|
|
|
|
|
|
pct = (duration / self._last_profile_total * 100) if self._last_profile_total > 0 else 0
|
|
|
|
|
|
# Color code: red if >50% of frame time
|
|
|
|
|
|
if pct > 50:
|
|
|
|
|
|
gray_paint.setColor(skia.Color(255, 150, 150, 255))
|
|
|
|
|
|
elif pct > 25:
|
|
|
|
|
|
gray_paint.setColor(skia.Color(255, 220, 150, 255))
|
|
|
|
|
|
else:
|
|
|
|
|
|
gray_paint.setColor(skia.Color(180, 180, 180, 255))
|
|
|
|
|
|
canvas.drawString(f"{name}: {ms:.1f}ms ({pct:.0f}%)", panel_x + 8, y, small_font, gray_paint)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 22:17:23 +00:00
|
|
|
|
# Show render_dom sub-timings if available
|
|
|
|
|
|
if self._render_sub_timings:
|
|
|
|
|
|
y += 16
|
|
|
|
|
|
text_paint.setColor(skia.Color(150, 200, 255, 255))
|
2026-01-12 09:22:34 +00:00
|
|
|
|
canvas.drawString(
|
|
|
|
|
|
f"render_dom breakdown ({self._visible_line_count} lines):",
|
|
|
|
|
|
panel_x + 5,
|
|
|
|
|
|
y,
|
|
|
|
|
|
small_font,
|
|
|
|
|
|
text_paint,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-11 22:17:23 +00:00
|
|
|
|
sub_sorted = sorted(self._render_sub_timings.items(), key=lambda x: x[1], reverse=True)
|
|
|
|
|
|
for name, duration in sub_sorted:
|
|
|
|
|
|
y += 11
|
|
|
|
|
|
ms = duration * 1000
|
|
|
|
|
|
gray_paint.setColor(skia.Color(150, 180, 200, 255))
|
|
|
|
|
|
canvas.drawString(f" {name}: {ms:.2f}ms", panel_x + 8, y, small_font, gray_paint)
|
2026-01-09 23:19:21 +00:00
|
|
|
|
|
|
|
|
|
|
def paint(self):
|
|
|
|
|
|
"""Trigger redraw of the drawing area."""
|
2026-01-12 16:36:14 +00:00
|
|
|
|
if self.drawing_area and self.window:
|
2026-01-09 23:19:21 +00:00
|
|
|
|
self.drawing_area.queue_draw()
|
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.
- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.
- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.
- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.
- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.
- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 12:06:20 +00:00
|
|
|
|
|
2026-01-12 16:36:14 +00:00
|
|
|
|
def _request_redraw(self):
|
|
|
|
|
|
"""Request a redraw, called when async images finish loading."""
|
|
|
|
|
|
# This is called from the main thread via GLib.idle_add
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Only redraw if we have a valid window and drawing area
|
|
|
|
|
|
if self.window and self.drawing_area and self.browser.active_tab:
|
|
|
|
|
|
self.logger.debug("Async image loaded, requesting redraw")
|
|
|
|
|
|
self.drawing_area.queue_draw()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
self.logger.warning(f"Failed to request redraw: {e}")
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
def _setup_keyboard_shortcuts(self):
|
|
|
|
|
|
"""Setup keyboard event handling for shortcuts."""
|
|
|
|
|
|
if not self.window:
|
|
|
|
|
|
return
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
key_name = Gdk.keyval_name(keyval)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Ctrl+Shift+D: DOM graph visualization
|
2026-01-09 23:19:21 +00:00
|
|
|
|
if ctrl_pressed and shift_pressed and key_name in ('D', 'd'):
|
|
|
|
|
|
self._show_dom_graph()
|
2026-01-11 21:35:56 +00:00
|
|
|
|
return True
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
return False # Event not handled
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _show_scrollbar(self):
|
|
|
|
|
|
"""Show scrollbar and schedule fade out."""
|
|
|
|
|
|
from gi.repository import GLib
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
self.scrollbar_opacity = 1.0
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Cancel any existing fade timeout
|
|
|
|
|
|
if self.scrollbar_fade_timeout:
|
|
|
|
|
|
GLib.source_remove(self.scrollbar_fade_timeout)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Schedule fade out after 1 second
|
|
|
|
|
|
self.scrollbar_fade_timeout = GLib.timeout_add(1000, self._fade_scrollbar)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _fade_scrollbar(self):
|
|
|
|
|
|
"""Gradually fade out the scrollbar."""
|
|
|
|
|
|
from gi.repository import GLib
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
self.paint()
|
|
|
|
|
|
# Continue fading
|
|
|
|
|
|
self.scrollbar_fade_timeout = GLib.timeout_add(50, self._fade_scrollbar)
|
|
|
|
|
|
return False # This instance is done
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _draw_scrollbar(self, canvas, width: int, height: int):
|
|
|
|
|
|
"""Draw the scrollbar overlay."""
|
|
|
|
|
|
if self.scrollbar_opacity <= 0 or self.document_height <= height:
|
|
|
|
|
|
return
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Calculate scrollbar dimensions
|
|
|
|
|
|
scrollbar_width = 8
|
|
|
|
|
|
scrollbar_margin = 4
|
|
|
|
|
|
scrollbar_x = width - scrollbar_width - scrollbar_margin
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Track height (full viewport)
|
|
|
|
|
|
track_height = height - 2 * scrollbar_margin
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Thumb size proportional to viewport/document ratio
|
|
|
|
|
|
thumb_ratio = height / self.document_height
|
|
|
|
|
|
thumb_height = max(30, track_height * thumb_ratio)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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(
|
2026-01-12 09:22:34 +00:00
|
|
|
|
skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin,
|
2026-01-11 21:35:56 +00:00
|
|
|
|
scrollbar_x + scrollbar_width, height - scrollbar_margin),
|
|
|
|
|
|
scrollbar_width / 2, scrollbar_width / 2
|
|
|
|
|
|
)
|
|
|
|
|
|
canvas.drawRRect(track_rect, track_paint)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _on_mouse_released(self, gesture, n_press, x, y):
|
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.
- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.
- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.
- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.
- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.
- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 12:06:20 +00:00
|
|
|
|
"""Handle mouse button release for text selection or link clicks."""
|
|
|
|
|
|
click_x = x
|
|
|
|
|
|
click_y = y + self.scroll_y
|
|
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
if self.is_selecting:
|
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.
- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.
- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.
- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.
- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.
- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 12:06:20 +00:00
|
|
|
|
self.selection_end = (click_x, click_y)
|
2026-01-11 21:35:56 +00:00
|
|
|
|
self.is_selecting = False
|
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.
- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.
- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.
- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.
- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.
- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 12:06:20 +00:00
|
|
|
|
|
|
|
|
|
|
# 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)
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.
- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.
- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.
- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.
- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.
- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 12:06:20 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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()
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _draw_text_selection(self, canvas):
|
2026-01-11 21:47:42 +00:00
|
|
|
|
"""Draw selection highlight for selected text at character level."""
|
2026-01-11 21:35:56 +00:00
|
|
|
|
if not self.selection_start or not self.selection_end:
|
|
|
|
|
|
return
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# Normalize selection: start should be before end in reading order
|
|
|
|
|
|
if (self.selection_start[1] > self.selection_end[1] or
|
2026-01-12 09:22:34 +00:00
|
|
|
|
(self.selection_start[1] == self.selection_end[1] and
|
2026-01-11 21:47:42 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
paint = skia.Paint()
|
|
|
|
|
|
paint.setColor(skia.Color(100, 149, 237, 100)) # Cornflower blue
|
|
|
|
|
|
paint.setStyle(skia.Paint.kFill_Style)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
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"]
|
2026-01-11 21:47:42 +00:00
|
|
|
|
char_positions = line_info.get("char_positions", [])
|
2026-01-12 09:22:34 +00:00
|
|
|
|
# text not needed for highlight geometry
|
|
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# Skip lines completely outside selection
|
|
|
|
|
|
if line_bottom < sel_start[1] or line_top > sel_end[1]:
|
2026-01-11 21:35:56 +00:00
|
|
|
|
continue
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# Determine selection bounds for this line
|
2026-01-11 21:35:56 +00:00
|
|
|
|
hl_left = line_left
|
2026-01-11 21:47:42 +00:00
|
|
|
|
hl_right = line_left + line_info["width"]
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
hl_left = (
|
|
|
|
|
|
line_left + char_positions[start_char_idx]
|
|
|
|
|
|
if start_char_idx < len(char_positions)
|
|
|
|
|
|
else line_left
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
hl_right = (
|
|
|
|
|
|
line_left + char_positions[end_char_idx]
|
|
|
|
|
|
if end_char_idx < len(char_positions)
|
|
|
|
|
|
else hl_right
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
# Draw highlight
|
2026-01-11 21:47:42 +00:00
|
|
|
|
if hl_right > hl_left:
|
|
|
|
|
|
rect = skia.Rect.MakeLTRB(hl_left, line_top, hl_right, line_bottom)
|
|
|
|
|
|
canvas.drawRect(rect, paint)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# 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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
return len(char_positions) - 1
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _get_selected_text(self) -> str:
|
2026-01-11 21:47:42 +00:00
|
|
|
|
"""Extract text from the current selection at character level."""
|
2026-01-11 21:35:56 +00:00
|
|
|
|
if not self.selection_start or not self.selection_end or not self.text_layout:
|
|
|
|
|
|
return ""
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# Normalize selection: start should be before end in reading order
|
|
|
|
|
|
if (self.selection_start[1] > self.selection_end[1] or
|
2026-01-12 09:22:34 +00:00
|
|
|
|
(self.selection_start[1] == self.selection_end[1] and
|
2026-01-11 21:47:42 +00:00
|
|
|
|
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
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
selected_parts = []
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
for line_info in self.text_layout:
|
|
|
|
|
|
line_top = line_info["y"]
|
|
|
|
|
|
line_bottom = line_info["y"] + line_info["height"]
|
2026-01-11 21:47:42 +00:00
|
|
|
|
line_left = line_info["x"]
|
|
|
|
|
|
char_positions = line_info.get("char_positions", [])
|
|
|
|
|
|
text = line_info["text"]
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# Skip lines completely outside selection
|
|
|
|
|
|
if line_bottom < sel_start[1] or line_top > sel_end[1]:
|
|
|
|
|
|
continue
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
start_idx = 0
|
|
|
|
|
|
end_idx = len(text)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# 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)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
# Extract the selected portion
|
|
|
|
|
|
if end_idx > start_idx:
|
|
|
|
|
|
selected_parts.append(text[start_idx:end_idx])
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:47:42 +00:00
|
|
|
|
return " ".join(selected_parts)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-11 21:35:56 +00:00
|
|
|
|
def _copy_to_clipboard(self, text: str):
|
|
|
|
|
|
"""Copy text to system clipboard."""
|
|
|
|
|
|
clipboard = Gdk.Display.get_default().get_clipboard()
|
|
|
|
|
|
clipboard.set(text)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
def _show_dom_graph(self):
|
|
|
|
|
|
"""Generate and display DOM graph for current tab."""
|
2026-01-13 13:23:45 +00:00
|
|
|
|
from ..debug.dom_graph import render_dom_graph_to_png, save_dom_graph, print_dom_tree
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
if not self.browser.active_tab:
|
|
|
|
|
|
self.logger.warning("No active tab to visualize")
|
|
|
|
|
|
return
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
frame = self.browser.active_tab.main_frame
|
|
|
|
|
|
if not frame or not frame.document:
|
|
|
|
|
|
self.logger.warning("No document to visualize")
|
|
|
|
|
|
return
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Generate output path
|
|
|
|
|
|
output_dir = Path.home() / ".cache" / "bowser"
|
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-13 13:23:45 +00:00
|
|
|
|
# Try PNG first, fallback to DOT
|
|
|
|
|
|
png_path = output_dir / "dom_graph.png"
|
2026-01-09 23:19:21 +00:00
|
|
|
|
dot_path = output_dir / "dom_graph.dot"
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
self.logger.info("Generating DOM graph...")
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# 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")
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-13 13:23:45 +00:00
|
|
|
|
# Try to render as PNG
|
|
|
|
|
|
if render_dom_graph_to_png(frame.document, str(png_path)):
|
2026-01-09 23:19:21 +00:00
|
|
|
|
# Open in new browser tab
|
2026-01-13 13:23:45 +00:00
|
|
|
|
self.logger.info(f"Opening DOM graph in new tab: {png_path}")
|
|
|
|
|
|
self.browser.new_tab(f"about:dom-graph?path={png_path}")
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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}")
|
2026-01-12 09:22:34 +00:00
|
|
|
|
|
2026-01-09 23:19:21 +00:00
|
|
|
|
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()
|