bowser/src/browser/chrome.py
Benedikt Willi 283dae295c Implement HTTP redirect following
- Support for 301, 302, 303, 307, 308 redirects
- Automatic Location header following
- Relative URL resolution for redirects
- Max redirect limit (default 10) to prevent infinite loops
- 303 (See Other) automatically changes method to GET
- 4 new unit tests for redirect functionality
- All 73 tests passing
2026-01-09 14:11:46 +01:00

263 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Browser chrome (GTK UI)."""
import gi
from typing import Optional
import logging
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, Gdk, GdkPixbuf
from functools import partial
import skia
class Chrome:
def __init__(self, browser):
self.logger = logging.getLogger("bowser.chrome")
self.browser = browser
self.window: Optional[Gtk.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.tabs_box: Optional[Gtk.Box] = None
self.skia_surface: Optional[skia.Surface] = None
def create_window(self):
"""Initialize the GTK application window."""
self.window = Gtk.ApplicationWindow(application=self.browser.app)
self.window.set_default_size(1024, 768)
self.window.set_title("Bowser")
# Main vertical box
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.window.set_child(vbox)
# Top bar: address bar + buttons
top_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
top_bar.set_margin_top(5)
top_bar.set_margin_bottom(5)
top_bar.set_margin_start(5)
top_bar.set_margin_end(5)
self.back_btn = Gtk.Button(label="")
self.forward_btn = Gtk.Button(label="")
self.reload_btn = Gtk.Button(label="")
self.address_bar = Gtk.Entry()
self.address_bar.set_text("https://example.com")
self.address_bar.set_hexpand(True)
self.go_btn = Gtk.Button(label="Go")
top_bar.append(self.back_btn)
top_bar.append(self.forward_btn)
top_bar.append(self.reload_btn)
top_bar.append(self.address_bar)
top_bar.append(self.go_btn)
vbox.append(top_bar)
# Tabs bar: contains tab buttons and a new-tab button
self.tabs_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
self.tabs_box.set_margin_start(5)
self.tabs_box.set_margin_end(5)
self.tabs_box.set_margin_bottom(4)
vbox.append(self.tabs_box)
# Drawing area for 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)
vbox.append(self.drawing_area)
# Status bar
status_bar = Gtk.Label(label="Ready")
status_bar.set_xalign(0)
status_bar.set_margin_start(5)
vbox.append(status_bar)
self.window.present()
# Build initial tab bar now that window exists
self.rebuild_tab_bar()
# 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())
def _clear_children(self, box: Gtk.Box):
child = box.get_first_child()
while child is not None:
nxt = child.get_next_sibling()
box.remove(child)
child = nxt
def rebuild_tab_bar(self):
"""Recreate tab buttons to reflect current tabs and active tab."""
if not self.tabs_box:
return
self._clear_children(self.tabs_box)
# Add a button per tab with a close '×' control
for i, tab in enumerate(self.browser.tabs):
# Composite tab widget: label button + inline close button in one container
tab_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
tab_container.add_css_class("linked") # visual integration
label = f"{i+1}: {tab.title}"
tab_btn = Gtk.Button(label=label)
tab_btn.set_hexpand(False)
if tab is self.browser.active_tab:
tab_btn.add_css_class("suggested-action")
tab_btn.connect("clicked", partial(self.browser.set_active_tab, tab))
close_btn = Gtk.Button(label="×")
close_btn.add_css_class("flat")
close_btn.connect("clicked", partial(self.browser.close_tab, tab))
tab_container.append(tab_btn)
tab_container.append(close_btn)
self.tabs_box.append(tab_container)
# New tab '+' button at the end
plus_btn = Gtk.Button(label="+")
plus_btn.connect("clicked", lambda _b: self.browser.new_tab("https://example.com"))
self.tabs_box.append(plus_btn)
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."""
self.logger.debug(f"on_draw start {width}x{height}")
# Create Skia surface for this frame
self.skia_surface = skia.Surface(width, height)
canvas = self.skia_surface.getCanvas()
# White background
canvas.clear(skia.ColorWHITE)
# Get content to render
content_text = self._get_content_text()
if content_text:
# Render actual page content with text wrapping
self._render_text_content(canvas, content_text, width, height)
else:
# Show placeholder
paint = skia.Paint()
paint.setAntiAlias(True)
paint.setColor(skia.ColorBLACK)
font = skia.Font(skia.Typeface.MakeDefault(), 20)
canvas.drawString("Bowser — Enter a URL to browse", 20, 50, font, paint)
# Convert Skia surface to GTK Pixbuf and blit to Cairo context
image = self.skia_surface.makeImageSnapshot()
png_data = image.encodeToData().bytes()
# Load PNG data into a Pixbuf
from io import BytesIO
loader = GdkPixbuf.PixbufLoader.new_with_type("png")
loader.write(png_data)
loader.close()
pixbuf = loader.get_pixbuf()
# Render pixbuf to Cairo context
Gdk.cairo_set_source_pixbuf(context, pixbuf, 0, 0)
context.paint()
self.logger.debug("on_draw end")
def _get_content_text(self) -> str:
"""Extract text content from active tab's document."""
if not self.browser.active_tab:
return ""
frame = self.browser.active_tab.main_frame
if not frame.document:
return ""
# Extract text from document tree
return self._extract_text(frame.document)
def _extract_text(self, node) -> str:
"""Recursively extract text from HTML tree."""
from ..parser.html import Text, Element
if isinstance(node, Text):
return node.text
elif isinstance(node, Element):
texts = []
for child in node.children:
texts.append(self._extract_text(child))
return " ".join(texts)
return ""
def _render_text_content(self, canvas, text: str, width: int, height: int):
"""Render text content with basic word wrapping."""
paint = skia.Paint()
paint.setAntiAlias(True)
paint.setColor(skia.ColorBLACK)
font_size = 14
font = skia.Font(skia.Typeface.MakeDefault(), font_size)
# Simple word wrapping
words = text.split()
lines = []
current_line = []
current_width = 0
max_width = width - 40 # 20px margin on each side
for word in words:
word_width = font.measureText(word + " ")
if current_width + word_width > max_width and current_line:
lines.append(" ".join(current_line))
current_line = [word]
current_width = word_width
else:
current_line.append(word)
current_width += word_width
if current_line:
lines.append(" ".join(current_line))
# Draw lines
y = 30
line_height = font_size * 1.4
for line in lines:
if y > height - 20: # Don't draw past bottom
break
canvas.drawString(line, 20, y, font, paint)
y += line_height
def paint(self):
"""Trigger redraw of the drawing area."""
if self.drawing_area:
self.drawing_area.queue_draw()