mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
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:
parent
d3119f0b10
commit
3838aa17af
19 changed files with 1841 additions and 258 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -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
146
FEATURE_DOM_GRAPH.md
Normal 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
80
QUICKSTART_DOM_GRAPH.md
Normal 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
|
||||
10
README.md
10
README.md
|
|
@ -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
152
UPDATE_DOM_TAB.md
Normal 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
232
assets/pages/dom_graph.html
Normal 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><html>, <body></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
157
docs/DOM_GRAPH_UX.md
Normal 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
97
docs/DOM_VISUALIZATION.md
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
1
src/debug/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Debug and inspection utilities."""
|
||||
189
src/debug/dom_graph.py
Normal file
189
src/debug/dom_graph.py
Normal 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)
|
||||
|
|
@ -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('<', '<')
|
||||
text_content = text_content.replace('>', '>')
|
||||
text_content = text_content.replace('&', '&')
|
||||
text_content = text_content.replace('"', '"')
|
||||
text_content = text_content.replace(''', "'")
|
||||
text_content = text_content.replace(' ', ' ')
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
35
test_dom.html
Normal 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
142
tests/test_dom_graph.py
Normal 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
|
||||
67
tests/test_dom_graph_page.py
Normal file
67
tests/test_dom_graph_page.py
Normal 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 >
|
||||
assert ("A -> B" in html or "A -> 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)
|
||||
|
|
@ -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><div> & "test"</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
|
||||
|
|
|
|||
Loading…
Reference in a new issue