mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
- 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
263 lines
9.2 KiB
Python
263 lines
9.2 KiB
Python
"""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()
|