Initial bowser project scaffold

This commit is contained in:
Benedikt Willi 2026-01-09 12:20:46 +01:00
commit b0e693e50c
31 changed files with 481 additions and 0 deletions

View 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
View file

@ -0,0 +1,6 @@
/* Default user-agent stylesheet placeholder. */
body {
margin: 8px;
font-family: sans-serif;
}

13
main.py Normal file
View 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
View file

@ -0,0 +1 @@
"""Bowser browser engine packages."""

View file

@ -0,0 +1 @@
"""Accessibility tree and helpers."""

10
src/accessibility/a11y.py Normal file
View 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
View file

@ -0,0 +1 @@
"""Browser chrome and tab orchestration."""

21
src/browser/browser.py Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
"""Layout: block/inline layout objects."""

23
src/layout/block.py Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
"""Networking: URL parsing, HTTP, cookies."""

17
src/network/cookies.py Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
"""Parsing: HTML and CSS parsers."""

16
src/parser/css.py Normal file
View 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
View 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
View file

@ -0,0 +1 @@
"""Rendering and painting primitives."""

11
src/render/composite.py Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
"""JavaScript integration and bindings."""

6
src/script/bindings.py Normal file
View 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
View 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
View 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
View file