mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
Initial bowser project scaffold
This commit is contained in:
commit
b0e693e50c
31 changed files with 481 additions and 0 deletions
158
.github/prompts/plan-bowserBrowser.prompt.md
vendored
Normal file
158
.github/prompts/plan-bowserBrowser.prompt.md
vendored
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
## Plan: Bowser — Custom Web Browser from Scratch
|
||||
|
||||
Build a complete web browser following the [browser.engineering](https://browser.engineering/) curriculum, implementing all major components without relying on WebView wrappers.
|
||||
|
||||
---
|
||||
|
||||
### Language Choice
|
||||
|
||||
- Default: **Python** (matches browser.engineering; fastest to build). Use Skia (`skia-python`) + GTK (`PyGObject`).
|
||||
- Optional ports: **Go** (networking/concurrency, single binary) or **Zig** (C interop, fine-grained control) once the Python version is feature-complete. Keep Python parity as the spec.
|
||||
|
||||
---
|
||||
|
||||
### GUI Toolkit: GTK via PyGObject
|
||||
|
||||
- Cross-platform; mature Python bindings.
|
||||
- Paint with Skia (preferred) or Cairo; full control of pixels, no WebView wrappers.
|
||||
- Alternative: Skia + SDL2/GLFW if you want lower-level window/input handling.
|
||||
|
||||
---
|
||||
|
||||
### Architecture Overview (from browser.engineering)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ ┌─────────┐ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Chrome │ │ Tab │ │
|
||||
│ │ (GTK) │ │ ┌───────┐ ┌────────┐ ┌───────────┐ │ │
|
||||
│ │ │ │ │ Frame │ │ Frame │ │ Frame │ │ │
|
||||
│ │ - tabs │ │ │(main) │ │(iframe)│ │ (iframe) │ │ │
|
||||
│ │ - addr │ │ └───────┘ └────────┘ └───────────┘ │ │
|
||||
│ │ - back │ └─────────────────────────────────────────┘ │
|
||||
│ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Part 1: Loading Pages
|
||||
| Chapter | Component | Key Classes/Functions |
|
||||
|---------|-----------|----------------------|
|
||||
| 1 | URL & HTTP | `URL`, `request()`, `resolve()`, `COOKIE_JAR` |
|
||||
| 2 | Drawing | `Browser`, `Chrome`, Skia/Cairo canvas |
|
||||
| 3 | Text Formatting | `get_font()`, `linespace()`, word wrapping |
|
||||
|
||||
#### Part 2: Viewing Documents
|
||||
| Chapter | Component | Key Classes/Functions |
|
||||
|---------|-----------|----------------------|
|
||||
| 4 | HTML Parsing | `HTMLParser`, `Text`, `Element`, `print_tree()` |
|
||||
| 5 | Layout | `DocumentLayout`, `BlockLayout`, `LineLayout`, `TextLayout` |
|
||||
| 6 | CSS | `CSSParser`, `TagSelector`, `DescendantSelector`, `style()` |
|
||||
| 7 | Interaction | `Chrome`, hyperlinks, `click()`, `focus_element()` |
|
||||
|
||||
#### Part 3: Running Applications
|
||||
| Chapter | Component | Key Classes/Functions |
|
||||
|---------|-----------|----------------------|
|
||||
| 8 | Forms | `submit_form()`, POST requests |
|
||||
| 9 | JavaScript | `JSContext`, embed external engine (see below) |
|
||||
| 10 | Security | Cookies, same-origin policy, XSS/CSRF protection |
|
||||
|
||||
#### Part 4: Modern Browsers
|
||||
| Chapter | Component | Key Classes/Functions |
|
||||
|---------|-----------|----------------------|
|
||||
| 11 | Visual Effects | `Blend`, `Transform`, `CompositedLayer` |
|
||||
| 12 | Threading | `Task`, `TaskRunner`, event loop |
|
||||
| 13 | Animations | `NumericAnimation`, GPU compositing |
|
||||
| 14 | Accessibility | `AccessibilityNode`, `speak_text()` |
|
||||
| 15 | Embeds | `ImageLayout`, `IframeLayout`, `Frame` |
|
||||
| 16 | Invalidation | `ProtectedField`, `dirty_style()`, incremental layout |
|
||||
|
||||
---
|
||||
|
||||
### JavaScript Engine Strategy
|
||||
|
||||
**Primary options (pick one to start):**
|
||||
- **QuickJS / QuickJS-NG** — Small, fast, ES2020; approachable embedding.
|
||||
- **Duktape** — Very small, forgiving; good starter.
|
||||
- **LibJS** — Much larger; useful for reference, heavier to embed.
|
||||
|
||||
**Path:** start by embedding QuickJS (or QuickJS-NG). Add bindings for DOM APIs used in chapters. Optionally experiment with Duktape for simplicity. Custom interpreter later only for learning.
|
||||
|
||||
---
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
bowser/
|
||||
├── src/
|
||||
│ ├── network/
|
||||
│ │ ├── url.py # URL parsing, resolution
|
||||
│ │ ├── http.py # HTTP/HTTPS requests
|
||||
│ │ └── cookies.py # Cookie jar management
|
||||
│ ├── parser/
|
||||
│ │ ├── html.py # HTMLParser, Text, Element
|
||||
│ │ └── css.py # CSSParser, selectors
|
||||
│ ├── layout/
|
||||
│ │ ├── document.py # DocumentLayout
|
||||
│ │ ├── block.py # BlockLayout, LineLayout
|
||||
│ │ ├── inline.py # TextLayout, InputLayout
|
||||
│ │ └── embed.py # ImageLayout, IframeLayout
|
||||
│ ├── render/
|
||||
│ │ ├── paint.py # PaintCommand, Draw* classes
|
||||
│ │ ├── composite.py # CompositedLayer, visual effects
|
||||
│ │ └── fonts.py # Font management, text shaping
|
||||
│ ├── script/
|
||||
│ │ ├── context.py # JSContext
|
||||
│ │ ├── bindings.py # DOM bindings for JS engine
|
||||
│ │ └── runtime.js # JS runtime helpers
|
||||
│ ├── browser/
|
||||
│ │ ├── tab.py # Tab, Frame
|
||||
│ │ ├── chrome.py # Chrome (UI)
|
||||
│ │ └── browser.py # Main Browser class
|
||||
│ └── accessibility/
|
||||
│ └── a11y.py # AccessibilityNode, screen reader
|
||||
├── tests/
|
||||
├── assets/
|
||||
│ └── default.css # User-agent stylesheet
|
||||
└── main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Development Milestones
|
||||
|
||||
- [ ] **M1**: Display "Hello World" in window (URL → HTTP → canvas)
|
||||
- [ ] **M2**: Render plain HTML with text wrapping
|
||||
- [ ] **M3**: Parse and apply basic CSS (colors, fonts, margins)
|
||||
- [ ] **M4**: Clickable links and navigation
|
||||
- [ ] **M5**: Form input and submission
|
||||
- [ ] **M6**: JavaScript execution (console.log, DOM queries)
|
||||
- [ ] **M7**: Event handling (onclick, onsubmit)
|
||||
- [ ] **M8**: Images and iframes
|
||||
- [ ] **M9**: Smooth scrolling and animations
|
||||
- [ ] **M10**: Accessibility tree and keyboard navigation
|
||||
|
||||
---
|
||||
|
||||
### Key Dependencies (Python)
|
||||
|
||||
```
|
||||
skia-python # 2D graphics (or cairocffi)
|
||||
PyGObject # GTK bindings
|
||||
pyduktape2 # JavaScript engine (or quickjs)
|
||||
harfbuzz # Text shaping (via uharfbuzz)
|
||||
Pillow # Image decoding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Resources
|
||||
|
||||
- 📖 [browser.engineering](https://browser.engineering/) — Primary reference
|
||||
- 📖 [Let's Build a Browser Engine](https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html) — Matt Brubeck's Rust tutorial
|
||||
- 🔧 [Skia Graphics Library](https://skia.org/)
|
||||
- 🔧 [QuickJS](https://bellard.org/quickjs/) — Embeddable JS engine
|
||||
6
assets/default.css
Normal file
6
assets/default.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/* Default user-agent stylesheet placeholder. */
|
||||
|
||||
body {
|
||||
margin: 8px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
13
main.py
Normal file
13
main.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Entry point for Bowser browser (stub)."""
|
||||
|
||||
from src.browser.browser import Browser
|
||||
|
||||
|
||||
def main():
|
||||
browser = Browser()
|
||||
browser.new_tab("https://example.com")
|
||||
browser.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Bowser browser engine packages."""
|
||||
1
src/accessibility/__init__.py
Normal file
1
src/accessibility/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Accessibility tree and helpers."""
|
||||
10
src/accessibility/a11y.py
Normal file
10
src/accessibility/a11y.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Accessibility stubs."""
|
||||
|
||||
|
||||
class AccessibilityNode:
|
||||
def __init__(self, node, parent=None):
|
||||
self.node = node
|
||||
self.parent = parent
|
||||
|
||||
def build(self):
|
||||
return self
|
||||
1
src/browser/__init__.py
Normal file
1
src/browser/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Browser chrome and tab orchestration."""
|
||||
21
src/browser/browser.py
Normal file
21
src/browser/browser.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Browser entry and orchestration."""
|
||||
|
||||
from ..network.url import URL
|
||||
from .tab import Tab
|
||||
|
||||
|
||||
class Browser:
|
||||
def __init__(self):
|
||||
self.tabs = []
|
||||
self.active_tab: Tab | None = None
|
||||
|
||||
def new_tab(self, url: str):
|
||||
tab = Tab(self)
|
||||
tab.load(URL(url))
|
||||
self.tabs.append(tab)
|
||||
self.active_tab = tab
|
||||
return tab
|
||||
|
||||
def run(self):
|
||||
# Placeholder: mainloop hooks into GTK
|
||||
pass
|
||||
10
src/browser/chrome.py
Normal file
10
src/browser/chrome.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Browser chrome (GTK UI) stub."""
|
||||
|
||||
|
||||
class Chrome:
|
||||
def __init__(self, browser):
|
||||
self.browser = browser
|
||||
|
||||
def paint(self):
|
||||
# Placeholder: draw tabs/address bar via GTK + Skia/Cairo
|
||||
pass
|
||||
27
src/browser/tab.py
Normal file
27
src/browser/tab.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Tab and frame orchestration stubs."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ..network.url import URL
|
||||
|
||||
|
||||
class Frame:
|
||||
def __init__(self, tab: "Tab", parent_frame=None, frame_element=None):
|
||||
self.tab = tab
|
||||
self.parent_frame = parent_frame
|
||||
self.frame_element = frame_element
|
||||
|
||||
def load(self, url: URL, payload: Optional[bytes] = None):
|
||||
# TODO: integrate network + parsing + layout + render pipeline
|
||||
self.tab.current_url = url
|
||||
|
||||
|
||||
class Tab:
|
||||
def __init__(self, browser: "Browser", tab_height: int = 40):
|
||||
self.browser = browser
|
||||
self.tab_height = tab_height
|
||||
self.current_url: Optional[URL] = None
|
||||
self.main_frame = Frame(self)
|
||||
|
||||
def load(self, url: URL, payload: Optional[bytes] = None):
|
||||
self.main_frame.load(url, payload)
|
||||
1
src/layout/__init__.py
Normal file
1
src/layout/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Layout: block/inline layout objects."""
|
||||
23
src/layout/block.py
Normal file
23
src/layout/block.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Block and line layout stubs."""
|
||||
|
||||
|
||||
class BlockLayout:
|
||||
def __init__(self, node, parent=None, previous=None, frame=None):
|
||||
self.node = node
|
||||
self.parent = parent
|
||||
self.previous = previous
|
||||
self.frame = frame
|
||||
self.children = []
|
||||
|
||||
def layout(self):
|
||||
return 0
|
||||
|
||||
|
||||
class LineLayout:
|
||||
def __init__(self, node, parent=None, previous=None):
|
||||
self.node = node
|
||||
self.parent = parent
|
||||
self.previous = previous
|
||||
|
||||
def layout(self):
|
||||
return 0
|
||||
12
src/layout/document.py
Normal file
12
src/layout/document.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Document-level layout stub."""
|
||||
|
||||
|
||||
class DocumentLayout:
|
||||
def __init__(self, node, frame=None):
|
||||
self.node = node
|
||||
self.frame = frame
|
||||
self.children = []
|
||||
|
||||
def layout(self, width: int, zoom: float = 1.0):
|
||||
# Placeholder layout logic
|
||||
return width * zoom
|
||||
23
src/layout/embed.py
Normal file
23
src/layout/embed.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Embedded content layout stubs (images, iframes)."""
|
||||
|
||||
|
||||
class ImageLayout:
|
||||
def __init__(self, node, parent=None, previous=None, frame=None):
|
||||
self.node = node
|
||||
self.parent = parent
|
||||
self.previous = previous
|
||||
self.frame = frame
|
||||
|
||||
def layout(self):
|
||||
return 0
|
||||
|
||||
|
||||
class IframeLayout:
|
||||
def __init__(self, node, parent=None, previous=None, parent_frame=None):
|
||||
self.node = node
|
||||
self.parent = parent
|
||||
self.previous = previous
|
||||
self.parent_frame = parent_frame
|
||||
|
||||
def layout(self):
|
||||
return 0
|
||||
12
src/layout/inline.py
Normal file
12
src/layout/inline.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Inline and text layout stubs."""
|
||||
|
||||
|
||||
class TextLayout:
|
||||
def __init__(self, node, word, parent=None, previous=None):
|
||||
self.node = node
|
||||
self.word = word
|
||||
self.parent = parent
|
||||
self.previous = previous
|
||||
|
||||
def layout(self):
|
||||
return len(self.word)
|
||||
1
src/network/__init__.py
Normal file
1
src/network/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Networking: URL parsing, HTTP, cookies."""
|
||||
17
src/network/cookies.py
Normal file
17
src/network/cookies.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""In-memory cookie jar (placeholder)."""
|
||||
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class CookieJar:
|
||||
def __init__(self):
|
||||
self._cookies: Dict[str, SimpleCookie] = {}
|
||||
|
||||
def set_cookies(self, origin: str, cookie_header: str) -> None:
|
||||
jar = self._cookies.setdefault(origin, SimpleCookie())
|
||||
jar.load(cookie_header)
|
||||
|
||||
def get_cookie_header(self, origin: str) -> str:
|
||||
jar = self._cookies.get(origin)
|
||||
return jar.output(header="", sep="; ").strip() if jar else ""
|
||||
18
src/network/http.py
Normal file
18
src/network/http.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""HTTP requests and response handling."""
|
||||
|
||||
import http.client
|
||||
from typing import Optional
|
||||
|
||||
from .url import URL
|
||||
|
||||
|
||||
def request(url: URL, payload: Optional[bytes] = None, method: str = "GET"):
|
||||
parsed = url._parsed
|
||||
conn_class = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
||||
conn = conn_class(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||
path = parsed.path or "/"
|
||||
if parsed.query:
|
||||
path = f"{path}?{parsed.query}"
|
||||
headers = {}
|
||||
conn.request(method, path, body=payload, headers=headers)
|
||||
return conn.getresponse()
|
||||
20
src/network/url.py
Normal file
20
src/network/url.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""URL parsing and resolution."""
|
||||
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
|
||||
class URL:
|
||||
def __init__(self, url: str):
|
||||
self._parsed = urlparse(url)
|
||||
|
||||
def resolve(self, relative: str) -> "URL":
|
||||
return URL(urljoin(self._parsed.geturl(), relative))
|
||||
|
||||
def origin(self) -> str:
|
||||
scheme = self._parsed.scheme
|
||||
host = self._parsed.hostname or ""
|
||||
port = f":{self._parsed.port}" if self._parsed.port else ""
|
||||
return f"{scheme}://{host}{port}"
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - convenience
|
||||
return self._parsed.geturl()
|
||||
1
src/parser/__init__.py
Normal file
1
src/parser/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Parsing: HTML and CSS parsers."""
|
||||
16
src/parser/css.py
Normal file
16
src/parser/css.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""CSS parser stubs."""
|
||||
|
||||
|
||||
class CSSRule:
|
||||
def __init__(self, selector: str, declarations: dict):
|
||||
self.selector = selector
|
||||
self.declarations = declarations
|
||||
|
||||
|
||||
def parse(css_text: str):
|
||||
# Placeholder: split on semicolons per line
|
||||
rules = []
|
||||
for line in css_text.splitlines():
|
||||
if "{" not in line:
|
||||
continue
|
||||
return rules
|
||||
29
src/parser/html.py
Normal file
29
src/parser/html.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""HTML parser stubs."""
|
||||
|
||||
|
||||
class Text:
|
||||
def __init__(self, text, parent=None):
|
||||
self.text = text
|
||||
self.parent = parent
|
||||
|
||||
def __repr__(self): # pragma: no cover - debug helper
|
||||
return f"Text({self.text!r})"
|
||||
|
||||
|
||||
class Element:
|
||||
def __init__(self, tag, attributes=None, parent=None):
|
||||
self.tag = tag
|
||||
self.attributes = attributes or {}
|
||||
self.children = []
|
||||
self.parent = parent
|
||||
|
||||
def __repr__(self): # pragma: no cover - debug helper
|
||||
return f"Element({self.tag!r}, {self.attributes!r})"
|
||||
|
||||
|
||||
def print_tree(node, indent=0):
|
||||
spacer = " " * indent
|
||||
print(f"{spacer}{node}")
|
||||
if hasattr(node, "children"):
|
||||
for child in node.children:
|
||||
print_tree(child, indent + 1)
|
||||
1
src/render/__init__.py
Normal file
1
src/render/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Rendering and painting primitives."""
|
||||
11
src/render/composite.py
Normal file
11
src/render/composite.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Compositing stubs."""
|
||||
|
||||
|
||||
class CompositedLayer:
|
||||
def __init__(self, display_item=None):
|
||||
self.items = []
|
||||
if display_item:
|
||||
self.items.append(display_item)
|
||||
|
||||
def add(self, display_item):
|
||||
self.items.append(display_item)
|
||||
10
src/render/fonts.py
Normal file
10
src/render/fonts.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Font management stubs."""
|
||||
|
||||
|
||||
def get_font(size: int, weight: str = "normal", style: str = "normal"):
|
||||
return (size, weight, style)
|
||||
|
||||
|
||||
def linespace(font) -> int:
|
||||
size, _, _ = font
|
||||
return int(size * 1.2)
|
||||
18
src/render/paint.py
Normal file
18
src/render/paint.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Painting primitives (stubs)."""
|
||||
|
||||
|
||||
class PaintCommand:
|
||||
def __init__(self, rect):
|
||||
self.rect = rect
|
||||
|
||||
|
||||
class DrawText(PaintCommand):
|
||||
def __init__(self, x1, y1, text, font, color):
|
||||
super().__init__((x1, y1, x1, y1))
|
||||
self.text = text
|
||||
self.font = font
|
||||
self.color = color
|
||||
|
||||
def execute(self, canvas):
|
||||
# Placeholder: integrate with Skia/Cairo later
|
||||
pass
|
||||
1
src/script/__init__.py
Normal file
1
src/script/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""JavaScript integration and bindings."""
|
||||
6
src/script/bindings.py
Normal file
6
src/script/bindings.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""DOM bindings to expose into the JS engine (stub)."""
|
||||
|
||||
|
||||
def install_dom_bindings(js_context):
|
||||
# TODO: expose document/window APIs
|
||||
return js_context
|
||||
11
src/script/context.py
Normal file
11
src/script/context.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""JavaScript context integration (stub)."""
|
||||
|
||||
|
||||
class JSContext:
|
||||
def __init__(self, tab, url_origin: str):
|
||||
self.tab = tab
|
||||
self.url_origin = url_origin
|
||||
|
||||
def run(self, script: str, code: str, window_id=0):
|
||||
# Placeholder: wire to QuickJS/NG binding
|
||||
return None
|
||||
2
src/script/runtime.js
Normal file
2
src/script/runtime.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Runtime helpers injected into JS contexts.
|
||||
// Placeholder: add DOM polyfills and event loop shims here.
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
Reference in a new issue