bowser/src/browser/chrome.py
Benedikt Willi a9d52e49c8 Improve tab bar visual integration with modern browser-like styling
Enhanced tab widget design:
- Unified tab widget with integrated close button (no visual separation)
- Close button on the right with rounded right corners
- Tab button with rounded left corners for seamless integration
- Custom CSS styling for professional appearance
- Hover effects: close button highlights in warning color
- Tab highlights as suggested-action when active

Visual improvements:
- Tabs have rounded corners (4px radius)
- Proper borders matching theme colors
- Better padding and spacing
- Flat buttons by default except active tab
- Close button marked with ✕ instead of ×
- Better visual feedback on hover

UX improvements:
- Tooltips on all buttons (tab URL, close tab, new tab)
- Small square close button (32px) for easy clicking
- Active tab uses blue suggested-action styling
- Tabs show full URL on hover
- New tab uses about:startpage instead of example.com

CSS styling:
- Theme-aware colors using @view_bg_color, @borders, @warning_color
- Proper border-radius on tab and close button
- Visual separation between tab and close button
- Hover state for better feedback

All tests passing (15/15)
2026-01-09 14:34:48 +01:00

355 lines
13 KiB
Python

"""Browser chrome (Adwaita UI)."""
import gi
from typing import Optional
import logging
from functools import partial
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Gdk, GdkPixbuf, Adw
import skia
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.tabs_box: Optional[Gtk.Box] = None
self.skia_surface: Optional[skia.Surface] = None
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)
# 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(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)
# 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 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)
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 integrated close button
for i, tab in enumerate(self.browser.tabs):
# Create a custom tab widget with better visual integration
tab_widget = self._create_tab_widget(tab, i)
self.tabs_box.append(tab_widget)
# New tab '+' button at the end
plus_btn = Gtk.Button(label="+")
plus_btn.set_tooltip_text("New Tab")
plus_btn.add_css_class("flat")
plus_btn.connect("clicked", lambda _b: self.browser.new_tab("about:startpage"))
self.tabs_box.append(plus_btn)
def _create_tab_widget(self, tab, index: int) -> Gtk.Widget:
"""Create a visually integrated tab widget with close button."""
# Main container for the tab
tab_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
tab_container.add_css_class("tab-widget")
# Determine styling
is_active = tab is self.browser.active_tab
# Tab button - shows title and handles activation
label = f"{index+1}: {tab.title}"
tab_btn = Gtk.Button(label=label)
tab_btn.set_hexpand(False)
tab_btn.add_css_class("tab-button")
if is_active:
tab_btn.add_css_class("suggested-action")
else:
tab_btn.add_css_class("flat")
tab_btn.set_tooltip_text(str(tab.current_url) if tab.current_url else "New Tab")
tab_btn.connect("clicked", partial(self.browser.set_active_tab, tab))
tab_container.append(tab_btn)
# Close button - appears inline, flat styling
close_btn = Gtk.Button(label="")
close_btn.set_size_request(32, -1) # Small, square button
close_btn.add_css_class("tab-close-button")
close_btn.add_css_class("flat")
close_btn.set_tooltip_text("Close tab")
close_btn.connect("clicked", partial(self._on_close_tab_clicked, tab, tab_container))
tab_container.append(close_btn)
# Apply CSS styling for better visual integration
css_provider = Gtk.CssProvider()
css_provider.load_from_data(b"""
.tab-widget {
border-radius: 4px;
margin-right: 2px;
padding: 0px;
background-color: @view_bg_color;
border: 1px solid @borders;
}
.tab-widget:hover {
background-color: mix(@view_bg_color, @theme_fg_color, 0.95);
}
.tab-button {
border-radius: 4px 0px 0px 4px;
padding: 4px 8px;
border: 0px;
font-weight: 500;
min-width: 80px;
}
.tab-button:focus {
outline: none;
}
.tab-close-button {
border-radius: 0px 4px 4px 0px;
padding: 4px 4px;
border: 0px;
margin-left: -1px;
min-width: 32px;
font-size: 0.9em;
}
.tab-close-button:hover {
background-color: @warning_color;
color: white;
}
""")
context = tab_container.get_style_context()
context.add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
return tab_container
def _on_close_tab_clicked(self, tab, tab_widget: Gtk.Widget):
"""Handle tab close button click."""
self.browser.close_tab(tab)
# The widget will be removed when rebuild_tab_bar is called by browser
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()