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-09 13:31:55 +00:00
|
|
|
|
from functools import partial
|
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-09 13:31:55 +00:00
|
|
|
|
from gi.repository import Gtk, Gdk, GdkPixbuf, Adw
|
2026-01-09 12:31:48 +00:00
|
|
|
|
import skia
|
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
|
|
|
|
|
|
self.tabs_box: Optional[Gtk.Box] = None
|
|
|
|
|
|
self.skia_surface: Optional[skia.Surface] = None
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
|
# Navigation buttons in header bar
|
|
|
|
|
|
nav_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
|
|
|
|
nav_box.add_css_class("linked")
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
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
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# Tabs bar: contains tab buttons and a new-tab button
|
|
|
|
|
|
self.tabs_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
2026-01-09 13:31:55 +00:00
|
|
|
|
self.tabs_box.set_margin_start(8)
|
|
|
|
|
|
self.tabs_box.set_margin_end(8)
|
|
|
|
|
|
self.tabs_box.set_margin_top(6)
|
|
|
|
|
|
self.tabs_box.set_margin_bottom(6)
|
|
|
|
|
|
|
|
|
|
|
|
tabs_frame = Gtk.Frame()
|
|
|
|
|
|
tabs_frame.set_child(self.tabs_box)
|
|
|
|
|
|
vbox.append(tabs_frame)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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
|
2026-01-09 13:36:49 +00:00
|
|
|
|
|
|
|
|
|
|
# Clear existing tabs
|
2026-01-09 12:31:48 +00:00
|
|
|
|
self._clear_children(self.tabs_box)
|
|
|
|
|
|
|
2026-01-09 13:52:05 +00:00
|
|
|
|
# Add each tab as an integrated unit
|
2026-01-09 12:31:48 +00:00
|
|
|
|
for i, tab in enumerate(self.browser.tabs):
|
2026-01-09 13:36:49 +00:00
|
|
|
|
is_active = tab is self.browser.active_tab
|
2026-01-09 13:34:48 +00:00
|
|
|
|
|
2026-01-09 13:52:05 +00:00
|
|
|
|
# Create integrated tab widget
|
|
|
|
|
|
tab_widget = self._create_integrated_tab(tab, i, is_active)
|
|
|
|
|
|
self.tabs_box.append(tab_widget)
|
2026-01-09 13:36:49 +00:00
|
|
|
|
|
|
|
|
|
|
# New tab button
|
|
|
|
|
|
new_tab_btn = Gtk.Button(label="+")
|
|
|
|
|
|
new_tab_btn.set_size_request(48, -1)
|
|
|
|
|
|
new_tab_btn.add_css_class("flat")
|
|
|
|
|
|
new_tab_btn.set_tooltip_text("New Tab")
|
|
|
|
|
|
new_tab_btn.connect("clicked", self._on_new_tab_clicked)
|
|
|
|
|
|
self.tabs_box.append(new_tab_btn)
|
|
|
|
|
|
|
2026-01-09 13:52:05 +00:00
|
|
|
|
def _create_integrated_tab(self, tab, index: int, is_active: bool) -> Gtk.Widget:
|
|
|
|
|
|
"""Create an integrated tab widget with close button as one unit."""
|
|
|
|
|
|
# Outer container - this is the visual tab
|
|
|
|
|
|
tab_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
|
|
|
|
tab_container.add_css_class("integrated-tab")
|
|
|
|
|
|
if is_active:
|
|
|
|
|
|
tab_container.add_css_class("active-tab")
|
|
|
|
|
|
tab_container.set_homogeneous(False)
|
|
|
|
|
|
|
|
|
|
|
|
# Left side: tab label (expandable)
|
|
|
|
|
|
tab_label = f"{index+1}: {tab.title}"
|
|
|
|
|
|
tab_btn = Gtk.Button(label=tab_label)
|
|
|
|
|
|
tab_btn.set_hexpand(True)
|
|
|
|
|
|
tab_btn.add_css_class("tab-label")
|
|
|
|
|
|
tab_btn.add_css_class("flat")
|
|
|
|
|
|
tab_btn.tab = tab
|
|
|
|
|
|
tab_btn.connect("clicked", self._on_tab_clicked)
|
|
|
|
|
|
tab_container.append(tab_btn)
|
|
|
|
|
|
|
|
|
|
|
|
# Right side: close button (fixed width, no expand)
|
|
|
|
|
|
close_btn = Gtk.Button(label="✕")
|
|
|
|
|
|
close_btn.set_size_request(34, -1)
|
|
|
|
|
|
close_btn.add_css_class("tab-close")
|
|
|
|
|
|
close_btn.add_css_class("flat")
|
|
|
|
|
|
close_btn.tab = tab
|
|
|
|
|
|
close_btn.connect("clicked", self._on_close_clicked)
|
|
|
|
|
|
tab_container.append(close_btn)
|
|
|
|
|
|
|
|
|
|
|
|
# Apply CSS styling to make it look like one unit
|
|
|
|
|
|
css = Gtk.CssProvider()
|
|
|
|
|
|
css.load_from_data(b"""
|
|
|
|
|
|
.integrated-tab {
|
|
|
|
|
|
background-color: @theme_bg_color;
|
|
|
|
|
|
border: 1px solid @borders;
|
|
|
|
|
|
border-radius: 4px 4px 0 0;
|
|
|
|
|
|
margin-right: 2px;
|
|
|
|
|
|
padding: 0px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.integrated-tab.active-tab {
|
|
|
|
|
|
background-color: @theme_base_color;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tab-label {
|
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tab-label:hover {
|
|
|
|
|
|
background-color: mix(@theme_bg_color, @theme_fg_color, 0.95);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tab-close {
|
|
|
|
|
|
padding: 2px 4px;
|
|
|
|
|
|
font-size: 0.9em;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-left: 1px solid @borders;
|
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
|
min-width: 32px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tab-close:hover {
|
|
|
|
|
|
background-color: @error_color;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
}
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
context = tab_container.get_style_context()
|
|
|
|
|
|
context.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
|
|
|
|
|
|
|
|
|
|
|
return tab_container
|
|
|
|
|
|
|
2026-01-09 13:36:49 +00:00
|
|
|
|
def _on_tab_clicked(self, btn: Gtk.Button):
|
|
|
|
|
|
"""Handle tab button click - set as active."""
|
|
|
|
|
|
if hasattr(btn, 'tab'):
|
|
|
|
|
|
self.browser.set_active_tab(btn.tab)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_close_clicked(self, btn: Gtk.Button):
|
|
|
|
|
|
"""Handle close button click - close the tab."""
|
|
|
|
|
|
if hasattr(btn, 'tab'):
|
|
|
|
|
|
self.browser.close_tab(btn.tab)
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
|
"""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)
|
|
|
|
|
|
|
2026-01-09 13:11:46 +00:00
|
|
|
|
# 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)
|
2026-01-09 12:31:48 +00:00
|
|
|
|
|
|
|
|
|
|
# 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")
|
2026-01-09 13:11:46 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-01-09 11:20:46 +00:00
|
|
|
|
|
|
|
|
|
|
def paint(self):
|
2026-01-09 12:31:48 +00:00
|
|
|
|
"""Trigger redraw of the drawing area."""
|
|
|
|
|
|
if self.drawing_area:
|
|
|
|
|
|
self.drawing_area.queue_draw()
|