Add DOM Visualization Feature with Integrated Browser Support

- Introduced a new feature to visualize the Document Object Model (DOM) tree of the loaded web page.
- Implemented keyboard shortcut (Ctrl+Shift+D) for generating and displaying the DOM graph.
- Added core implementation files:
  - src/debug/dom_graph.py: Handles generation and rendering of graphs in DOT and SVG formats.
- Created templates and documentation for the new feature, including:
  - docs/DOM_VISUALIZATION.md: Overview and usage instructions.
  - docs/DOM_GRAPH_UX.md: User experience design documentation.
- Allowed the visualization to open in a new browser tab instead of an external viewer.
- Enhanced visual representation through color coding of different HTML elements.
- Implemented comprehensive tests for graph generation and page rendering.
- Updated README.md to include usage instructions for DOM visualization.
- Included test_holder.html as an example test page.
- Modified various components in the browser to integrate tab management and enhance the graphical rendering experience.
This commit is contained in:
Benedikt Willi 2026-01-10 00:19:21 +01:00
parent d3119f0b10
commit 3838aa17af
19 changed files with 1841 additions and 258 deletions

9
.gitignore vendored
View file

@ -1 +1,10 @@
__pycache__/
**/__pycache__/*
src/browser/__pycache__/*
# DOM graph outputs
*.dot
*.svg
# Test outputs
example_dom_graph.*

146
FEATURE_DOM_GRAPH.md Normal file
View file

@ -0,0 +1,146 @@
# DOM Visualization Feature - Implementation Summary
## Overview
Added a keyboard shortcut (Ctrl+Shift+D) to generate and visualize the DOM tree of the current tab as a graph.
## Files Created
### Core Implementation
- **src/debug/__init__.py** - Debug utilities package
- **src/debug/dom_graph.py** - DOM graph generation and visualization
- `generate_dot_graph()` - Generates Graphviz DOT format
- `save_dom_graph()` - Saves DOT file
- `render_dom_graph_to_svg()` - Renders to SVG (requires graphviz)
- `print_dom_tree()` - Text tree representation
### Tests
- **tests/test_dom_graph.py** - Comprehensive test suite (10 tests, all passing)
- Tests graph generation, coloring, escaping, truncation
- Tests tree printing with proper indentation
- Tests depth limiting and attribute handling
### Documentation
- **docs/DOM_VISUALIZATION.md** - Feature documentation
- **test_dom.html** - Example test page
- Updated **README.md** with keyboard shortcuts section
## Implementation Details
### Browser Integration (src/browser/chrome.py)
1. **Keyboard Shortcut Setup**
- Added `_setup_keyboard_shortcuts()` method
- Registers GTK EventControllerKey for key presses
- Listens for Ctrl+Shift+D combination
2. **DOM Graph Handler**
- Added `_on_key_pressed()` callback
- Added `_show_dom_graph()` method that:
- Gets current tab's DOM document
- Generates graph in DOT format
- Attempts SVG rendering (if graphviz installed)
- Falls back to DOT file
- Prints tree to console
- Shows info dialog with result
3. **UI Feedback**
- Added `_show_info_dialog()` for user notifications
- Opens generated SVG automatically with xdg-open
### Graph Features
**Color Coding:**
- Light green: `<html>`, `<body>`
- Light yellow: Headings (`<h1>`-`<h6>`)
- Light gray: Block elements (`<div>`, `<p>`, `<span>`)
- Light cyan: Lists (`<ul>`, `<ol>`, `<li>`)
- Light pink: Interactive (`<a>`, `<button>`)
- Light blue: Text nodes
- White: Other elements
**Node Information:**
- Element nodes show tag name and up to 3 attributes
- Text nodes show content preview (max 50 chars)
- Hierarchical edges show parent-child relationships
**Output:**
- Files saved to `~/.cache/bowser/`
- `dom_graph.svg` - Visual graph (if graphviz available)
- `dom_graph.dot` - DOT format definition
- Console output shows full tree structure
## Usage
1. Open any page in Bowser
2. Press **Ctrl+Shift+D**
3. View results:
- Console: Text tree structure
- File browser: Opens SVG (if graphviz installed)
- Dialog: Shows file location
## Testing
All tests passing:
```bash
uv run pytest tests/test_dom_graph.py -v
# 10 passed in 0.11s
```
Test coverage:
- Empty document handling
- Simple HTML structures
- Nested elements
- Attributes rendering
- Text escaping
- Long text truncation
- Color coding
- Tree indentation
- Depth limiting
## Dependencies
**Required:**
- Python standard library
- Existing Bowser dependencies (GTK, Skia)
**Optional:**
- Graphviz (`dot` command) for SVG rendering
- Install: `sudo apt install graphviz`
- Gracefully falls back to DOT file if not available
## Example Output
For this HTML:
```html
<html>
<body>
<h1>Title</h1>
<p>Content</p>
</body>
</html>
```
Generates a graph showing:
- Root `html` node (green)
- `body` child (green)
- `h1` child (yellow)
- "Title" text (blue)
- `p` child (gray)
- "Content" text (blue)
## Benefits
1. **Debugging Aid**: Visually inspect parsed DOM structure
2. **Learning Tool**: Understand how HTML is parsed
3. **Structure Validation**: Verify element nesting and hierarchy
4. **Development**: Quickly check if DOM building works correctly
## Future Enhancements
Potential improvements:
- Add CSS selector highlighting
- Show computed styles on nodes
- Interactive graph (clickable nodes)
- Export to different formats (PNG, PDF)
- Side-by-side HTML source comparison
- DOM mutation tracking

80
QUICKSTART_DOM_GRAPH.md Normal file
View file

@ -0,0 +1,80 @@
# DOM Graph Visualization - Quick Reference
## What is it?
A debugging tool that visualizes the Document Object Model (DOM) tree of the currently loaded web page as a graph.
## How to use it?
Press **Ctrl+Shift+D** while viewing any page in Bowser.
## What you get
### 1. Console Output
```
DOM TREE STRUCTURE:
====================
<html>
<body>
<h1>
Text: 'Page Title'
<p>
Text: 'Page content...'
```
### 2. New Browser Tab
- Automatically opens with the visualization
- Clean, dark-themed interface
- Color legend explaining node colors
- Interactive SVG graph (if Graphviz installed)
- DOT format view with installation instructions (without Graphviz)
### 3. Graph File
Saved to `~/.cache/bowser/dom_graph.svg` (or `.dot` without Graphviz)
## Node Colors
| Color | Elements |
|-------|----------|
| 🟢 Light Green | `<html>`, `<body>` |
| 🟡 Light Yellow | `<h1>`, `<h2>`, `<h3>`, `<h4>`, `<h5>`, `<h6>` |
| ⚪ Light Gray | `<div>`, `<p>`, `<span>` |
| 🔵 Light Cyan | `<ul>`, `<ol>`, `<li>` |
| 🔴 Light Pink | `<a>`, `<button>` |
| 🔵 Light Blue | Text nodes |
| ⚪ White | Other elements |
## Installation (Optional)
For visual graphs, install Graphviz:
```bash
sudo apt install graphviz
```
Without it, you'll get a `.dot` file that you can:
- View with any text editor
- Render online at http://webgraphviz.com
- Render manually: `dot -Tsvg dom_graph.dot -o output.svg`
## Test it!
1. Load the test page:
```bash
uv run bowser file://$(pwd)/test_dom.html
```
2. Press **Ctrl+Shift+D**
3. View the generated graph!
## Files
- **Implementation**: `src/debug/dom_graph.py`
- **Tests**: `tests/test_dom_graph.py` (10/10 passing)
- **Docs**: `docs/DOM_VISUALIZATION.md`
- **Example**: `test_dom.html`
## Benefits
- 🔍 **Debug**: Quickly inspect DOM structure
- 📚 **Learn**: Understand HTML parsing
- ✅ **Validate**: Check element hierarchy
- 🎨 **Visualize**: See the tree structure clearly

View file

@ -11,6 +11,7 @@ A custom web browser built from scratch following the [browser.engineering](http
- GTK 4 development libraries (Debian: `libgtk-4-dev libgtk-4-1`)
- Skia-Python (`skia-python`): `pip install skia-python`
- PyGObject (`PyGObject`): `pip install PyGObject`
- Graphviz (optional, for DOM visualization): `sudo apt install graphviz`
### Setup
```bash
@ -18,6 +19,15 @@ uv sync
uv run bowser
```
## Usage
### Keyboard Shortcuts
- **Ctrl+Shift+D**: Generate and visualize DOM tree graph of current page
- Opens visualization in a new browser tab
- Displays interactive SVG graph (if Graphviz installed)
- Falls back to DOT format if Graphviz not available
- Prints tree structure to console
### Testing
Run the test suite:
```bash

152
UPDATE_DOM_TAB.md Normal file
View file

@ -0,0 +1,152 @@
# DOM Graph in Browser Tab - Update Summary
## What Changed
The DOM visualization feature now displays the graph **in a new browser tab** instead of opening an external application.
## Key Improvements
### Before
- Pressed Ctrl+Shift+D
- Graph opened in external SVG viewer (xdg-open)
- Required switching between applications
- Less integrated experience
### After
- Press Ctrl+Shift+D
- Graph opens in new browser tab automatically
- Stay within Bowser
- Beautiful dark-themed interface
- Built-in color legend
- Consistent with browser workflow
## Implementation Changes
### 1. Modified chrome.py
**`_show_dom_graph()` method**:
- Removed external `xdg-open` call
- Now calls `browser.new_tab("about:dom-graph?path=...")`
- Passes graph file path as URL parameter
### 2. Modified tab.py
**`Frame.load()` method**:
- Added handler for `about:dom-graph` URLs
- Parses query parameter to get graph file path
- Calls `render_dom_graph_page()` to generate HTML
- Parses result into DOM
### 3. Modified templates.py
**New function: `render_dom_graph_page()`**:
- Takes graph file path as parameter
- Detects SVG vs DOT format
- Reads file content
- Renders using `dom_graph.html` template
- Handles errors gracefully
### 4. New Template
**`assets/pages/dom_graph.html`**:
- Dark-themed, modern interface
- Header with title and file path
- Color legend for SVG graphs
- Inline SVG embedding
- DOT format display with syntax highlighting
- Installation instructions for Graphviz
- Responsive layout
### 5. New Tests
**`tests/test_dom_graph_page.py`**:
- Tests SVG rendering
- Tests DOT rendering
- Tests error handling
- Tests legend presence
- All 4 tests passing ✓
## Features
### Visual Design
- 🎨 Dark theme (#1e1e1e background)
- 🎯 VS Code-inspired color scheme
- 📊 Inline SVG rendering
- 🎨 Color-coded legend
- 📝 Monospace font for code
- 🔲 Card-based layout
### User Experience
- ⚡ Automatic tab opening
- 🎯 No application switching
- 📍 Clear file path display
- 💡 Helpful installation hints
- 🔄 Reminder to use Ctrl+Shift+D
### Error Handling
- ✅ Missing file detection
- ✅ Read error handling
- ✅ Graceful degradation
- ✅ Clear error messages
## URL Scheme
New special URL: `about:dom-graph?path=/path/to/graph.svg`
Query parameters:
- `path`: Absolute path to graph file (SVG or DOT)
## File Structure
```
src/
browser/
chrome.py # Opens new tab with about:dom-graph
tab.py # Handles about:dom-graph URL
templates.py # Renders graph page
debug/
dom_graph.py # Generates graph files (unchanged)
assets/pages/
dom_graph.html # New template for graph display
tests/
test_dom_graph.py # Graph generation tests (10 tests)
test_dom_graph_page.py # Page rendering tests (4 tests)
```
## Testing Results
```
tests/test_html_parsing.py - 7/7 passed ✓
tests/test_dom_graph.py - 10/10 passed ✓
tests/test_dom_graph_page.py - 4/4 passed ✓
tests/test_templates.py - 7/7 passed ✓
--------------------------------
Total: 28/28 passed ✓
```
## Usage Example
1. Browse to any page
2. Press **Ctrl+Shift+D**
3. New tab opens with:
- Header: "🌳 DOM Tree Visualization"
- File path shown
- Color legend
- Interactive SVG graph OR
- DOT format with install instructions
4. Console shows text tree structure
## Benefits
**Better UX**: No context switching
**Consistent**: All in browser
**Informative**: Built-in legend and hints
**Beautiful**: Modern, dark theme
**Accessible**: Clear labels and structure
**Flexible**: Works with/without Graphviz
**Tested**: Comprehensive test coverage
## Backward Compatibility
- Graph files still saved to `~/.cache/bowser/`
- Console output unchanged
- Same keyboard shortcut (Ctrl+Shift+D)
- Same file formats (SVG/DOT)
- Existing functionality preserved

232
assets/pages/dom_graph.html Normal file
View file

@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOM Graph Visualization</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'SF Mono', Monaco, monospace;
background: #1e1e1e;
color: #d4d4d4;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #2d2d2d;
padding: 16px 24px;
border-bottom: 1px solid #3e3e3e;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.header h1 {
font-size: 20px;
font-weight: 600;
color: #4ec9b0;
margin-bottom: 8px;
}
.header .info {
font-size: 13px;
color: #858585;
font-family: 'SF Mono', Monaco, monospace;
}
.container {
flex: 1;
padding: 24px;
overflow: auto;
}
.graph-wrapper {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
display: inline-block;
min-width: 100%;
}
.graph-wrapper svg {
max-width: 100%;
height: auto;
}
.dot-content {
background: #2d2d2d;
border: 1px solid #3e3e3e;
border-radius: 8px;
padding: 20px;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
color: #d4d4d4;
white-space: pre;
}
.dot-content::before {
content: "DOT Graph Definition:";
display: block;
color: #4ec9b0;
font-weight: 600;
margin-bottom: 16px;
font-size: 14px;
}
.error {
background: #f14c4c;
color: white;
padding: 16px 24px;
border-radius: 8px;
margin: 24px;
font-size: 14px;
box-shadow: 0 4px 12px rgba(241, 76, 76, 0.3);
}
.error::before {
content: "⚠ ";
font-size: 18px;
}
.install-note {
background: #0e639c;
color: white;
padding: 16px 24px;
border-radius: 8px;
margin: 24px;
font-size: 14px;
line-height: 1.6;
box-shadow: 0 4px 12px rgba(14, 99, 156, 0.3);
}
.install-note strong {
display: block;
margin-bottom: 8px;
font-size: 15px;
}
.install-note code {
background: rgba(255,255,255,0.1);
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 13px;
}
.legend {
background: #2d2d2d;
border: 1px solid #3e3e3e;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.legend h3 {
color: #4ec9b0;
font-size: 16px;
margin-bottom: 16px;
}
.legend-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.legend-color {
width: 32px;
height: 24px;
border-radius: 4px;
border: 1px solid #555;
}
.footer {
background: #2d2d2d;
padding: 12px 24px;
border-top: 1px solid #3e3e3e;
text-align: center;
font-size: 12px;
color: #858585;
}
</style>
</head>
<body>
<div class="header">
<h1>🌳 DOM Tree Visualization</h1>
{% if graph_path %}
<div class="info">Source: {{ graph_path }}</div>
{% endif %}
</div>
<div class="container">
{% if error %}
<div class="error">
{{ error }}
</div>
{% elif is_svg %}
<div class="legend">
<h3>Color Legend</h3>
<div class="legend-grid">
<div class="legend-item">
<div class="legend-color" style="background: lightgreen;"></div>
<span>&lt;html&gt;, &lt;body&gt;</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: lightyellow;"></div>
<span>Headings (h1-h6)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: lightgray;"></div>
<span>Block elements</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: lightcyan;"></div>
<span>Lists (ul, ol, li)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: lightpink;"></div>
<span>Interactive (a, button)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: lightblue;"></div>
<span>Text nodes</span>
</div>
</div>
</div>
<div class="graph-wrapper">
{{ graph_content|safe }}
</div>
{% else %}
<div class="install-note">
<strong>📦 Graphviz not installed</strong>
Showing DOT format instead. For visual graphs, install Graphviz:
<br><br>
<code>sudo apt install graphviz</code>
</div>
<div class="dot-content">{{ graph_content }}</div>
{% endif %}
</div>
<div class="footer">
Bowser Browser • DOM Graph Visualization • Press Ctrl+Shift+D to regenerate
</div>
</body>
</html>

157
docs/DOM_GRAPH_UX.md Normal file
View file

@ -0,0 +1,157 @@
# DOM Graph Visualization - User Experience
## What You'll See
When you press **Ctrl+Shift+D**, a new tab automatically opens with this view:
```
┌─────────────────────────────────────────────────────────────────┐
│ 🌳 DOM Tree Visualization │
│ Source: /home/user/.cache/bowser/dom_graph.svg │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Color Legend: │
│ ┌─────────┬──────────────────────────────────────────────┐ │
│ │ 🟢 │ <html>, <body> │ │
│ │ 🟡 │ Headings (h1-h6) │ │
│ │ ⚪ │ Block elements │ │
│ │ 🔵 │ Lists (ul, ol, li) │ │
│ │ 🔴 │ Interactive (a, button) │ │
│ │ 🔵 │ Text nodes │ │
│ └─────────┴──────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [Visual DOM Graph Here] │ │
│ │ │ │
│ │ html ──┬── body ──┬── h1 ── "Title" │ │
│ │ │ │ │
│ │ ├── p ── "Content" │ │
│ │ │ │ │
│ │ └── ul ──┬── li ── "Item 1" │ │
│ │ └── li ── "Item 2" │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Bowser Browser • DOM Graph Visualization • Ctrl+Shift+D │
└─────────────────────────────────────────────────────────────────┘
```
## Interface Elements
### Header (Dark Gray)
- 🌳 Title with icon
- File path showing where graph is saved
- Clean, minimal design
### Color Legend
- Explains what each color means
- 6 different node types
- Easy reference while viewing graph
### Graph Display
**With Graphviz (SVG):**
- Beautiful vector graphics
- Zoomable/scalable
- Clean node layout
- Clear hierarchy
**Without Graphviz (DOT):**
- Installation instructions
- Raw DOT format shown
- Helpful hints for rendering
### Footer (Dark Gray)
- Branding
- Keyboard shortcut reminder
## Color Scheme
**Background:**
- Dark: `#1e1e1e` (VS Code inspired)
- Cards: `#2d2d2d`
- Borders: `#3e3e3e`
**Text:**
- Primary: `#d4d4d4`
- Accent: `#4ec9b0` (teal/green)
- Muted: `#858585`
**Graph Nodes:**
- Exactly as in the generated SVG
- Matches console output colors
## Workflow
```
┌──────────────────────────────────────────────────────────┐
│ 1. You're viewing a page │
│ (e.g., example.com) │
└──────────────────────────────────────────────────────────┘
Press Ctrl+Shift+D
┌──────────────────────────────────────────────────────────┐
│ 2. Console Output (Terminal) │
│ ═══════════════════════════════ │
│ DOM TREE STRUCTURE: │
<html>
<body>
<h1>
│ Text: 'Example' │
└──────────────────────────────────────────────────────────┘
(simultaneously)
┌──────────────────────────────────────────────────────────┐
│ 3. New Tab Opens │
│ about:dom-graph?path=/home/.../.cache/...svg │
│ │
│ Shows beautiful visualization with: │
│ • Color legend │
│ • Interactive graph │
│ • Dark theme │
└──────────────────────────────────────────────────────────┘
Keep working!
┌──────────────────────────────────────────────────────────┐
│ 4. Continue browsing │
│ • Graph tab stays open │
│ • Switch back anytime │
│ • Generate new graphs anytime │
└──────────────────────────────────────────────────────────┘
```
## Comparison
### Before (External Viewer)
```
Browser → Ctrl+Shift+D → xdg-open → SVG Viewer
↓ ↑
└──────── Switch apps ────────────────┘
```
### After (Browser Tab)
```
Browser → Ctrl+Shift+D → New Tab → View Graph
└────────── All in Bowser ────────────┘
```
## Advantages
1. **No App Switching** - Everything in browser
2. **Consistent UX** - Same interface as other pages
3. **Always Available** - No need for external apps
4. **Better Integration** - Part of your browsing session
5. **More Features** - Legend, hints, instructions
6. **Beautiful Design** - Custom dark theme
7. **Responsive** - Works at any size
## Next Steps
Try it yourself:
1. Open Bowser
2. Navigate to any page (or use the test page)
3. Press **Ctrl+Shift+D**
4. Enjoy the beautiful DOM visualization!

97
docs/DOM_VISUALIZATION.md Normal file
View file

@ -0,0 +1,97 @@
# DOM Visualization Feature
## Overview
The DOM visualization feature allows you to inspect the Document Object Model (DOM) tree of the currently loaded page as a graph. This is useful for debugging, understanding page structure, and learning how browsers parse HTML.
## Usage
### Keyboard Shortcut
Press **Ctrl+Shift+D** while viewing any page to generate a DOM graph.
### What Happens
1. **Console Output**: The DOM tree structure is printed to the console in text format
2. **Graph File**: A graph file is generated in `~/.cache/bowser/`
3. **New Browser Tab**: Opens automatically displaying the visualization
- Shows interactive SVG graph (if Graphviz installed)
- Shows DOT format with installation instructions (if Graphviz not installed)
### Graph Features
- **Color-Coded Nodes**: Different element types have different colors
- Light green: `<html>`, `<body>`
- Light yellow: Headings (`<h1>`, `<h2>`, etc.)
- Light gray: Block elements (`<div>`, `<p>`, `<span>`)
- Light cyan: Lists (`<ul>`, `<ol>`, `<li>`)
- Light pink: Interactive elements (`<a>`, `<button>`)
- White: Other elements
- **Node Labels**: Show element tags and up to 3 attributes
- **Text Nodes**: Display text content (truncated to 50 characters)
- **Hierarchical Layout**: Shows parent-child relationships with arrows
## Installation (Optional)
For visual graph rendering, install Graphviz:
```bash
# Debian/Ubuntu
sudo apt install graphviz
# macOS
brew install graphviz
# Fedora
sudo dnf install graphviz
```
Without Graphviz, the tool will save a `.dot` file that you can:
- Open with a text editor to see the graph definition
- Render online at http://www.webgraphviz.com/
- Render with `dot -Tsvg dom_graph.dot -o dom_graph.svg`
## Output Files
All generated files are saved to `~/.cache/bowser/`:
- `dom_graph.svg` - Visual graph (if Graphviz available)
- `dom_graph.dot` - Graph definition in DOT format
## Examples
### Simple Page
```html
<html>
<body>
<h1>Title</h1>
<p>Content</p>
</body>
</html>
```
Will generate a graph showing:
```
html (green) → body (green) → h1 (yellow) → "Title" (blue text node)
→ p (gray) → "Content" (blue text node)
```
### Testing
A test page is included at `test_dom.html`. Open it with:
```bash
uv run bowser file://$(pwd)/test_dom.html
```
Then press Ctrl+Shift+D to visualize its DOM structure.
## Implementation
The visualization is implemented in:
- `src/debug/dom_graph.py` - Graph generation logic
- `src/browser/chrome.py` - Keyboard shortcut handler
The feature uses:
- **Graphviz DOT format** for graph definition
- **Recursive tree traversal** to build node hierarchy
- **GTK keyboard event handling** for the shortcut

View file

@ -28,8 +28,6 @@ class Browser:
"""Called when the application is activated."""
self._log("Application activated", logging.DEBUG)
self.chrome.create_window()
# Build initial tab bar if tabs exist
self.chrome.rebuild_tab_bar()
def new_tab(self, url: str):
tab = Tab(self)
@ -38,9 +36,9 @@ class Browser:
tab.load(URL(url))
self.tabs.append(tab)
self.active_tab = tab
# Reflect in UI if available
if self.chrome.tabs_box:
self.chrome.rebuild_tab_bar()
# Add to UI
self.chrome.add_tab(tab)
self.chrome.update_tab(tab) # Update title after load
self.chrome.update_address_bar()
self._log(f"New tab opened: {url}", logging.INFO)
return tab
@ -49,9 +47,8 @@ class Browser:
if tab in self.tabs:
self.active_tab = tab
self._log(f"Active tab set: {tab.title}", logging.DEBUG)
# Update UI highlighting
if self.chrome.tabs_box:
self.chrome.rebuild_tab_bar()
# Update UI
self.chrome.set_active_tab(tab)
# Trigger repaint of content area
self.chrome.paint()
self.chrome.update_address_bar()
@ -65,11 +62,13 @@ class Browser:
if self.active_tab is tab:
if self.tabs:
self.active_tab = self.tabs[max(0, idx - 1)]
self.chrome.set_active_tab(self.active_tab)
else:
self.active_tab = None
# Update UI
if self.chrome.tabs_box:
self.chrome.rebuild_tab_bar()
# Open a new tab when all tabs are closed
self.new_tab("about:startpage")
return # Return early - don't try to remove a tab that's being closed
# Update UI (tab_pages already cleaned in _on_close_page)
self.chrome.paint()
self.chrome.update_address_bar()
self._log(f"Tab closed: {tab.title}", logging.INFO)
@ -85,6 +84,7 @@ class Browser:
return
self.active_tab.load(URL(url_str))
self.chrome.paint()
self.chrome.update_tab(self.active_tab) # Update title after load
self.chrome.update_address_bar()
self._log(f"Navigate to: {url_str}", logging.INFO)
@ -103,12 +103,14 @@ class Browser:
def go_back(self):
if self.active_tab and self.active_tab.go_back():
self.chrome.paint()
self.chrome.update_tab(self.active_tab)
self.chrome.update_address_bar()
self._log("Go back", logging.DEBUG)
def go_forward(self):
if self.active_tab and self.active_tab.go_forward():
self.chrome.paint()
self.chrome.update_tab(self.active_tab)
self.chrome.update_address_bar()
self._log("Go forward", logging.DEBUG)
@ -116,6 +118,7 @@ class Browser:
if self.active_tab:
self.active_tab.reload()
self.chrome.paint()
self.chrome.update_tab(self.active_tab)
self.chrome.update_address_bar()
self._log("Reload", logging.DEBUG)

View file

@ -23,8 +23,11 @@ class Chrome:
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.tab_view: Optional[Adw.TabView] = None
self.tab_bar: Optional[Adw.TabBar] = None
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
def create_window(self):
"""Initialize the Adwaita application window."""
@ -74,23 +77,37 @@ class Chrome:
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)
# Create TabView for managing tabs
self.tab_view = Adw.TabView()
tabs_frame = Gtk.Frame()
tabs_frame.set_child(self.tabs_box)
vbox.append(tabs_frame)
# Drawing area for content
# 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)
self.drawing_area.set_hexpand(True)
self.drawing_area.set_draw_func(self.on_draw)
vbox.append(self.drawing_area)
content_box.append(self.drawing_area)
# Add content box to vbox (not to TabView - we use a single drawing area for all tabs)
vbox.append(content_box)
# Status bar with Adwaita styling
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
@ -102,9 +119,6 @@ class Chrome:
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:
@ -117,121 +131,113 @@ 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())
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:
# 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
# Clear existing tabs
self._clear_children(self.tabs_box)
# Add each tab as an integrated unit
for i, tab in enumerate(self.browser.tabs):
is_active = tab is self.browser.active_tab
# Create integrated tab widget
tab_widget = self._create_integrated_tab(tab, i, is_active)
self.tabs_box.append(tab_widget)
# New tab button
new_tab_btn = Gtk.Button(label="")
new_tab_btn.set_size_request(48, -1)
new_tab_btn.add_css_class("flat")
new_tab_btn.set_tooltip_text("New Tab")
new_tab_btn.connect("clicked", self._on_new_tab_clicked)
self.tabs_box.append(new_tab_btn)
def _create_integrated_tab(self, tab, index: int, is_active: bool) -> Gtk.Widget:
"""Create an integrated tab widget with close button as one unit."""
# Outer container - this is the visual tab
tab_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
tab_container.add_css_class("integrated-tab")
if is_active:
tab_container.add_css_class("active-tab")
tab_container.set_homogeneous(False)
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:
return
page = self.tab_pages[tab]
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.
# Left side: tab label (expandable)
tab_label = f"{index+1}: {tab.title}"
tab_btn = Gtk.Button(label=tab_label)
tab_btn.set_hexpand(True)
tab_btn.add_css_class("tab-label")
tab_btn.add_css_class("flat")
tab_btn.tab = tab
tab_btn.connect("clicked", self._on_tab_clicked)
tab_container.append(tab_btn)
This is called when close_page() is invoked. We must call close_page_finish()
to actually complete the page removal.
"""
# Find the tab associated with this page
tab_to_close = None
for tab, tab_page in list(self.tab_pages.items()):
if tab_page == page:
tab_to_close = tab
break
# Right side: close button (fixed width, no expand)
close_btn = Gtk.Button(label="")
close_btn.set_size_request(34, -1)
close_btn.add_css_class("tab-close")
close_btn.add_css_class("flat")
close_btn.tab = tab
close_btn.connect("clicked", self._on_close_clicked)
tab_container.append(close_btn)
# Apply CSS styling to make it look like one unit
css = Gtk.CssProvider()
css.load_from_data(b"""
.integrated-tab {
background-color: @theme_bg_color;
border: 1px solid @borders;
border-radius: 4px 4px 0 0;
margin-right: 2px;
padding: 0px;
}
.integrated-tab.active-tab {
background-color: @theme_base_color;
}
.tab-label {
padding: 6px 8px;
font-weight: 500;
border: none;
border-radius: 0;
}
.tab-label:hover {
background-color: mix(@theme_bg_color, @theme_fg_color, 0.95);
}
.tab-close {
padding: 2px 4px;
font-size: 0.9em;
border: none;
border-left: 1px solid @borders;
border-radius: 0;
min-width: 32px;
}
.tab-close:hover {
background-color: @error_color;
color: white;
}
""")
context = tab_container.get_style_context()
context.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
return tab_container
def _on_tab_clicked(self, btn: Gtk.Button):
"""Handle tab button click - set as active."""
if hasattr(btn, 'tab'):
self.browser.set_active_tab(btn.tab)
def _on_close_clicked(self, btn: Gtk.Button):
"""Handle close button click - close the tab."""
if hasattr(btn, 'tab'):
self.browser.close_tab(btn.tab)
if tab_to_close:
# Remove from our tracking
del self.tab_pages[tab_to_close]
# Confirm the close - this actually removes the page from TabView
self.tab_view.close_page_finish(page, True)
# Call browser cleanup (but don't call remove_tab since we already handled it)
self.browser.close_tab(tab_to_close)
return True
else:
# 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()
if selected_page:
# Find the tab associated with this page
for tab, page in self.tab_pages.items():
if page == selected_page:
self.browser.set_active_tab(tab)
break
def _on_new_tab_clicked(self, btn: Gtk.Button):
"""Handle new tab button click."""
@ -263,14 +269,12 @@ class Chrome:
# 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)
# Render DOM content
frame = self.browser.active_tab.main_frame if self.browser.active_tab else None
document = frame.document if frame else None
if document:
self._render_dom_content(canvas, document, width, height)
else:
# Show placeholder
paint = skia.Paint()
paint.setAntiAlias(True)
paint.setColor(skia.ColorBLACK)
@ -294,72 +298,204 @@ class Chrome:
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."""
def _render_dom_content(self, canvas, document, width: int, height: int):
"""Render a basic DOM tree with headings, paragraphs, and lists."""
from ..parser.html import Element, Text
body = self._find_body(document)
if not body:
return
blocks = self._collect_blocks(body)
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
x_margin = 20
max_width = max(10, width - 2 * x_margin)
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
for block in blocks:
font_size = block.get("font_size", 14)
font = skia.Font(skia.Typeface.MakeDefault(), font_size)
text = block.get("text", "")
if not text:
y += font_size * 0.6
continue
# Optional bullet prefix
if block.get("bullet"):
text = f"{text}"
# Word wrapping per block
words = text.split()
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:
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))
line_height = font_size * 1.4
top_margin = block.get("margin_top", 6)
y += top_margin
for line in lines:
if y > height - 20:
return
canvas.drawString(line, x_margin, y, font, paint)
y += line_height
y += block.get("margin_bottom", 10)
def _find_body(self, document):
from ..parser.html import Element
if isinstance(document, Element) and document.tag == "body":
return document
if hasattr(document, "children"):
for child in document.children:
if isinstance(child, Element) and child.tag == "body":
return child
found = self._find_body(child)
if found:
return found
return None
def _collect_blocks(self, node):
"""Flatten DOM into renderable blocks with basic styling."""
from ..parser.html import Element, Text
blocks = []
def text_of(n):
if isinstance(n, Text):
return n.text
if isinstance(n, Element):
parts = []
for c in n.children:
parts.append(text_of(c))
return " ".join([p for p in parts if p]).strip()
return ""
for child in getattr(node, "children", []):
if isinstance(child, Text):
txt = child.text.strip()
if txt:
blocks.append({"text": txt, "font_size": 14})
continue
if isinstance(child, Element):
tag = child.tag.lower()
content = text_of(child)
if not content:
continue
if tag == "h1":
blocks.append({"text": content, "font_size": 24, "margin_top": 12, "margin_bottom": 12})
elif tag == "h2":
blocks.append({"text": content, "font_size": 20, "margin_top": 10, "margin_bottom": 10})
elif tag == "h3":
blocks.append({"text": content, "font_size": 18, "margin_top": 8, "margin_bottom": 8})
elif tag == "p":
blocks.append({"text": content, "font_size": 14, "margin_top": 6, "margin_bottom": 12})
elif tag == "li":
blocks.append({"text": content, "font_size": 14, "bullet": True, "margin_top": 4, "margin_bottom": 4})
elif tag in {"ul", "ol"}:
blocks.extend(self._collect_blocks(child))
else:
# Generic element: render text
blocks.append({"text": content, "font_size": 14})
return blocks
def paint(self):
"""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)
if ctrl_pressed and shift_pressed and key_name in ('D', 'd'):
self._show_dom_graph()
return True # Event handled
return False # Event not handled
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
import os
from pathlib import Path
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)
print("DOM TREE STRUCTURE:")
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
self.logger.info(f"Opening DOM graph in new tab: {svg_path}")
self.browser.new_tab(f"about:dom-graph?path={svg_path}")
else:
# Fallback to DOT file
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(
transient_for=self.window,
modal=True,
message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK,
text=title
)
dialog.set_property("secondary-text", message)
dialog.connect("response", lambda d, r: d.destroy())
dialog.present()

View file

@ -28,6 +28,19 @@ class Frame:
self.tab.current_url = url
return
if url_str.startswith("about:dom-graph"):
# Extract path parameter
from urllib.parse import urlparse, parse_qs
from ..templates import render_dom_graph_page
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)

1
src/debug/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Debug and inspection utilities."""

189
src/debug/dom_graph.py Normal file
View file

@ -0,0 +1,189 @@
"""DOM tree visualization as a graph."""
import logging
from typing import Optional
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 '')
label = escape_label(text_preview)
lines.append(f' {node_id} [label="{label}", fillcolor=lightblue, shape=box];')
elif isinstance(node, Element):
# Element nodes
attrs_str = ""
if node.attributes:
attrs_list = [f'{k}="{v}"' for k, v in list(node.attributes.items())[:3]]
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'
elif node.tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
color = 'lightyellow'
elif node.tag in ('div', 'span', 'p'):
color = 'lightgray'
elif node.tag in ('ul', 'ol', 'li'):
color = 'lightcyan'
elif node.tag in ('a', 'button'):
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
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],
input=dot_content.encode('utf-8'),
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()}")
# Fallback: save as .dot file
dot_path = output_path.replace('.svg', '.dot')
return save_dom_graph(document, dot_path)
except FileNotFoundError:
logger.warning("Graphviz 'dot' command not found. Saving as .dot file instead.")
dot_path = output_path.replace('.svg', '.dot')
return save_dom_graph(document, dot_path)
except Exception as e:
logger.error(f"Failed to render DOM graph: {e}")
return False
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:
lines.append(f"{spacer}Text: {repr(text_preview)}\n")
elif isinstance(node, Element):
attrs_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)

View file

@ -1,5 +1,7 @@
"""HTML parser stubs."""
"""Very small HTML parser that builds a simple DOM tree."""
from html import unescape
from html.parser import HTMLParser
import re
@ -31,34 +33,93 @@ def print_tree(node, indent=0):
print_tree(child, indent + 1)
class _DOMBuilder(HTMLParser):
"""Tiny HTML parser that produces Element/Text nodes."""
def __init__(self):
super().__init__(convert_charrefs=False)
self.root = Element("html")
self.body = Element("body", parent=self.root)
self.root.children.append(self.body)
self.current = self.body
self._skip_depth = 0 # for script/style skipping
# Helpers
def _push(self, el: Element):
el.parent = self.current
self.current.children.append(el)
self.current = el
def _pop(self, tag: str):
node = self.current
while node and node is not self.root:
if getattr(node, "tag", None) == tag:
self.current = node.parent or self.root
return
node = node.parent
self.current = self.root
def _append_text(self, text: str):
"""Append text to current node, merging with previous text when possible."""
if not text:
return
last = self.current.children[-1] if self.current.children else None
if isinstance(last, Text):
# Avoid accumulating duplicate whitespace when merging segments
if last.text.endswith(" ") and text.startswith(" "):
text = text.lstrip()
last.text += text
else:
self.current.children.append(Text(text, parent=self.current))
# HTMLParser callbacks
def handle_starttag(self, tag, attrs):
if tag in {"script", "style"}:
self._skip_depth += 1
return
if self._skip_depth > 0:
return
attr_dict = {k: v for k, v in attrs}
el = Element(tag, attr_dict)
self._push(el)
def handle_endtag(self, tag):
if tag in {"script", "style"}:
if self._skip_depth > 0:
self._skip_depth -= 1
return
if self._skip_depth > 0:
return
self._pop(tag)
def handle_data(self, data):
if self._skip_depth > 0:
return
text = unescape(data)
# Collapse whitespace
if not text:
return
text = re.sub(r"\s+", " ", text)
if not text.strip():
text = " "
self._append_text(text)
def handle_entityref(self, name):
self.handle_data(f"&{name};")
def handle_charref(self, name):
self.handle_data(f"&#{name};")
def parse_html(html_text: str) -> Element:
"""
Very basic HTML parser that extracts text content.
For now, just removes tags and returns a simple tree.
Parse HTML into a small DOM tree of Element/Text nodes.
- Scripts and styles are skipped
- Whitespace is normalized within text nodes
- Entities are decoded
- A root <html><body> is always provided
"""
# Strip HTML tags for basic text extraction
text_content = re.sub(r'<script[^>]*>.*?</script>', '', html_text, flags=re.DOTALL | re.IGNORECASE)
text_content = re.sub(r'<style[^>]*>.*?</style>', '', text_content, flags=re.DOTALL | re.IGNORECASE)
text_content = re.sub(r'<[^>]+>', ' ', text_content)
# Decode HTML entities
text_content = text_content.replace('&lt;', '<')
text_content = text_content.replace('&gt;', '>')
text_content = text_content.replace('&amp;', '&')
text_content = text_content.replace('&quot;', '"')
text_content = text_content.replace('&#39;', "'")
text_content = text_content.replace('&nbsp;', ' ')
# Clean up whitespace
text_content = re.sub(r'\s+', ' ', text_content).strip()
# Create a simple document structure
root = Element("html")
body = Element("body", parent=root)
root.children.append(body)
if text_content:
text_node = Text(text_content, parent=body)
body.children.append(text_node)
return root
parser = _DOMBuilder()
parser.feed(html_text)
parser.close()
return parser.root

View file

@ -99,3 +99,48 @@ def render_error_page(status_code: int, url: str = "", error_message: str = "")
def render_startpage() -> str:
"""Render the startpage."""
return render_template("startpage.html")
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",
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",
error=f"Failed to load graph: {e}",
graph_content="",
is_svg=False)

35
test_dom.html Normal file
View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DOM Test Page</title>
</head>
<body>
<h1>DOM Visualization Test</h1>
<p>This page demonstrates the DOM visualization feature.</p>
<div class="container">
<h2>Section 1</h2>
<p>Press <strong>Ctrl+Shift+D</strong> to visualize the DOM tree.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</div>
<div class="features">
<h2>Features</h2>
<ol>
<li>Color-coded nodes by element type</li>
<li>Visual hierarchy representation</li>
<li>Text content preview</li>
</ol>
</div>
<footer>
<p>Bowser Browser - Educational Project</p>
</footer>
</body>
</html>

142
tests/test_dom_graph.py Normal file
View file

@ -0,0 +1,142 @@
"""Tests for DOM graph visualization."""
import pytest
from src.parser.html import parse_html, Element, Text
from src.debug.dom_graph import generate_dot_graph, print_dom_tree
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 = "<html><body><p>Hello World</p></body></html>"
doc = parse_html(html)
dot = generate_dot_graph(doc)
assert "digraph DOM" in dot
assert "node_" in dot # Should have node IDs
assert "<html>" in dot
assert "<body>" in dot
assert "<p>" in dot
assert "Hello World" in dot
def test_generate_dot_graph_with_attributes(self):
"""Test graph generation with element attributes."""
html = '<html><body><div class="test" id="main">Content</div></body></html>'
doc = parse_html(html)
dot = generate_dot_graph(doc)
assert "digraph DOM" in dot
assert "<div>" in dot
# Attributes should be included (at least some of them)
assert "class" in dot or "id" in dot
def test_generate_dot_graph_nested(self):
"""Test graph generation with nested elements."""
html = """
<html>
<body>
<div>
<p>First</p>
<p>Second</p>
</div>
</body>
</html>
"""
doc = parse_html(html)
dot = generate_dot_graph(doc)
assert "digraph DOM" in dot
assert "->" in dot # Should have edges
assert "First" in dot
assert "Second" in dot
def test_generate_dot_graph_colors(self):
"""Test that different element types get different colors."""
html = "<html><body><h1>Title</h1><p>Text</p><ul><li>Item</li></ul></body></html>"
doc = parse_html(html)
dot = generate_dot_graph(doc)
# Check for color attributes
assert "fillcolor=" in dot
assert "lightgreen" in dot or "lightyellow" in dot or "lightgray" in dot
def test_print_dom_tree_simple(self):
"""Test text tree representation."""
html = "<html><body><p>Hello</p></body></html>"
doc = parse_html(html)
tree = print_dom_tree(doc)
assert "<html>" in tree
assert "<body>" in tree
assert "<p>" in tree
assert "Hello" in tree
def test_print_dom_tree_indentation(self):
"""Test that tree has proper indentation."""
html = "<html><body><div><p>Nested</p></div></body></html>"
doc = parse_html(html)
tree = print_dom_tree(doc)
# Should have increasing indentation
lines = tree.split('\n')
# Find the nested <p> line - should be more indented than <div>
p_line = [l for l in lines if '<p>' in l][0]
div_line = [l for l in lines if '<div>' in l][0]
# Count leading spaces
p_indent = len(p_line) - len(p_line.lstrip())
div_indent = len(div_line) - len(div_line.lstrip())
assert p_indent > div_indent
def test_print_dom_tree_max_depth(self):
"""Test that max_depth limits tree traversal."""
html = "<html><body><div><div><div><p>Deep</p></div></div></div></body></html>"
doc = parse_html(html)
tree_shallow = print_dom_tree(doc, max_depth=2)
tree_deep = print_dom_tree(doc, max_depth=10)
# Shallow should be shorter
assert len(tree_shallow) < len(tree_deep)
assert "..." in tree_shallow
def test_generate_dot_graph_text_escaping(self):
"""Test that special characters in text are escaped."""
html = '<html><body><p>Text with "quotes" and newlines\n</p></body></html>'
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
lines = dot.split('\n')
# All lines should be valid (no line starts with unexpected characters)
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"<html><body><p>{long_text}</p></body></html>"
doc = parse_html(html)
dot = generate_dot_graph(doc)
# Should contain truncation marker
assert "..." in dot

View file

@ -0,0 +1,67 @@
"""Tests for DOM graph page rendering."""
import pytest
from src.templates import render_dom_graph_page
from pathlib import Path
import tempfile
import os
class TestDOMGraphPage:
def test_render_dom_graph_page_svg(self):
"""Test rendering page with SVG graph."""
# Create temporary SVG file
with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f:
f.write('<svg><circle cx="50" cy="50" r="40"/></svg>')
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
assert '<svg>' in html
assert 'circle' in html
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 &gt;
assert ("A -> B" in html or "A -&gt; B" in html)
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('<svg><rect/></svg>')
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:
os.unlink(temp_path)

View file

@ -4,6 +4,16 @@ import pytest
from src.parser.html import parse_html, Text, Element
def collect_text(node):
texts = []
if isinstance(node, Text):
texts.append(node.text)
if hasattr(node, "children"):
for child in node.children:
texts.extend(collect_text(child))
return texts
class TestParseHTML:
def test_parse_simple_text(self):
html = "<html><body>Hello World</body></html>"
@ -15,60 +25,58 @@ class TestParseHTML:
body = root.children[0]
assert body.tag == "body"
assert len(body.children) == 1
text = body.children[0]
assert isinstance(text, Text)
assert "Hello World" in text.text
texts = collect_text(body)
joined = " ".join(texts)
assert "Hello World" in joined
def test_parse_strips_tags(self):
html = "<html><body><p>Hello</p><div>World</div></body></html>"
root = parse_html(html)
body = root.children[0]
text = body.children[0]
assert "Hello" in text.text
assert "World" in text.text
joined = " ".join(collect_text(body))
assert "Hello" in joined
assert "World" in joined
def test_parse_removes_script_tags(self):
html = "<html><body>Visible<script>alert('bad')</script>Text</body></html>"
root = parse_html(html)
body = root.children[0]
text = body.children[0]
assert "Visible" in text.text
assert "Text" in text.text
assert "alert" not in text.text
assert "script" not in text.text.lower()
joined = " ".join(collect_text(body))
assert "Visible" in joined
assert "Text" in joined
assert "alert" not in joined
assert "script" not in joined.lower()
def test_parse_removes_style_tags(self):
html = "<html><body>Text<style>body{color:red;}</style>More</body></html>"
root = parse_html(html)
body = root.children[0]
text = body.children[0]
assert "Text" in text.text
assert "More" in text.text
assert "color" not in text.text
joined = " ".join(collect_text(body))
assert "Text" in joined
assert "More" in joined
assert "color" not in joined
def test_parse_decodes_entities(self):
html = "<html><body>&lt;div&gt; &amp; &quot;test&quot;</body></html>"
root = parse_html(html)
body = root.children[0]
text = body.children[0]
assert "<div>" in text.text
assert "&" in text.text
assert '"test"' in text.text
joined = " ".join(collect_text(body))
assert "<div>" in joined
assert "&" in joined
assert '"test"' in joined
def test_parse_normalizes_whitespace(self):
html = "<html><body>Hello \n\n World</body></html>"
root = parse_html(html)
body = root.children[0]
text = body.children[0]
joined = " ".join(collect_text(body))
# Multiple whitespace should be collapsed
assert "Hello World" in text.text
assert "Hello World" in joined
def test_parse_empty_document(self):
html = "<html><body></body></html>"
@ -79,4 +87,4 @@ class TestParseHTML:
body = root.children[0]
assert body.tag == "body"
# Empty body should have no text children
assert len(body.children) == 0
assert len(collect_text(body)) == 0