"""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()