mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
- Implement PNG rendering for DOM graphs, with fallback to DOT format. - Add support for max-width constraints in image layout based on CSS styles. - Introduce caching mechanisms for image loading, including tracking failed and pending loads. - Update HTML parser to handle void elements correctly. - Modify template rendering to support PNG graph files.
996 lines
37 KiB
Python
996 lines
37 KiB
Python
# ruff: noqa: E402
|
|
"""Browser chrome (Adwaita UI)."""
|
|
|
|
import gi
|
|
from typing import Optional
|
|
import logging
|
|
import cairo
|
|
import time
|
|
from pathlib import Path
|
|
|
|
gi.require_version("Gtk", "4.0")
|
|
gi.require_version("Adw", "1")
|
|
|
|
from gi.repository import Gtk, Gdk, Adw
|
|
import skia
|
|
|
|
# Import the render pipeline
|
|
from ..render.pipeline import RenderPipeline
|
|
from ..render.fonts import get_font
|
|
|
|
|
|
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
|
|
|
|
# Render pipeline - handles layout and painting
|
|
self.render_pipeline = RenderPipeline()
|
|
|
|
# Debug mode state
|
|
self.debug_mode = False
|
|
|
|
# FPS tracking for debug mode
|
|
self.frame_times = [] # List of recent frame timestamps
|
|
self.fps = 0.0
|
|
|
|
# Profiling data
|
|
self._last_profile = {}
|
|
self._last_profile_total = 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 (populated from render pipeline)
|
|
self.text_layout = []
|
|
|
|
# Sub-timings for detailed profiling
|
|
self._render_sub_timings = {}
|
|
self._visible_line_count = 0
|
|
|
|
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)
|
|
|
|
# Set up redraw callback for async image loading
|
|
self.render_pipeline.set_redraw_callback(self._request_redraw)
|
|
|
|
# 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."""
|
|
# 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)
|
|
|
|
# Profiling timers
|
|
profile_start = time.perf_counter()
|
|
timings = {}
|
|
|
|
# Create Skia surface for this frame
|
|
t0 = time.perf_counter()
|
|
self.skia_surface = skia.Surface(width, height)
|
|
canvas = self.skia_surface.getCanvas()
|
|
timings['surface_create'] = time.perf_counter() - t0
|
|
|
|
# Store viewport height
|
|
self.viewport_height = height
|
|
|
|
# White background
|
|
t0 = time.perf_counter()
|
|
canvas.clear(skia.ColorWHITE)
|
|
timings['clear'] = time.perf_counter() - t0
|
|
|
|
# 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:
|
|
t0 = time.perf_counter()
|
|
self._render_dom_content(canvas, document, width, height)
|
|
timings['render_dom'] = time.perf_counter() - t0
|
|
|
|
t0 = time.perf_counter()
|
|
self._draw_scrollbar(canvas, width, height)
|
|
timings['scrollbar'] = time.perf_counter() - t0
|
|
|
|
if self.debug_mode:
|
|
self._draw_fps_counter(canvas, width)
|
|
else:
|
|
paint = skia.Paint()
|
|
paint.setAntiAlias(True)
|
|
paint.setColor(skia.ColorBLACK)
|
|
font = get_font(20)
|
|
canvas.drawString("Bowser — Enter a URL to browse", 20, 50, font, paint)
|
|
|
|
# Get raw pixel data from Skia surface
|
|
t0 = time.perf_counter()
|
|
image = self.skia_surface.makeImageSnapshot()
|
|
timings['snapshot'] = time.perf_counter() - t0
|
|
|
|
t0 = time.perf_counter()
|
|
pixels = image.tobytes()
|
|
timings['tobytes'] = time.perf_counter() - t0
|
|
|
|
# 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
|
|
|
|
# Blit Cairo surface to context
|
|
t0 = time.perf_counter()
|
|
context.set_source_surface(cairo_surface, 0, 0)
|
|
context.paint()
|
|
timings['cairo_blit'] = time.perf_counter() - t0
|
|
|
|
total_time = time.perf_counter() - profile_start
|
|
|
|
# Store profiling data for debug display
|
|
if self.debug_mode:
|
|
self._last_profile = timings
|
|
self._last_profile_total = total_time
|
|
|
|
def _render_dom_content(self, canvas, document, width: int, height: int):
|
|
"""Render the DOM content using the render pipeline."""
|
|
sub_timings = {}
|
|
|
|
# Sync debug mode with render pipeline
|
|
self.render_pipeline.debug_mode = self.debug_mode
|
|
|
|
# Set base URL for resolving relative image paths
|
|
if self.browser.active_tab and self.browser.active_tab.current_url:
|
|
self.render_pipeline.base_url = str(self.browser.active_tab.current_url)
|
|
else:
|
|
self.render_pipeline.base_url = None
|
|
|
|
# Use render pipeline for layout and rendering
|
|
t0 = time.perf_counter()
|
|
self.render_pipeline.render(canvas, document, width, height, self.scroll_y)
|
|
sub_timings['render'] = time.perf_counter() - t0
|
|
|
|
# Get text layout for selection
|
|
t0 = time.perf_counter()
|
|
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
|
|
|
|
# Draw selection highlight (still in chrome as it's UI interaction)
|
|
t0 = time.perf_counter()
|
|
if self.selection_start and self.selection_end:
|
|
canvas.save()
|
|
canvas.translate(0, -self.scroll_y)
|
|
self._draw_text_selection(canvas)
|
|
canvas.restore()
|
|
sub_timings['selection'] = time.perf_counter() - t0
|
|
|
|
# Store sub-timings for display
|
|
if self.debug_mode:
|
|
self._render_sub_timings = sub_timings
|
|
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
|
|
])
|
|
|
|
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_fps_counter(self, canvas, width: int):
|
|
"""Draw FPS counter and profiling info in top-right corner."""
|
|
font = get_font(11)
|
|
small_font = get_font(9)
|
|
|
|
# Calculate panel size based on profile data
|
|
panel_width = 200
|
|
num_profile_lines = len(self._last_profile) + 2 # +2 for FPS and total
|
|
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
|
|
|
|
# Position in top-right
|
|
panel_x = width - panel_width - 10
|
|
panel_y = 10
|
|
|
|
# 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(
|
|
panel_x, panel_y,
|
|
panel_x + panel_width, panel_y + panel_height
|
|
), bg_paint)
|
|
|
|
text_paint = skia.Paint()
|
|
text_paint.setAntiAlias(True)
|
|
|
|
# FPS with color
|
|
if self.fps >= 50:
|
|
text_paint.setColor(skia.Color(100, 255, 100, 255))
|
|
elif self.fps >= 30:
|
|
text_paint.setColor(skia.Color(255, 255, 100, 255))
|
|
else:
|
|
text_paint.setColor(skia.Color(255, 100, 100, 255))
|
|
|
|
y = panel_y + 14
|
|
canvas.drawString(f"FPS: {self.fps:.0f}", panel_x + 5, y, font, text_paint)
|
|
|
|
# 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)
|
|
|
|
# Profile breakdown
|
|
gray_paint = skia.Paint()
|
|
gray_paint.setAntiAlias(True)
|
|
gray_paint.setColor(skia.Color(180, 180, 180, 255))
|
|
|
|
if self._last_profile:
|
|
# Sort by time descending
|
|
sorted_items = sorted(
|
|
self._last_profile.items(),
|
|
key=lambda x: x[1],
|
|
reverse=True
|
|
)
|
|
|
|
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)
|
|
|
|
# Show render_dom sub-timings if available
|
|
if self._render_sub_timings:
|
|
y += 16
|
|
text_paint.setColor(skia.Color(150, 200, 255, 255))
|
|
canvas.drawString(
|
|
f"render_dom breakdown ({self._visible_line_count} lines):",
|
|
panel_x + 5,
|
|
y,
|
|
small_font,
|
|
text_paint,
|
|
)
|
|
|
|
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)
|
|
|
|
def paint(self):
|
|
"""Trigger redraw of the drawing area."""
|
|
if self.drawing_area and self.window:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def _request_redraw(self):
|
|
"""Request a redraw, called when async images finish loading."""
|
|
# This is called from the main thread via GLib.idle_add
|
|
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}")
|
|
|
|
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 or link clicks."""
|
|
click_x = x
|
|
click_y = y + self.scroll_y
|
|
|
|
if self.is_selecting:
|
|
self.selection_end = (click_x, click_y)
|
|
self.is_selecting = False
|
|
|
|
# Check if this is a click (not a drag)
|
|
if self.selection_start:
|
|
dx = abs(click_x - self.selection_start[0])
|
|
dy = abs(click_y - self.selection_start[1])
|
|
is_click = dx < 5 and dy < 5
|
|
|
|
if is_click:
|
|
# Check if we clicked on a link
|
|
href = self._get_link_at_position(click_x, click_y)
|
|
if href:
|
|
self.logger.info(f"Link clicked: {href}")
|
|
self._navigate_to_link(href)
|
|
# Clear selection since we're navigating
|
|
self.selection_start = None
|
|
self.selection_end = None
|
|
return
|
|
|
|
# Extract selected text (for drag selection)
|
|
selected_text = self._get_selected_text()
|
|
if selected_text:
|
|
self.logger.info(f"Selected text: {selected_text[:100]}...")
|
|
# Copy to clipboard
|
|
self._copy_to_clipboard(selected_text)
|
|
self.paint()
|
|
|
|
def _get_link_at_position(self, x: float, y: float) -> str | None:
|
|
"""Get the href of a link at the given position, or None."""
|
|
for line_info in self.text_layout:
|
|
line_top = line_info["y"]
|
|
line_bottom = line_info["y"] + line_info["height"]
|
|
line_left = line_info["x"]
|
|
line_right = line_info["x"] + line_info["width"]
|
|
|
|
# Check if click is within this line's bounding box
|
|
if line_top <= y <= line_bottom and line_left <= x <= line_right:
|
|
href = line_info.get("href")
|
|
if href:
|
|
return href
|
|
return None
|
|
|
|
def _navigate_to_link(self, href: str):
|
|
"""Navigate to a link, handling relative URLs."""
|
|
if not href:
|
|
return
|
|
|
|
# Handle special URLs
|
|
if href.startswith("#"):
|
|
# Anchor link - for now just ignore (future: scroll to anchor)
|
|
self.logger.debug(f"Ignoring anchor link: {href}")
|
|
return
|
|
|
|
if href.startswith("javascript:"):
|
|
# JavaScript URLs - ignore for security
|
|
self.logger.debug(f"Ignoring javascript link: {href}")
|
|
return
|
|
|
|
# Resolve relative URLs against current page URL
|
|
if self.browser.active_tab and self.browser.active_tab.current_url:
|
|
base_url = self.browser.active_tab.current_url
|
|
resolved_url = base_url.resolve(href)
|
|
self.browser.navigate_to(str(resolved_url))
|
|
else:
|
|
# No current URL, treat href as absolute
|
|
self.browser.navigate_to(href)
|
|
|
|
def _on_mouse_motion(self, controller, x, y):
|
|
"""Handle mouse motion for drag selection."""
|
|
if self.is_selecting:
|
|
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 not needed for highlight geometry
|
|
|
|
# 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_png, save_dom_graph, print_dom_tree
|
|
|
|
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 PNG first, fallback to DOT
|
|
png_path = output_dir / "dom_graph.png"
|
|
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 PNG
|
|
if render_dom_graph_to_png(frame.document, str(png_path)):
|
|
# Open in new browser tab
|
|
self.logger.info(f"Opening DOM graph in new tab: {png_path}")
|
|
self.browser.new_tab(f"about:dom-graph?path={png_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()
|