diff --git a/.github/skills/skills.md b/.github/skills/skills.md new file mode 100644 index 0000000..0201185 --- /dev/null +++ b/.github/skills/skills.md @@ -0,0 +1,345 @@ +# Skills Index + +This file defines **canonical project skills** the LLM must follow. Each skill specifies the *one correct command* for a common project action. + +1. **Run the Project** → `uv run bowser` +2. **Test the Project** → `uv run pytest` +3. **Lint the Project** → `uv run ruff` + +Deviating from these commands is considered incorrect behavior unless explicitly instructed. + +--- + +# Skill: Run the Project with `uv run bowser` + +## Purpose +Teach the LLM **how and when to run this project** using the canonical command: + +```bash +uv run bowser +``` + +This skill ensures the LLM consistently uses the correct entry point, avoids ad‑hoc commands, and follows project conventions. + +--- + +## Canonical Command + +**Always run the project using:** + +```bash +uv run bowser +``` + +Do **not**: +- Call `python` directly +- Run scripts via file paths +- Use alternative task runners (e.g. `make`, `poetry run`, `pipenv run`) + +`uv` is the authoritative environment and dependency manager for this project, and `bowser` is the defined runtime entry point. + +--- + +## What `uv run bowser` Means + +- `uv run`: + - Ensures dependencies are resolved and installed according to the project configuration + - Executes commands inside the correct, isolated environment + +- `bowser`: + - The project’s primary executable / CLI + - Encapsulates startup logic, configuration loading, and runtime behavior + +Together, they guarantee a reproducible and correct execution environment. + +--- + +## When to Use This Command + +Use `uv run bowser` whenever you need to: + +- Start the application +- Run the main service or agent +- Execute project logic end‑to‑end +- Validate runtime behavior +- Demonstrate how the project is launched + +If the task says **“run the project”**, **“start the app”**, or **“execute Bowser”**, this is the command. + +--- + +## When *Not* to Use This Command + +Do **not** use `uv run bowser` when: + +- Running tests (use the project’s test command instead) +- Running one‑off scripts unless explicitly routed through `bowser` +- Installing dependencies +- Linting or formatting code + +If unsure, default to **not running anything** and explain what would be executed. + +--- + +## How to Explain This to Humans + +When documenting or instructing users, say: + +> “Run the project with `uv run bowser`.” + +Optionally add: + +> “This ensures the correct environment and entry point are used.” + +Do **not** over‑explain unless the user asks. + +--- + +## Error Handling Guidance + +If `uv run bowser` fails: + +1. Assume a dependency or configuration issue +2. Report the error output verbatim +3. Do **not** substitute another execution method +4. Suggest fixing the root cause, not changing the command + +--- + +## Mental Model for the LLM + +- There is **one** correct way to run the project +- That way is **stable and intentional** +- Deviating from it is a bug + +Think of `uv run bowser` as: + +> “The project’s on‑switch.” + +--- + +## Summary (Checklist) + +Before suggesting how to run the project, verify: + +- [ ] You are using `uv run` +- [ ] You are invoking `bowser` +- [ ] You are not calling Python directly +- [ ] You are not inventing alternate commands + +If all are true, you are doing it right. + + +--- + +# Skill: Test the Project with `uv run pytest` + +## Purpose +Teach the LLM **how and when to run tests** for this project using the canonical command: + +```bash +uv run pytest +``` + +This skill ensures tests are executed in the correct environment, using the project’s standard tooling, without inventing alternate commands. + +--- + +## Canonical Command + +**Always run tests using:** + +```bash +uv run pytest +``` + +Do **not**: +- Call `pytest` directly +- Use `python -m pytest` +- Run tests via ad-hoc scripts or task runners + +`uv` is the authoritative environment manager, and `pytest` is the test runner for this project. + +--- + +## What `uv run pytest` Means + +- `uv run`: + - Ensures dependencies (including test dependencies) are resolved correctly + - Runs inside the same environment model as the application + +- `pytest`: + - Discovers and runs the project’s test suite + - Applies project-level configuration (e.g. `pytest.ini`, `pyproject.toml`) + +Together, they guarantee consistent and reproducible test execution. + +--- + +## When to Use This Command + +Use `uv run pytest` whenever you need to: + +- Run the full test suite +- Verify a change before or after modifying code +- Reproduce a failing test +- Validate behavior without starting the application + +If the task says **“run tests”**, **“test the project”**, or **“verify with pytest”**, this is the command. + +--- + +## When *Not* to Use This Command + +Do **not** use `uv run pytest` when: + +- Running the application (use `uv run bowser`) +- Installing dependencies +- Linting or formatting code +- Executing non-test scripts + +If unsure, default to explaining what tests would be run rather than executing them. + +--- + +## Error Handling Guidance + +If `uv run pytest` fails: + +1. Capture and report the full pytest output +2. Identify whether the failure is: + - A test assertion failure + - A missing dependency or import error + - A configuration issue +3. Do **not** change the command to work around the failure +4. Fix the underlying cause, then re-run the same command + +--- + +## Mental Model for the LLM + +- There is **one** correct way to run tests +- Test execution should mirror the real runtime environment +- Consistency matters more than convenience + +Think of `uv run pytest` as: + +> “The project’s truth-check.” + +--- + +## Summary (Checklist) + +Before suggesting how to test the project, verify: + +- [ ] You are using `uv run` +- [ ] You are invoking `pytest` +- [ ] You are not calling Python directly +- [ ] You are not inventing alternate test commands + +If all are true, you are doing it right. + + +--- + +# Skill: Lint the Project with `uv run ruff` + +## Purpose +Teach the LLM **how and when to lint the project** using the canonical command: + +```bash +uv run ruff +``` + +This skill ensures linting is performed consistently, using the project’s configured rules and environment. + +--- + +## Canonical Command + +**Always lint the project using:** + +```bash +uv run ruff +``` + +Do **not**: +- Call `ruff` directly +- Use alternative linters unless explicitly instructed +- Invoke formatting or linting via ad-hoc scripts + +`uv` guarantees the correct environment, and `ruff` enforces the project’s linting standards. + +--- + +## What `uv run ruff` Means + +- `uv run`: + - Executes linting inside the managed project environment + - Ensures the correct version of `ruff` and dependencies are used + +- `ruff`: + - Performs fast, opinionated linting + - Applies rules configured in project files (e.g. `pyproject.toml`) + +Together, they provide deterministic and repeatable lint results. + +--- + +## When to Use This Command + +Use `uv run ruff` whenever you need to: + +- Check code quality +- Identify linting or style issues +- Validate changes before committing +- Respond to a request to “lint the project” or “run ruff” + +--- + +## When *Not* to Use This Command + +Do **not** use `uv run ruff` when: + +- Running the application (`uv run bowser`) +- Running tests (`uv run pytest`) +- Formatting code unless `ruff` is explicitly configured to do so +- Installing dependencies + +If unsure, explain what linting would check instead of executing it. + +--- + +## Error Handling Guidance + +If `uv run ruff` reports issues: + +1. Treat findings as authoritative +2. Report errors or warnings clearly +3. Do **not** suppress or bypass lint rules +4. Fix the code, then re-run the same command + +--- + +## Mental Model for the LLM + +- Linting enforces shared standards +- Speed and consistency matter more than flexibility +- There is **one** correct linting command + +Think of `uv run ruff` as: + +> “The project’s code-quality gate.” + +--- + +## Summary (Checklist) + +Before suggesting how to lint the project, verify: + +- [ ] You are using `uv run` +- [ ] You are invoking `ruff` +- [ ] You are not inventing alternate linting tools + +If all are true, you are doing it right. + diff --git a/main.py b/main.py index 197bec6..1d66eb1 100644 --- a/main.py +++ b/main.py @@ -32,11 +32,11 @@ def main(): _configure_logging(args) browser = Browser() - + # Enable debug mode in chrome if --debug flag is set if args.debug: browser.chrome.debug_mode = True - + # If no URL provided, use startpage url = args.url if args.url else "about:startpage" browser.new_tab(url) diff --git a/pyproject.toml b/pyproject.toml index 4b88839..3c38159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "Jinja2>=3.0", # Template engine for pages ] -[project.optional-dependencies] +[dependency-groups] dev = [ "pytest>=9.0.0", "pytest-cov>=7.0.0", @@ -35,12 +35,14 @@ managed = true packages = ["src"] [tool.black] -line-length = 100 +line-length = 120 target-version = ["py311"] [tool.ruff] -line-length = 100 +line-length = 120 target-version = "py311" + +[tool.ruff.lint] select = ["E", "F", "W"] [tool.mypy] diff --git a/src/browser/browser.py b/src/browser/browser.py index c92e34b..40fe7cc 100644 --- a/src/browser/browser.py +++ b/src/browser/browser.py @@ -1,3 +1,4 @@ +# ruff: noqa: E402 """Browser entry and orchestration.""" import gi diff --git a/src/browser/chrome.py b/src/browser/chrome.py index e030d1e..ebbe37c 100644 --- a/src/browser/chrome.py +++ b/src/browser/chrome.py @@ -1,3 +1,4 @@ +# ruff: noqa: E402 """Browser chrome (Adwaita UI).""" import gi @@ -34,38 +35,38 @@ class Chrome: self.skia_surface: Optional[skia.Surface] = None self.tab_pages: dict = {} # Map tab objects to AdwTabPage self._closing_tabs: set = set() # Track tabs being closed to prevent re-entry - + # Render pipeline - handles layout and painting self.render_pipeline = RenderPipeline() - + # Debug mode state self.debug_mode = False - + # FPS tracking for debug mode self.frame_times = [] # List of recent frame timestamps self.fps = 0.0 - + # Profiling data self._last_profile = {} self._last_profile_total = 0.0 - + # Scroll state self.scroll_y = 0 self.document_height = 0 # Total document height for scroll limits self.viewport_height = 0 # Current viewport height - + # Scrollbar fade state self.scrollbar_opacity = 0.0 self.scrollbar_fade_timeout = None - + # Selection state self.selection_start = None # (x, y) of selection start self.selection_end = None # (x, y) of selection end self.is_selecting = False # True while mouse is dragging - + # Layout information for text selection (populated from render pipeline) self.text_layout = [] - + # Sub-timings for detailed profiling self._render_sub_timings = {} self._visible_line_count = 0 @@ -76,7 +77,7 @@ class Chrome: 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) @@ -89,30 +90,30 @@ class Chrome: # 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") @@ -120,26 +121,26 @@ class Chrome: # Create TabView for managing tabs self.tab_view = Adw.TabView() - + # Create TabBar for tab display self.tab_bar = Adw.TabBar() self.tab_bar.set_view(self.tab_view) self.tab_bar.set_autohide(False) - + # Add New Tab button to the tab bar new_tab_btn = Gtk.Button() new_tab_btn.set_icon_name("list-add-symbolic") new_tab_btn.set_tooltip_text("New Tab") new_tab_btn.connect("clicked", lambda _: self.browser.new_tab("about:startpage")) self.tab_bar.set_end_action_widget(new_tab_btn) - + vbox.append(self.tab_bar) - + # Create a container box for content that will hold the drawing area content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) content_box.set_vexpand(True) content_box.set_hexpand(True) - + # Create the drawing area for rendering page content self.drawing_area = Gtk.DrawingArea() self.drawing_area.set_vexpand(True) @@ -148,25 +149,25 @@ class Chrome: self.drawing_area.set_can_focus(True) # Allow focus for keyboard events self.drawing_area.set_focusable(True) content_box.append(self.drawing_area) - + # Add scroll controller for mouse wheel scroll_controller = Gtk.EventControllerScroll.new( Gtk.EventControllerScrollFlags.VERTICAL ) scroll_controller.connect("scroll", self._on_scroll) self.drawing_area.add_controller(scroll_controller) - + # Add mouse button controller for selection click_controller = Gtk.GestureClick.new() click_controller.connect("pressed", self._on_mouse_pressed) click_controller.connect("released", self._on_mouse_released) self.drawing_area.add_controller(click_controller) - + # Add motion controller for drag selection motion_controller = Gtk.EventControllerMotion.new() motion_controller.connect("motion", self._on_mouse_motion) self.drawing_area.add_controller(motion_controller) - + # Add content box to vbox (not to TabView - we use a single drawing area for all tabs) vbox.append(content_box) @@ -192,59 +193,59 @@ class Chrome: 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()) - + # Connect TabView signals if self.tab_view: self.tab_view.connect("page-attached", self._on_page_attached) self.tab_view.connect("close-page", self._on_close_page) self.tab_view.connect("notify::selected-page", self._on_selected_page_changed) - + # Setup keyboard shortcuts self._setup_keyboard_shortcuts() - + # Add any tabs that were created before the window for tab in self.browser.tabs: if tab not in self.tab_pages: self._add_tab_to_ui(tab) - + # Set the active tab in the UI if self.browser.active_tab and self.browser.active_tab in self.tab_pages: page = self.tab_pages[self.browser.active_tab] self.tab_view.set_selected_page(page) - + # Show the window self.window.present() - + def _add_tab_to_ui(self, tab): """Internal method to add a tab to the TabView UI.""" # Create a simple placeholder for the tab # (Actual rendering happens in the shared drawing_area) placeholder = Gtk.Box() - + # Create the tab page page = self.tab_view.append(placeholder) page.set_title(tab.title) - + # Store mapping self.tab_pages[tab] = page - + # Select this tab self.tab_view.set_selected_page(page) - + def add_tab(self, tab): """Add a tab to the TabView.""" if not self.tab_view: # Window not created yet, tab will be added when window is created return - + self._add_tab_to_ui(tab) - + def update_tab(self, tab): """Update tab title and other properties.""" if tab in self.tab_pages: page = self.tab_pages[tab] page.set_title(tab.title) - + def remove_tab(self, tab): """Remove a tab from the TabView programmatically.""" if tab not in self.tab_pages: @@ -253,20 +254,20 @@ class Chrome: del self.tab_pages[tab] # Directly close the page - this triggers _on_close_page but we've already removed from tab_pages self.tab_view.close_page(page) - + def set_active_tab(self, tab): """Set the active tab in the TabView.""" if tab in self.tab_pages: page = self.tab_pages[tab] self.tab_view.set_selected_page(page) - + def _on_page_attached(self, tab_view, page, position): """Handle when a page is attached to the TabView.""" self.logger.debug(f"Page attached at position {position}") - + def _on_close_page(self, tab_view, page): """Handle tab close request from UI. - + This is called when close_page() is invoked. We must call close_page_finish() to actually complete the page removal. """ @@ -276,7 +277,7 @@ class Chrome: if tab_page == page: tab_to_close = tab break - + if tab_to_close: # Remove from our tracking del self.tab_pages[tab_to_close] @@ -289,7 +290,7 @@ class Chrome: # Page not in our tracking - just confirm the close self.tab_view.close_page_finish(page, True) return True - + def _on_selected_page_changed(self, tab_view, pspec): """Handle tab selection change.""" selected_page = tab_view.get_selected_page() @@ -321,7 +322,7 @@ class Chrome: 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.""" + """Callback for drawing the content area using Skia.""" # Track frame time for FPS calculation current_time = time.time() self.frame_times.append(current_time) @@ -329,17 +330,17 @@ class Chrome: self.frame_times = [t for t in self.frame_times if current_time - t < 1.0] if len(self.frame_times) > 1: self.fps = len(self.frame_times) - + # Profiling timers profile_start = time.perf_counter() timings = {} - + # Create Skia surface for this frame t0 = time.perf_counter() self.skia_surface = skia.Surface(width, height) canvas = self.skia_surface.getCanvas() timings['surface_create'] = time.perf_counter() - t0 - + # Store viewport height self.viewport_height = height @@ -355,11 +356,11 @@ class Chrome: t0 = time.perf_counter() self._render_dom_content(canvas, document, width, height) timings['render_dom'] = time.perf_counter() - t0 - + t0 = time.perf_counter() self._draw_scrollbar(canvas, width, height) timings['scrollbar'] = time.perf_counter() - t0 - + if self.debug_mode: self._draw_fps_counter(canvas, width) else: @@ -373,11 +374,11 @@ class Chrome: t0 = time.perf_counter() image = self.skia_surface.makeImageSnapshot() timings['snapshot'] = time.perf_counter() - t0 - + t0 = time.perf_counter() pixels = image.tobytes() timings['tobytes'] = time.perf_counter() - t0 - + # Create Cairo ImageSurface from raw pixels t0 = time.perf_counter() cairo_surface = cairo.ImageSurface.create_for_data( @@ -388,39 +389,39 @@ class Chrome: width * 4 # stride ) timings['cairo_surface'] = time.perf_counter() - t0 - + # Blit Cairo surface to context t0 = time.perf_counter() context.set_source_surface(cairo_surface, 0, 0) context.paint() timings['cairo_blit'] = time.perf_counter() - t0 - + total_time = time.perf_counter() - profile_start - + # Store profiling data for debug display if self.debug_mode: self._last_profile = timings self._last_profile_total = total_time - + def _render_dom_content(self, canvas, document, width: int, height: int): """Render the DOM content using the render pipeline.""" sub_timings = {} - + # Sync debug mode with render pipeline self.render_pipeline.debug_mode = self.debug_mode - + # Use render pipeline for layout and rendering t0 = time.perf_counter() self.render_pipeline.render(canvas, document, width, height, self.scroll_y) sub_timings['render'] = time.perf_counter() - t0 - + # Get text layout for selection t0 = time.perf_counter() self.text_layout = self.render_pipeline.get_text_layout() self.document_height = self.render_pipeline.get_document_height() sub_timings['get_layout'] = time.perf_counter() - t0 - + # Draw selection highlight (still in chrome as it's UI interaction) t0 = time.perf_counter() if self.selection_start and self.selection_end: @@ -429,61 +430,63 @@ class Chrome: self._draw_text_selection(canvas) canvas.restore() sub_timings['selection'] = time.perf_counter() - t0 - + # Store sub-timings for display if self.debug_mode: self._render_sub_timings = sub_timings - self._visible_line_count = len([l for l in self.text_layout - if self.scroll_y - 50 <= l["y"] + l["font_size"] <= self.scroll_y + height + 50]) + self._visible_line_count = len([ + line for line in self.text_layout + if self.scroll_y - 50 <= line["y"] + line["font_size"] <= self.scroll_y + height + 50 + ]) def _draw_selection_highlight(self, canvas, width: int): """Draw selection highlight rectangle.""" if not self.selection_start or not self.selection_end: return - + x1, y1 = self.selection_start x2, y2 = self.selection_end - + # Normalize coordinates left = min(x1, x2) right = max(x1, x2) top = min(y1, y2) bottom = max(y1, y2) - + paint = skia.Paint() paint.setColor(skia.Color(100, 149, 237, 80)) # Cornflower blue, semi-transparent paint.setStyle(skia.Paint.kFill_Style) - + rect = skia.Rect.MakeLTRB(left, top, right, bottom) canvas.drawRect(rect, paint) - + def _draw_fps_counter(self, canvas, width: int): """Draw FPS counter and profiling info in top-right corner.""" font = get_font(11) small_font = get_font(9) - + # Calculate panel size based on profile data panel_width = 200 num_profile_lines = len(self._last_profile) + 2 # +2 for FPS and total num_sub_lines = len(self._render_sub_timings) + 1 if self._render_sub_timings else 0 panel_height = 18 + num_profile_lines * 12 + num_sub_lines * 11 + 10 - + # Position in top-right panel_x = width - panel_width - 10 panel_y = 10 - + # Background bg_paint = skia.Paint() bg_paint.setColor(skia.Color(0, 0, 0, 200)) bg_paint.setStyle(skia.Paint.kFill_Style) canvas.drawRect(skia.Rect.MakeLTRB( - panel_x, panel_y, + panel_x, panel_y, panel_x + panel_width, panel_y + panel_height ), bg_paint) - + text_paint = skia.Paint() text_paint.setAntiAlias(True) - + # FPS with color if self.fps >= 50: text_paint.setColor(skia.Color(100, 255, 100, 255)) @@ -491,29 +494,29 @@ class Chrome: text_paint.setColor(skia.Color(255, 255, 100, 255)) else: text_paint.setColor(skia.Color(255, 100, 100, 255)) - + y = panel_y + 14 canvas.drawString(f"FPS: {self.fps:.0f}", panel_x + 5, y, font, text_paint) - + # Total frame time text_paint.setColor(skia.ColorWHITE) total_ms = self._last_profile_total * 1000 y += 14 canvas.drawString(f"Frame: {total_ms:.1f}ms", panel_x + 5, y, font, text_paint) - + # Profile breakdown gray_paint = skia.Paint() gray_paint.setAntiAlias(True) gray_paint.setColor(skia.Color(180, 180, 180, 255)) - + if self._last_profile: # Sort by time descending sorted_items = sorted( - self._last_profile.items(), - key=lambda x: x[1], + self._last_profile.items(), + key=lambda x: x[1], reverse=True ) - + for name, duration in sorted_items: y += 12 ms = duration * 1000 @@ -526,13 +529,19 @@ class Chrome: else: gray_paint.setColor(skia.Color(180, 180, 180, 255)) canvas.drawString(f"{name}: {ms:.1f}ms ({pct:.0f}%)", panel_x + 8, y, small_font, gray_paint) - + # Show render_dom sub-timings if available if self._render_sub_timings: y += 16 text_paint.setColor(skia.Color(150, 200, 255, 255)) - canvas.drawString(f"render_dom breakdown ({self._visible_line_count} lines):", panel_x + 5, y, small_font, text_paint) - + canvas.drawString( + f"render_dom breakdown ({self._visible_line_count} lines):", + panel_x + 5, + y, + small_font, + text_paint, + ) + sub_sorted = sorted(self._render_sub_timings.items(), key=lambda x: x[1], reverse=True) for name, duration in sub_sorted: y += 11 @@ -544,35 +553,35 @@ class Chrome: """Trigger redraw of the drawing area.""" if self.drawing_area: self.drawing_area.queue_draw() - + def _setup_keyboard_shortcuts(self): """Setup keyboard event handling for shortcuts.""" if not self.window: return - + # Create event controller for key presses key_controller = Gtk.EventControllerKey() key_controller.connect("key-pressed", self._on_key_pressed) self.window.add_controller(key_controller) - + def _on_key_pressed(self, controller, keyval, keycode, state): """Handle keyboard shortcuts.""" # Check for Ctrl+Shift+D (DOM graph visualization) ctrl_pressed = state & Gdk.ModifierType.CONTROL_MASK shift_pressed = state & Gdk.ModifierType.SHIFT_MASK - + key_name = Gdk.keyval_name(keyval) - + # Ctrl+Shift+D: DOM graph visualization if ctrl_pressed and shift_pressed and key_name in ('D', 'd'): self._show_dom_graph() return True - + # Ctrl+Shift+O: Toggle debug mode (DOM outline visualization) if ctrl_pressed and shift_pressed and key_name in ('O', 'o'): self._toggle_debug_mode() return True - + # Page scrolling with arrow keys, Page Up/Down, Home/End scroll_amount = 50 if key_name == 'Down': @@ -602,86 +611,86 @@ class Chrome: else: self._scroll_by(400) return True - + return False # Event not handled - + def _toggle_debug_mode(self): """Toggle debug mode for DOM visualization.""" self.debug_mode = not self.debug_mode mode_str = "ON" if self.debug_mode else "OFF" self.logger.info(f"Debug mode: {mode_str}") self.paint() - + def _scroll_by(self, delta: int): """Scroll the page by the given amount, clamped to document bounds.""" max_scroll = max(0, self.document_height - self.viewport_height) self.scroll_y = max(0, min(max_scroll, self.scroll_y + delta)) self._show_scrollbar() self.paint() - + def _show_scrollbar(self): """Show scrollbar and schedule fade out.""" from gi.repository import GLib - + self.scrollbar_opacity = 1.0 - + # Cancel any existing fade timeout if self.scrollbar_fade_timeout: GLib.source_remove(self.scrollbar_fade_timeout) - + # Schedule fade out after 1 second self.scrollbar_fade_timeout = GLib.timeout_add(1000, self._fade_scrollbar) - + def _fade_scrollbar(self): """Gradually fade out the scrollbar.""" from gi.repository import GLib - + self.scrollbar_opacity -= 0.1 if self.scrollbar_opacity <= 0: self.scrollbar_opacity = 0 self.scrollbar_fade_timeout = None self.paint() return False # Stop the timeout - + self.paint() # Continue fading self.scrollbar_fade_timeout = GLib.timeout_add(50, self._fade_scrollbar) return False # This instance is done - + def _draw_scrollbar(self, canvas, width: int, height: int): """Draw the scrollbar overlay.""" if self.scrollbar_opacity <= 0 or self.document_height <= height: return - + # Calculate scrollbar dimensions scrollbar_width = 8 scrollbar_margin = 4 scrollbar_x = width - scrollbar_width - scrollbar_margin - + # Track height (full viewport) track_height = height - 2 * scrollbar_margin - + # Thumb size proportional to viewport/document ratio thumb_ratio = height / self.document_height thumb_height = max(30, track_height * thumb_ratio) - + # Thumb position based on scroll position max_scroll = max(1, self.document_height - height) scroll_ratio = self.scroll_y / max_scroll thumb_y = scrollbar_margin + scroll_ratio * (track_height - thumb_height) - + # Draw track (subtle) alpha = int(30 * self.scrollbar_opacity) track_paint = skia.Paint() track_paint.setColor(skia.Color(0, 0, 0, alpha)) track_paint.setStyle(skia.Paint.kFill_Style) track_rect = skia.RRect.MakeRectXY( - skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin, + skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin, scrollbar_x + scrollbar_width, height - scrollbar_margin), scrollbar_width / 2, scrollbar_width / 2 ) canvas.drawRRect(track_rect, track_paint) - + # Draw thumb alpha = int(150 * self.scrollbar_opacity) thumb_paint = skia.Paint() @@ -693,20 +702,20 @@ class Chrome: scrollbar_width / 2, scrollbar_width / 2 ) canvas.drawRRect(thumb_rect, thumb_paint) - + def _on_scroll(self, controller, dx, dy): """Handle mouse wheel scroll.""" scroll_amount = int(dy * 50) # Scale scroll amount self._scroll_by(scroll_amount) return True - + def _on_mouse_pressed(self, gesture, n_press, x, y): """Handle mouse button press for text selection.""" self.selection_start = (x, y + self.scroll_y) self.selection_end = None self.is_selecting = True self.drawing_area.grab_focus() - + def _on_mouse_released(self, gesture, n_press, x, y): """Handle mouse button release for text selection.""" if self.is_selecting: @@ -719,70 +728,78 @@ class Chrome: # Copy to clipboard self._copy_to_clipboard(selected_text) self.paint() - + def _on_mouse_motion(self, controller, x, y): """Handle mouse motion for drag selection.""" if self.is_selecting: self.selection_end = (x, y + self.scroll_y) self.paint() - + def _draw_text_selection(self, canvas): """Draw selection highlight for selected text at character level.""" if not self.selection_start or not self.selection_end: return - + # Normalize selection: start should be before end in reading order if (self.selection_start[1] > self.selection_end[1] or - (self.selection_start[1] == self.selection_end[1] and + (self.selection_start[1] == self.selection_end[1] and self.selection_start[0] > self.selection_end[0])): sel_start = self.selection_end sel_end = self.selection_start else: sel_start = self.selection_start sel_end = self.selection_end - + paint = skia.Paint() paint.setColor(skia.Color(100, 149, 237, 100)) # Cornflower blue paint.setStyle(skia.Paint.kFill_Style) - + for line_info in self.text_layout: line_top = line_info["y"] line_bottom = line_info["y"] + line_info["height"] line_left = line_info["x"] char_positions = line_info.get("char_positions", []) - text = line_info["text"] - + # text not needed for highlight geometry + # Skip lines completely outside selection if line_bottom < sel_start[1] or line_top > sel_end[1]: continue - + # Determine selection bounds for this line hl_left = line_left hl_right = line_left + line_info["width"] - + # If this line contains the start of selection if line_top <= sel_start[1] < line_bottom: # Find character index at sel_start x start_char_idx = self._x_to_char_index(sel_start[0], line_left, char_positions) - hl_left = line_left + char_positions[start_char_idx] if start_char_idx < len(char_positions) else line_left - + hl_left = ( + line_left + char_positions[start_char_idx] + if start_char_idx < len(char_positions) + else line_left + ) + # If this line contains the end of selection if line_top <= sel_end[1] < line_bottom: # Find character index at sel_end x end_char_idx = self._x_to_char_index(sel_end[0], line_left, char_positions) - hl_right = line_left + char_positions[end_char_idx] if end_char_idx < len(char_positions) else hl_right - + hl_right = ( + line_left + char_positions[end_char_idx] + if end_char_idx < len(char_positions) + else hl_right + ) + # Draw highlight if hl_right > hl_left: rect = skia.Rect.MakeLTRB(hl_left, line_top, hl_right, line_bottom) canvas.drawRect(rect, paint) - + def _x_to_char_index(self, x: float, line_x: float, char_positions: list) -> int: """Convert x coordinate to character index within a line.""" rel_x = x - line_x if rel_x <= 0: return 0 - + # Binary search for the character position for i, pos in enumerate(char_positions): if pos >= rel_x: @@ -790,82 +807,82 @@ class Chrome: if i > 0 and (pos - rel_x) > (rel_x - char_positions[i-1]): return i - 1 return i - + return len(char_positions) - 1 - + def _get_selected_text(self) -> str: """Extract text from the current selection at character level.""" if not self.selection_start or not self.selection_end or not self.text_layout: return "" - + # Normalize selection: start should be before end in reading order if (self.selection_start[1] > self.selection_end[1] or - (self.selection_start[1] == self.selection_end[1] and + (self.selection_start[1] == self.selection_end[1] and self.selection_start[0] > self.selection_end[0])): sel_start = self.selection_end sel_end = self.selection_start else: sel_start = self.selection_start sel_end = self.selection_end - + selected_parts = [] - + for line_info in self.text_layout: line_top = line_info["y"] line_bottom = line_info["y"] + line_info["height"] line_left = line_info["x"] char_positions = line_info.get("char_positions", []) text = line_info["text"] - + # Skip lines completely outside selection if line_bottom < sel_start[1] or line_top > sel_end[1]: continue - + start_idx = 0 end_idx = len(text) - + # If this line contains the start of selection if line_top <= sel_start[1] < line_bottom: start_idx = self._x_to_char_index(sel_start[0], line_left, char_positions) - + # If this line contains the end of selection if line_top <= sel_end[1] < line_bottom: end_idx = self._x_to_char_index(sel_end[0], line_left, char_positions) - + # Extract the selected portion if end_idx > start_idx: selected_parts.append(text[start_idx:end_idx]) - + return " ".join(selected_parts) - + def _copy_to_clipboard(self, text: str): """Copy text to system clipboard.""" clipboard = Gdk.Display.get_default().get_clipboard() clipboard.set(text) - + def _show_dom_graph(self): """Generate and display DOM graph for current tab.""" from ..debug.dom_graph import render_dom_graph_to_svg, save_dom_graph, print_dom_tree - + if not self.browser.active_tab: self.logger.warning("No active tab to visualize") return - + frame = self.browser.active_tab.main_frame if not frame or not frame.document: self.logger.warning("No document to visualize") return - + # Generate output path output_dir = Path.home() / ".cache" / "bowser" output_dir.mkdir(parents=True, exist_ok=True) - + # Try SVG first, fallback to DOT svg_path = output_dir / "dom_graph.svg" dot_path = output_dir / "dom_graph.dot" - + self.logger.info("Generating DOM graph...") - + # Print tree to console for debugging tree_text = print_dom_tree(frame.document, max_depth=15) print("\n" + "="*60) @@ -873,7 +890,7 @@ class Chrome: print("="*60) print(tree_text) print("="*60 + "\n") - + # Try to render as SVG if render_dom_graph_to_svg(frame.document, str(svg_path)): # Open in new browser tab @@ -884,7 +901,7 @@ class Chrome: if save_dom_graph(frame.document, str(dot_path)): self.logger.info(f"Opening DOM graph (DOT format) in new tab: {dot_path}") self.browser.new_tab(f"about:dom-graph?path={dot_path}") - + def _show_info_dialog(self, title: str, message: str): """Show an information dialog.""" dialog = Gtk.MessageDialog( diff --git a/src/browser/tab.py b/src/browser/tab.py index f58724b..daf9f18 100644 --- a/src/browser/tab.py +++ b/src/browser/tab.py @@ -1,6 +1,6 @@ """Tab and frame orchestration stubs.""" -from typing import Optional +from typing import Optional, TYPE_CHECKING import logging from ..network.url import URL @@ -8,6 +8,9 @@ from ..network import http from ..parser.html import parse_html, Element from ..templates import render_startpage, render_error_page +if TYPE_CHECKING: + from .browser import Browser + class Frame: def __init__(self, tab: "Tab", parent_frame=None, frame_element=None): @@ -19,7 +22,7 @@ class Frame: def load(self, url: URL, payload: Optional[bytes] = None): """Fetch and parse the URL content.""" logger = logging.getLogger("bowser.frame") - + # Handle special about: URLs url_str = str(url) if url_str.startswith("about:startpage"): @@ -27,7 +30,7 @@ class Frame: self.document = parse_html(html) self.tab.current_url = url return - + if url_str.startswith("about:dom-graph"): # Extract path parameter from urllib.parse import urlparse, parse_qs @@ -35,19 +38,19 @@ class Frame: parsed = urlparse(url_str) params = parse_qs(parsed.query) graph_path = params.get('path', [''])[0] - + html = render_dom_graph_page(graph_path) self.document = parse_html(html) self.tab.current_url = url return - + try: status, content_type, body = http.request(url, payload) - + if status == 200: # Decode response text = body.decode('utf-8', errors='replace') - + # Parse HTML self.document = parse_html(text) self.tab.current_url = url @@ -55,7 +58,7 @@ class Frame: # Error handling - show error page html = render_error_page(status, str(url)) self.document = parse_html(html) - + except Exception as e: # Network error - show error page html = render_error_page(0, str(url), str(e)) diff --git a/src/debug/dom_graph.py b/src/debug/dom_graph.py index da936e2..f147a89 100644 --- a/src/debug/dom_graph.py +++ b/src/debug/dom_graph.py @@ -8,33 +8,33 @@ from ..parser.html import Element, Text def generate_dot_graph(document: Optional[Element]) -> str: """ Generate a Graphviz DOT representation of the DOM tree. - + Args: document: Root element of the DOM tree - + Returns: DOT format string representing the DOM tree """ if not document: return 'digraph DOM {\n label="Empty Document";\n}\n' - + lines = [] lines.append('digraph DOM {') lines.append(' rankdir=TB;') lines.append(' node [shape=box, style=filled];') lines.append('') - + node_counter = [0] # mutable counter for unique IDs - + def escape_label(text: str) -> str: """Escape special characters for DOT labels.""" return text.replace('"', '\\"').replace('\n', '\\n') - + def add_node(node, parent_id: Optional[str] = None) -> str: """Recursively add nodes to the graph.""" node_id = f'node_{node_counter[0]}' node_counter[0] += 1 - + if isinstance(node, Text): # Text nodes text_preview = node.text[:50] + ('...' if len(node.text) > 50 else '') @@ -48,9 +48,9 @@ def generate_dot_graph(document: Optional[Element]) -> str: if len(node.attributes) > 3: attrs_list.append('...') attrs_str = '\\n' + ' '.join(attrs_list) - + label = f'<{escape_label(node.tag)}>{escape_label(attrs_str)}' - + # Color code by tag type if node.tag in ('html', 'body'): color = 'lightgreen' @@ -64,45 +64,45 @@ def generate_dot_graph(document: Optional[Element]) -> str: color = 'lightpink' else: color = 'white' - + lines.append(f' {node_id} [label="{label}", fillcolor={color}];') - + # Add edges to children if hasattr(node, 'children'): for child in node.children: child_id = add_node(child, node_id) lines.append(f' {node_id} -> {child_id};') - + return node_id - + add_node(document) lines.append('}') - + return '\n'.join(lines) def save_dom_graph(document: Optional[Element], output_path: str) -> bool: """ Save DOM tree as a DOT file. - + Args: document: Root element of the DOM tree output_path: Path where to save the .dot file - + Returns: True if successful, False otherwise """ logger = logging.getLogger("bowser.debug") - + try: dot_content = generate_dot_graph(document) - + with open(output_path, 'w', encoding='utf-8') as f: f.write(dot_content) - + logger.info(f"DOM graph saved to {output_path}") return True - + except Exception as e: logger.error(f"Failed to save DOM graph: {e}") return False @@ -111,21 +111,21 @@ def save_dom_graph(document: Optional[Element], output_path: str) -> bool: def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bool: """ Render DOM tree as an SVG image using Graphviz (if available). - + Args: document: Root element of the DOM tree output_path: Path where to save the .svg file - + Returns: True if successful, False otherwise """ logger = logging.getLogger("bowser.debug") - + try: import subprocess - + dot_content = generate_dot_graph(document) - + # Try to render with graphviz result = subprocess.run( ['dot', '-Tsvg', '-o', output_path], @@ -133,14 +133,14 @@ def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bo capture_output=True, timeout=10 ) - + if result.returncode == 0: logger.info(f"DOM graph rendered to {output_path}") return True else: logger.warning(f"Graphviz rendering failed: {result.stderr.decode()}") return False - + except FileNotFoundError: logger.warning("Graphviz 'dot' command not found. Install graphviz for SVG output.") return False @@ -152,21 +152,21 @@ def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bo def print_dom_tree(node, indent: int = 0, max_depth: int = 10) -> str: """ Generate a text representation of the DOM tree. - + Args: node: DOM node to print indent: Current indentation level max_depth: Maximum depth to traverse - + Returns: String representation of the tree """ if indent > max_depth: return " " * indent + "...\n" - + lines = [] spacer = " " * indent - + if isinstance(node, Text): text_preview = node.text.strip()[:60] if text_preview: @@ -176,11 +176,11 @@ def print_dom_tree(node, indent: int = 0, max_depth: int = 10) -> str: if node.attributes: attrs_preview = {k: v for k, v in list(node.attributes.items())[:3]} attrs_str = f" {attrs_preview}" - + lines.append(f"{spacer}<{node.tag}>{attrs_str}\n") - + if hasattr(node, 'children'): for child in node.children: lines.append(print_dom_tree(child, indent + 1, max_depth)) - + return "".join(lines) diff --git a/src/layout/block.py b/src/layout/block.py index 302a31c..29f0252 100644 --- a/src/layout/block.py +++ b/src/layout/block.py @@ -6,7 +6,7 @@ from ..parser.html import Element, Text class LineLayout: """Layout for a single line of text.""" - + def __init__(self, parent=None): self.parent = parent self.words = [] # List of (text, x, font_size) @@ -15,7 +15,7 @@ class LineLayout: self.width = 0 self.height = 0 self.font_size = 14 - + def add_word(self, word: str, x: float, font_size: int): """Add a word to this line.""" self.words.append((word, x, font_size)) @@ -27,7 +27,7 @@ class LineLayout: class BlockLayout: """Layout for a block-level element.""" - + def __init__(self, node, parent=None, previous=None, frame=None): self.node = node self.parent = parent @@ -44,30 +44,30 @@ class BlockLayout: self.font_size = 14 self.block_type = "block" self.tag = "" - + def layout(self, x: float, y: float, max_width: float): """Layout this block and return the height used.""" self.x = x self.y = y self.width = max_width - + current_y = y + self.margin_top - + # Layout children for child in self.children: child.layout(x, current_y, max_width) current_y += child.height + child.margin_bottom - + # Layout lines for line in self.lines: line.y = current_y current_y += line.height - + self.height = current_y - y + self.margin_bottom return self.height -def build_block_layout(node, parent=None, font_size: int = 14, +def build_block_layout(node, parent=None, font_size: int = 14, margin_top: int = 6, margin_bottom: int = 10, block_type: str = "block", bullet: bool = False) -> BlockLayout: """Build a BlockLayout from a DOM node.""" @@ -77,17 +77,17 @@ def build_block_layout(node, parent=None, font_size: int = 14, block.margin_bottom = margin_bottom block.block_type = block_type block.tag = node.tag if isinstance(node, Element) else "" - + # Collect text content text = _extract_text(node) if bullet and text: text = f"• {text}" - + if text: block._raw_text = text else: block._raw_text = "" - + return block diff --git a/src/layout/document.py b/src/layout/document.py index d9d5476..a87af4c 100644 --- a/src/layout/document.py +++ b/src/layout/document.py @@ -2,12 +2,11 @@ from ..parser.html import Element, Text from ..render.fonts import get_font, linespace -from .block import BlockLayout, LineLayout class LayoutLine: """A laid-out line ready for rendering.""" - + def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None): self.text = text self.x = x @@ -16,7 +15,7 @@ class LayoutLine: self.height = linespace(font_size) self.width = 0 self.char_positions = char_positions or [] - + # Calculate width if text: font = get_font(font_size) @@ -25,7 +24,7 @@ class LayoutLine: class LayoutBlock: """A laid-out block with its lines.""" - + def __init__(self, tag: str, block_type: str = "block"): self.tag = tag self.block_type = block_type @@ -38,7 +37,7 @@ class LayoutBlock: class DocumentLayout: """Layout engine for a document.""" - + def __init__(self, node, frame=None): self.node = node self.frame = frame @@ -46,29 +45,29 @@ class DocumentLayout: self.lines = [] # Flat list of all LayoutLine for rendering self.width = 0 self.height = 0 - + def layout(self, width: int, x_margin: int = 20, y_start: int = 30) -> list: """ Layout the document and return a list of LayoutLine objects. - + Returns: List of LayoutLine objects ready for rendering """ self.width = width max_width = max(10, width - 2 * x_margin) y = y_start - + self.blocks = [] self.lines = [] - + # Find body body = self._find_body(self.node) if not body: return self.lines - + # Collect and layout blocks raw_blocks = self._collect_blocks(body) - + for block_info in raw_blocks: font_size = block_info.get("font_size", 14) text = block_info.get("text", "") @@ -76,26 +75,26 @@ class DocumentLayout: margin_bottom = block_info.get("margin_bottom", 10) block_type = block_info.get("block_type", "block") tag = block_info.get("tag", "") - + if not text: y += font_size * 0.6 continue - + # Optional bullet prefix if block_info.get("bullet"): text = f"• {text}" - + layout_block = LayoutBlock(tag, block_type) layout_block.x = x_margin layout_block.y = y + margin_top - + # Word wrap font = get_font(font_size) words = text.split() wrapped_lines = [] current_line = [] current_width = 0 - + for word in words: word_width = font.measureText(word + " ") if current_width + word_width > max_width and current_line: @@ -107,18 +106,18 @@ class DocumentLayout: current_width += word_width if current_line: wrapped_lines.append(" ".join(current_line)) - + # Create LayoutLines line_height = linespace(font_size) y += margin_top block_start_y = y - + for line_text in wrapped_lines: # Calculate character positions char_positions = [0.0] for i in range(1, len(line_text) + 1): char_positions.append(font.measureText(line_text[:i])) - + layout_line = LayoutLine( text=line_text, x=x_margin, @@ -126,20 +125,20 @@ class DocumentLayout: font_size=font_size, char_positions=char_positions ) - + layout_block.lines.append(layout_line) self.lines.append(layout_line) y += line_height - + layout_block.height = y - block_start_y layout_block.width = max_width self.blocks.append(layout_block) - + y += margin_bottom - + self.height = y + 50 # Padding at bottom return self.lines - + def _find_body(self, node): """Find the body element in the document.""" if isinstance(node, Element) and node.tag == "body": @@ -152,27 +151,27 @@ class DocumentLayout: if found: return found return None - + def _collect_blocks(self, node) -> list: """Collect renderable blocks from the DOM.""" blocks = [] - + for child in getattr(node, "children", []): if isinstance(child, Text): txt = child.text.strip() if txt: blocks.append({"text": txt, "font_size": 14, "block_type": "text"}) continue - + if isinstance(child, Element): tag = child.tag.lower() content = self._text_of(child) if not content: continue - + if tag == "h1": blocks.append({ - "text": content, "font_size": 24, + "text": content, "font_size": 24, "margin_top": 12, "margin_bottom": 12, "block_type": "block", "tag": "h1" }) @@ -215,9 +214,9 @@ class DocumentLayout: "text": content, "font_size": 14, "block_type": "block", "tag": tag }) - + return blocks - + def _text_of(self, node) -> str: """Extract text content from a node.""" if isinstance(node, Text): diff --git a/src/layout/inline.py b/src/layout/inline.py index d58bf01..1f60897 100644 --- a/src/layout/inline.py +++ b/src/layout/inline.py @@ -1,11 +1,11 @@ """Inline and text layout.""" -from ..render.fonts import get_font, measure_text, linespace +from ..render.fonts import get_font, linespace class TextLayout: """Layout for a single word/text run.""" - + def __init__(self, node, word: str, parent=None, previous=None): self.node = node self.word = word @@ -16,7 +16,7 @@ class TextLayout: self.width = 0 self.height = 0 self.font_size = 14 - + def layout(self, font_size: int = 14): """Calculate layout for this text.""" self.font_size = font_size @@ -28,7 +28,7 @@ class TextLayout: class InlineLayout: """Layout for inline content (text runs within a line).""" - + def __init__(self, node, parent=None): self.node = node self.parent = parent @@ -37,14 +37,14 @@ class InlineLayout: self.y = 0 self.width = 0 self.height = 0 - + def add_word(self, word: str, font_size: int = 14): """Add a word to this inline layout.""" text_layout = TextLayout(self.node, word, parent=self) text_layout.layout(font_size) self.children.append(text_layout) return text_layout - + def layout(self, x: float, y: float, max_width: float, font_size: int = 14): """Layout all words, wrapping as needed. Returns list of lines.""" lines = [] @@ -52,7 +52,7 @@ class InlineLayout: current_x = x line_y = y line_height = linespace(font_size) - + for child in self.children: if current_x + child.width > x + max_width and current_line: # Wrap to next line @@ -60,14 +60,14 @@ class InlineLayout: current_line = [] current_x = x line_y += line_height - + child.x = current_x child.y = line_y current_line.append(child) current_x += child.width - + if current_line: lines.append((current_line, line_y, line_height)) - + self.height = line_y + line_height - y if lines else 0 return lines diff --git a/src/network/http.py b/src/network/http.py index 8080f8e..fcaa2f3 100644 --- a/src/network/http.py +++ b/src/network/http.py @@ -7,60 +7,65 @@ import logging from .url import URL -def request(url: URL, payload: Optional[bytes] = None, method: str = "GET", max_redirects: int = 10) -> Tuple[int, str, bytes]: +def request( + url: URL, + payload: Optional[bytes] = None, + method: str = "GET", + max_redirects: int = 10, +) -> Tuple[int, str, bytes]: """ Fetch a URL and follow redirects, returning (status_code, content_type, body). - + Args: url: URL to fetch payload: Optional request body method: HTTP method (GET, POST, etc.) max_redirects: Maximum number of redirects to follow (default 10) - + Returns: Tuple of (status_code, content_type, response_body) """ logger = logging.getLogger("bowser.network") current_url = url redirect_count = 0 - + while redirect_count < max_redirects: parsed = current_url._parsed conn_class = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection - + try: conn = conn_class(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) path = parsed.path or "/" if parsed.query: path = f"{path}?{parsed.query}" - + headers = { "User-Agent": "Bowser/0.0.1", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } - + logger.info(f"HTTP {method} {parsed.scheme}://{parsed.hostname}{path}") conn.request(method, path, body=payload, headers=headers) resp = conn.getresponse() - + status = resp.status content_type = resp.getheader("Content-Type", "text/html") body = resp.read() - + logger.info(f"HTTP response {status} {resp.reason} ({len(body)} bytes)") - + # Handle redirects (3xx status codes) if 300 <= status < 400 and status != 304: location = resp.getheader("Location") conn.close() - + if not location: logger.warning(f"Redirect response {status} without Location header") return status, content_type, body - + logger.info(f"Following redirect to {location}") redirect_count += 1 - + # Convert relative URLs to absolute if location.startswith("http://") or location.startswith("https://"): current_url = URL(location) @@ -70,21 +75,21 @@ def request(url: URL, payload: Optional[bytes] = None, method: str = "GET", max_ if parsed.port: base_url += f":{parsed.port}" current_url = URL(base_url + location) - + # For 303 (See Other), change method to GET if status == 303: method = "GET" payload = None - + continue - + conn.close() return status, content_type, body - + except Exception as e: logger.error(f"HTTP request failed: {e}") raise - + # Max redirects exceeded logger.error(f"Maximum redirects ({max_redirects}) exceeded") raise Exception(f"Too many redirects (max: {max_redirects})") diff --git a/src/render/fonts.py b/src/render/fonts.py index 4e03756..10be589 100644 --- a/src/render/fonts.py +++ b/src/render/fonts.py @@ -5,22 +5,22 @@ import skia class FontCache: """Cache for Skia fonts and typefaces.""" - + _instance = None - + def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._font_cache = {} cls._instance._default_typeface = None return cls._instance - + def get_typeface(self): """Get the default typeface, creating it if needed.""" if self._default_typeface is None: self._default_typeface = skia.Typeface.MakeDefault() return self._default_typeface - + def get_font(self, size: int, weight: str = "normal", style: str = "normal") -> skia.Font: """Get a cached Skia font for the given parameters.""" key = (size, weight, style) @@ -28,11 +28,11 @@ class FontCache: typeface = self.get_typeface() self._font_cache[key] = skia.Font(typeface, size) return self._font_cache[key] - + def measure_text(self, text: str, font: skia.Font) -> float: """Measure the width of text using the given font.""" return font.measureText(text) - + def get_line_height(self, font_size: int) -> float: """Get the line height for a given font size.""" return font_size * 1.4 diff --git a/src/render/paint.py b/src/render/paint.py index 045d889..e22afc4 100644 --- a/src/render/paint.py +++ b/src/render/paint.py @@ -6,10 +6,10 @@ from .fonts import get_font class PaintCommand: """Base class for paint commands.""" - + def __init__(self, rect): self.rect = rect # (x1, y1, x2, y2) bounding box - + def execute(self, canvas: skia.Canvas): """Execute this paint command on the canvas.""" raise NotImplementedError @@ -17,7 +17,7 @@ class PaintCommand: class DrawText(PaintCommand): """Command to draw text.""" - + def __init__(self, x: float, y: float, text: str, font_size: int, color=None): self.x = x self.y = y @@ -27,7 +27,7 @@ class DrawText(PaintCommand): self._font = get_font(font_size) width = self._font.measureText(text) super().__init__((x, y - font_size, x + width, y)) - + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): """Draw the text on the canvas.""" if paint is None: @@ -39,12 +39,12 @@ class DrawText(PaintCommand): class DrawRect(PaintCommand): """Command to draw a rectangle.""" - + def __init__(self, x1: float, y1: float, x2: float, y2: float, color, fill: bool = True): super().__init__((x1, y1, x2, y2)) self.color = color self.fill = fill - + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): """Draw the rectangle on the canvas.""" if paint is None: @@ -57,21 +57,21 @@ class DrawRect(PaintCommand): class DisplayList: """A list of paint commands to execute.""" - + def __init__(self): self.commands = [] - + def append(self, command: PaintCommand): """Add a paint command.""" self.commands.append(command) - + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): """Execute all commands on the canvas.""" for cmd in self.commands: cmd.execute(canvas, paint) - + def __len__(self): return len(self.commands) - + def __iter__(self): return iter(self.commands) diff --git a/src/render/pipeline.py b/src/render/pipeline.py index 3652d60..f7ae282 100644 --- a/src/render/pipeline.py +++ b/src/render/pipeline.py @@ -5,92 +5,92 @@ from typing import Optional from ..parser.html import Element from ..layout.document import DocumentLayout from .fonts import get_font -from .paint import DisplayList, DrawText, DrawRect +from .paint import DisplayList class RenderPipeline: """Coordinates layout calculation and rendering to a Skia canvas.""" - + def __init__(self): # Layout cache self._layout: Optional[DocumentLayout] = None self._layout_width = 0 self._layout_doc_id = None - + # Paint cache self._text_paint: Optional[skia.Paint] = None self._display_list: Optional[DisplayList] = None - + # Debug mode self.debug_mode = False - + def layout(self, document: Element, width: int) -> DocumentLayout: """ Calculate layout for the document. Returns the DocumentLayout with all positioned elements. """ doc_id = id(document) - + # Check cache - if (self._layout_doc_id == doc_id and - self._layout_width == width and + if (self._layout_doc_id == doc_id and + self._layout_width == width and self._layout is not None): return self._layout - + # Build new layout self._layout = DocumentLayout(document) self._layout.layout(width) self._layout_doc_id = doc_id self._layout_width = width - + return self._layout - - def render(self, canvas: skia.Canvas, document: Element, + + def render(self, canvas: skia.Canvas, document: Element, width: int, height: int, scroll_y: float = 0): """ Render the document to the canvas. - + Args: canvas: Skia canvas to draw on document: DOM document root width: Viewport width - height: Viewport height + height: Viewport height scroll_y: Vertical scroll offset """ # Get or update layout layout = self.layout(document, width) - + if not layout.lines: return - + # Apply scroll transform canvas.save() canvas.translate(0, -scroll_y) - + # Get paint if self._text_paint is None: self._text_paint = skia.Paint() self._text_paint.setAntiAlias(True) self._text_paint.setColor(skia.ColorBLACK) - + # Render visible lines only visible_top = scroll_y - 50 visible_bottom = scroll_y + height + 50 - + for line in layout.lines: baseline_y = line.y + line.font_size if baseline_y < visible_top or line.y > visible_bottom: continue - + font = get_font(line.font_size) canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint) - + # Draw debug overlays if enabled if self.debug_mode: self._render_debug_overlays(canvas, layout) - + canvas.restore() - + def _render_debug_overlays(self, canvas: skia.Canvas, layout: DocumentLayout): """Render debug bounding boxes for layout blocks.""" # Color scheme for different block types @@ -100,35 +100,35 @@ class RenderPipeline: "list-item": (0, 255, 0, 60), # Green "text": (255, 255, 0, 60), # Yellow } - + border_colors = { "block": (255, 0, 0, 180), "inline": (0, 0, 255, 180), "list-item": (0, 255, 0, 180), "text": (255, 255, 0, 180), } - + for block in layout.blocks: block_type = block.block_type - + # Calculate block bounds from lines if not block.lines: continue - + x = block.x - 5 y = block.y - block.lines[0].font_size if block.lines else block.y w = block.width + 10 h = block.height + 5 - + # Fill fill_paint = skia.Paint() c = colors.get(block_type, colors["block"]) fill_paint.setColor(skia.Color(*c)) fill_paint.setStyle(skia.Paint.kFill_Style) - + rect = skia.Rect.MakeLTRB(x, y, x + w, y + h) canvas.drawRect(rect, fill_paint) - + # Border border_paint = skia.Paint() bc = border_colors.get(block_type, border_colors["block"]) @@ -136,7 +136,7 @@ class RenderPipeline: border_paint.setStyle(skia.Paint.kStroke_Style) border_paint.setStrokeWidth(1) canvas.drawRect(rect, border_paint) - + def get_text_layout(self) -> list: """ Get the text layout for text selection. @@ -144,7 +144,7 @@ class RenderPipeline: """ if self._layout is None: return [] - + result = [] for line in self._layout.lines: result.append({ @@ -157,13 +157,13 @@ class RenderPipeline: "char_positions": line.char_positions }) return result - + def get_document_height(self) -> float: """Get the total document height for scrolling.""" if self._layout is None: return 0 return self._layout.height - + def invalidate(self): """Invalidate the layout cache, forcing recalculation.""" self._layout = None diff --git a/src/templates.py b/src/templates.py index b5de9d9..e23a349 100644 --- a/src/templates.py +++ b/src/templates.py @@ -1,6 +1,5 @@ """Template rendering utilities.""" -import os from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape import logging @@ -12,11 +11,11 @@ def get_template_env(): # The templates.py file is at src/templates.py, so parent.parent gets to project root current_file = Path(__file__) pages_dir = current_file.parent.parent / "assets" / "pages" - + # If pages_dir doesn't exist, try alternative path (for tests) if not pages_dir.exists(): pages_dir = Path("/home/bw/Workspace/bowser/assets/pages") - + env = Environment( loader=FileSystemLoader(str(pages_dir)), autoescape=select_autoescape(['html', 'xml']) @@ -27,24 +26,24 @@ def get_template_env(): def render_template(template_name: str, **context) -> str: """ Render a template with the given context. - + Args: template_name: Name of the template file (e.g., 'startpage.html') **context: Variables to pass to the template - + Returns: Rendered HTML string """ logger = logging.getLogger("bowser.templates") - + try: env = get_template_env() template = env.get_template(template_name) - + # Set default version if not provided if 'version' not in context: context['version'] = '0.0.1' - + logger.debug(f"Rendering template: {template_name}") return template.render(**context) except Exception as e: @@ -55,32 +54,26 @@ def render_template(template_name: str, **context) -> str: def render_error_page(status_code: int, url: str = "", error_message: str = "") -> str: """ Render an error page for the given status code. - + Args: status_code: HTTP status code (404, 500, etc.) url: URL that caused the error error_message: Optional error details - + Returns: Rendered error HTML """ logger = logging.getLogger("bowser.templates") - - # Map common status codes to templates - template_map = { - 404: "error_404.html", - 500: "error_500.html", - # Network errors - "network": "error_network.html", - } - + + # Determine template per status + if status_code == 404: template = "error_404.html" elif status_code >= 500: template = "error_500.html" else: template = "error_network.html" - + try: env = get_template_env() tmpl = env.get_template(template) @@ -104,40 +97,40 @@ def render_startpage() -> str: def render_dom_graph_page(graph_path: str) -> str: """ Render the DOM graph visualization page. - + Args: graph_path: Path to the SVG or DOT file - + Returns: Rendered HTML with embedded graph """ from pathlib import Path - + logger = logging.getLogger("bowser.templates") graph_path_obj = Path(graph_path) - + if not graph_path_obj.exists(): logger.error(f"Graph file not found: {graph_path}") - return render_template("dom_graph.html", + return render_template("dom_graph.html", error="Graph file not found", graph_content="", is_svg=False) - + try: # Check file type is_svg = graph_path_obj.suffix == '.svg' - + # Read the file with open(graph_path, 'r', encoding='utf-8') as f: graph_content = f.read() - + logger.info(f"Rendering DOM graph from {graph_path}") return render_template("dom_graph.html", graph_content=graph_content, is_svg=is_svg, graph_path=str(graph_path), error=None) - + except Exception as e: logger.error(f"Failed to read graph file {graph_path}: {e}") return render_template("dom_graph.html", diff --git a/tests/test_browser.py b/tests/test_browser.py index 863f957..ea113e4 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -1,6 +1,5 @@ """Tests for browser tab management.""" -import pytest from unittest.mock import Mock, patch from src.browser.browser import Browser from src.browser.tab import Tab @@ -15,101 +14,101 @@ class TestTab: assert tab.current_url is None assert tab.history == [] assert tab.history_index == -1 - + def test_tab_title_new(self): browser = Mock() tab = Tab(browser) assert tab.title == "New Tab" - + def test_tab_title_with_url(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) assert "example.com" in tab.title - + def test_tab_load_adds_history(self): browser = Mock() browser._log = Mock() tab = Tab(browser) url1 = URL("https://example.com") url2 = URL("https://other.com") - + tab.load(url1) assert len(tab.history) == 1 assert tab.history_index == 0 - + tab.load(url2) assert len(tab.history) == 2 assert tab.history_index == 1 - + def test_tab_go_back(self): browser = Mock() browser._log = Mock() tab = Tab(browser) url1 = URL("https://example.com") url2 = URL("https://other.com") - + tab.load(url1) tab.load(url2) - + result = tab.go_back() assert result is True assert tab.history_index == 0 - + def test_tab_go_back_at_start(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) - + result = tab.go_back() assert result is False assert tab.history_index == 0 - + def test_tab_go_forward(self): browser = Mock() browser._log = Mock() tab = Tab(browser) - + tab.load(URL("https://example.com")) tab.load(URL("https://other.com")) tab.go_back() - + result = tab.go_forward() assert result is True assert tab.history_index == 1 - + def test_tab_go_forward_at_end(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) - + result = tab.go_forward() assert result is False - + def test_tab_reload(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) - + result = tab.reload() assert result is True assert tab.history_index == 0 - + def test_tab_history_truncation(self): browser = Mock() browser._log = Mock() tab = Tab(browser) - + tab.load(URL("https://example.com")) tab.load(URL("https://other.com")) tab.load(URL("https://third.com")) tab.go_back() # now at other.com tab.load(URL("https://new.com")) # should truncate third.com - + assert len(tab.history) == 3 assert tab.history_index == 2 @@ -120,91 +119,91 @@ class TestBrowser: browser = Browser() assert browser.tabs == [] assert browser.active_tab is None - + def test_new_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() - + tab = browser.new_tab("https://example.com") - + assert len(browser.tabs) == 1 assert browser.active_tab is tab assert tab in browser.tabs - + def test_set_active_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - + tab1 = browser.new_tab("https://example.com") - tab2 = browser.new_tab("https://other.com") - + _ = browser.new_tab("https://other.com") + browser.set_active_tab(tab1) assert browser.active_tab is tab1 - + def test_close_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - + tab1 = browser.new_tab("https://example.com") tab2 = browser.new_tab("https://other.com") - + browser.close_tab(tab1) - + assert len(browser.tabs) == 1 assert tab1 not in browser.tabs assert browser.active_tab is tab2 - + def test_close_active_tab_selects_previous(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - - tab1 = browser.new_tab("https://example.com") + + _ = browser.new_tab("https://example.com") tab2 = browser.new_tab("https://other.com") tab3 = browser.new_tab("https://third.com") - + browser.close_tab(tab3) assert browser.active_tab is tab2 - + def test_close_last_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - + tab = browser.new_tab("https://example.com") browser.close_tab(tab) - + assert len(browser.tabs) == 0 assert browser.active_tab is None - + def test_navigate_to(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() - + tab = browser.new_tab("https://example.com") browser.navigate_to("https://other.com") - + assert len(tab.history) == 2 - + def test_navigate_to_no_active_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() - + browser.navigate_to("https://example.com") - + assert len(browser.tabs) == 1 assert browser.active_tab is not None diff --git a/tests/test_cookies.py b/tests/test_cookies.py index eb4bda0..b6af30b 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -1,6 +1,5 @@ """Tests for cookie management.""" -import pytest from src.network.cookies import CookieJar @@ -8,34 +7,34 @@ class TestCookieJar: def test_cookie_jar_creation(self): jar = CookieJar() assert jar._cookies == {} - + def test_set_cookies(self): jar = CookieJar() jar.set_cookies("https://example.com", "session=abc123") - + cookies = jar.get_cookie_header("https://example.com") assert "session=abc123" in cookies - + def test_get_cookie_header_empty(self): jar = CookieJar() cookies = jar.get_cookie_header("https://example.com") assert cookies == "" - + def test_multiple_cookies_same_origin(self): jar = CookieJar() jar.set_cookies("https://example.com", "session=abc123") jar.set_cookies("https://example.com", "user=john") - + cookies = jar.get_cookie_header("https://example.com") assert "session=abc123" in cookies or "user=john" in cookies - + def test_cookies_isolated_by_origin(self): jar = CookieJar() jar.set_cookies("https://example.com", "session=abc123") jar.set_cookies("https://other.com", "session=xyz789") - + cookies1 = jar.get_cookie_header("https://example.com") cookies2 = jar.get_cookie_header("https://other.com") - + assert "abc123" in cookies1 assert "xyz789" in cookies2 diff --git a/tests/test_dom_graph.py b/tests/test_dom_graph.py index 879f76b..6b53cc0 100644 --- a/tests/test_dom_graph.py +++ b/tests/test_dom_graph.py @@ -1,7 +1,6 @@ """Tests for DOM graph visualization.""" -import pytest -from src.parser.html import parse_html, Element, Text +from src.parser.html import parse_html from src.debug.dom_graph import generate_dot_graph, print_dom_tree @@ -9,36 +8,36 @@ class TestDOMGraph: def test_generate_dot_graph_empty(self): """Test generating graph for None document.""" dot = generate_dot_graph(None) - + assert "digraph DOM" in dot assert "Empty Document" in dot - + def test_generate_dot_graph_simple(self): """Test generating graph for simple HTML.""" html = "
Hello World
" doc = parse_html(html) - + dot = generate_dot_graph(doc) - + assert "digraph DOM" in dot assert "node_" in dot # Should have node IDs assert "" in dot assert "" in dot assert "" in dot assert "Hello World" in dot - + def test_generate_dot_graph_with_attributes(self): """Test graph generation with element attributes.""" html = '
Text
Hello
" doc = parse_html(html) - + tree = print_dom_tree(doc) - + assert "" in tree assert "" in tree assert "" in tree assert "Hello" in tree - + def test_print_dom_tree_indentation(self): """Test that tree has proper indentation.""" html = "
Nested
line - should be more indented than
' in l][0] - div_line = [l for l in lines if '
' in line][0] + div_line = [line for line in lines if '
Deep
Text with "quotes" and newlines\n
' doc = parse_html(html) - + dot = generate_dot_graph(doc) - + # Should have escaped quotes assert '\\"' in dot or 'quotes' in dot # Should not have raw newlines breaking the DOT format @@ -129,14 +128,14 @@ class TestDOMGraph: for line in lines: if line.strip(): assert not line.strip().startswith('"') or line.strip().endswith(';') or line.strip().endswith(']') - + def test_generate_dot_graph_long_text_truncation(self): """Test that very long text nodes are truncated.""" long_text = "A" * 100 html = f"{long_text}
" doc = parse_html(html) - + dot = generate_dot_graph(doc) - + # Should contain truncation marker assert "..." in dot diff --git a/tests/test_dom_graph_page.py b/tests/test_dom_graph_page.py index 6bfa931..09c78e3 100644 --- a/tests/test_dom_graph_page.py +++ b/tests/test_dom_graph_page.py @@ -1,8 +1,6 @@ """Tests for DOM graph page rendering.""" -import pytest from src.templates import render_dom_graph_page -from pathlib import Path import tempfile import os @@ -14,10 +12,10 @@ class TestDOMGraphPage: with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f: f.write('') temp_path = f.name - + try: html = render_dom_graph_page(temp_path) - + assert html assert "DOM" in html assert "Visualization" in html or "Graph" in html @@ -26,17 +24,17 @@ class TestDOMGraphPage: assert temp_path in html # Should show file path finally: os.unlink(temp_path) - + def test_render_dom_graph_page_dot(self): """Test rendering page with DOT graph.""" # Create temporary DOT file with tempfile.NamedTemporaryFile(mode='w', suffix='.dot', delete=False) as f: f.write('digraph G { A -> B; }') temp_path = f.name - + try: html = render_dom_graph_page(temp_path) - + assert html assert "digraph G" in html # HTML escapes -> as > @@ -44,23 +42,23 @@ class TestDOMGraphPage: assert "Graphviz" in html # Should suggest installing graphviz finally: os.unlink(temp_path) - + def test_render_dom_graph_page_missing_file(self): """Test error handling for missing file.""" html = render_dom_graph_page("/nonexistent/path/to/graph.svg") - + assert html assert "error" in html.lower() or "not found" in html.lower() - + def test_render_dom_graph_page_has_legend(self): """Test that SVG page includes color legend.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f: f.write('') temp_path = f.name - + try: html = render_dom_graph_page(temp_path) - + # Should have legend explaining colors assert 'legend' in html.lower() or 'color' in html.lower() finally: diff --git a/tests/test_frame.py b/tests/test_frame.py index 725b620..634289e 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -1,8 +1,7 @@ """Tests for Frame and content loading.""" -import pytest from unittest.mock import Mock, patch -from src.browser.tab import Frame, Tab +from src.browser.tab import Tab from src.network.url import URL @@ -10,66 +9,66 @@ class TestFrame: @patch('src.browser.tab.http.request') def test_frame_load_success(self, mock_request): mock_request.return_value = (200, "text/html", b"Test") - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://example.com") frame.load(url) - + assert frame.document is not None assert frame.document.tag == "html" assert tab.current_url == url - + @patch('src.browser.tab.http.request') def test_frame_load_404(self, mock_request): mock_request.return_value = (404, "text/html", b"Not Found") - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://example.com/missing") frame.load(url) - + # Should create error document assert frame.document is not None # Error message in document text = frame.document.children[0].children[0].text if frame.document.children else "" assert "404" in text or "Error" in text - + @patch('src.browser.tab.http.request') def test_frame_load_network_error(self, mock_request): mock_request.side_effect = Exception("Network unreachable") - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://unreachable.example.com") frame.load(url) - + # Should create error document assert frame.document is not None text = frame.document.children[0].children[0].text if frame.document.children else "" assert "Error" in text or "unreachable" in text - + @patch('src.browser.tab.http.request') def test_frame_load_utf8_decode(self, mock_request): mock_request.return_value = (200, "text/html", "Héllo Wörld".encode('utf-8')) - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://example.com") frame.load(url) - + assert frame.document is not None # Should handle UTF-8 characters text = frame.document.children[0].children[0].text diff --git a/tests/test_html_parsing.py b/tests/test_html_parsing.py index d129862..f911b8a 100644 --- a/tests/test_html_parsing.py +++ b/tests/test_html_parsing.py @@ -1,6 +1,5 @@ """Tests for HTML parsing functionality.""" -import pytest from src.parser.html import parse_html, Text, Element @@ -18,70 +17,70 @@ class TestParseHTML: def test_parse_simple_text(self): html = "Hello World" root = parse_html(html) - + assert isinstance(root, Element) assert root.tag == "html" assert len(root.children) == 1 - + body = root.children[0] assert body.tag == "body" texts = collect_text(body) joined = " ".join(texts) assert "Hello World" in joined - + def test_parse_strips_tags(self): html = "Hello