From b0e693e50cdf595d39d5a275eaaaed3151c4cb7f Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Fri, 9 Jan 2026 12:20:46 +0100 Subject: [PATCH] Initial bowser project scaffold --- .github/prompts/plan-bowserBrowser.prompt.md | 158 +++++++++++++++++++ assets/default.css | 6 + main.py | 13 ++ src/__init__.py | 1 + src/accessibility/__init__.py | 1 + src/accessibility/a11y.py | 10 ++ src/browser/__init__.py | 1 + src/browser/browser.py | 21 +++ src/browser/chrome.py | 10 ++ src/browser/tab.py | 27 ++++ src/layout/__init__.py | 1 + src/layout/block.py | 23 +++ src/layout/document.py | 12 ++ src/layout/embed.py | 23 +++ src/layout/inline.py | 12 ++ src/network/__init__.py | 1 + src/network/cookies.py | 17 ++ src/network/http.py | 18 +++ src/network/url.py | 20 +++ src/parser/__init__.py | 1 + src/parser/css.py | 16 ++ src/parser/html.py | 29 ++++ src/render/__init__.py | 1 + src/render/composite.py | 11 ++ src/render/fonts.py | 10 ++ src/render/paint.py | 18 +++ src/script/__init__.py | 1 + src/script/bindings.py | 6 + src/script/context.py | 11 ++ src/script/runtime.js | 2 + tests/__init__.py | 0 31 files changed, 481 insertions(+) create mode 100644 .github/prompts/plan-bowserBrowser.prompt.md create mode 100644 assets/default.css create mode 100644 main.py create mode 100644 src/__init__.py create mode 100644 src/accessibility/__init__.py create mode 100644 src/accessibility/a11y.py create mode 100644 src/browser/__init__.py create mode 100644 src/browser/browser.py create mode 100644 src/browser/chrome.py create mode 100644 src/browser/tab.py create mode 100644 src/layout/__init__.py create mode 100644 src/layout/block.py create mode 100644 src/layout/document.py create mode 100644 src/layout/embed.py create mode 100644 src/layout/inline.py create mode 100644 src/network/__init__.py create mode 100644 src/network/cookies.py create mode 100644 src/network/http.py create mode 100644 src/network/url.py create mode 100644 src/parser/__init__.py create mode 100644 src/parser/css.py create mode 100644 src/parser/html.py create mode 100644 src/render/__init__.py create mode 100644 src/render/composite.py create mode 100644 src/render/fonts.py create mode 100644 src/render/paint.py create mode 100644 src/script/__init__.py create mode 100644 src/script/bindings.py create mode 100644 src/script/context.py create mode 100644 src/script/runtime.js create mode 100644 tests/__init__.py diff --git a/.github/prompts/plan-bowserBrowser.prompt.md b/.github/prompts/plan-bowserBrowser.prompt.md new file mode 100644 index 0000000..cb398c8 --- /dev/null +++ b/.github/prompts/plan-bowserBrowser.prompt.md @@ -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 diff --git a/assets/default.css b/assets/default.css new file mode 100644 index 0000000..83c6765 --- /dev/null +++ b/assets/default.css @@ -0,0 +1,6 @@ +/* Default user-agent stylesheet placeholder. */ + +body { + margin: 8px; + font-family: sans-serif; +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..b3eca53 --- /dev/null +++ b/main.py @@ -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() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..ff8ffc5 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Bowser browser engine packages.""" diff --git a/src/accessibility/__init__.py b/src/accessibility/__init__.py new file mode 100644 index 0000000..f51cf3f --- /dev/null +++ b/src/accessibility/__init__.py @@ -0,0 +1 @@ +"""Accessibility tree and helpers.""" diff --git a/src/accessibility/a11y.py b/src/accessibility/a11y.py new file mode 100644 index 0000000..5f4744c --- /dev/null +++ b/src/accessibility/a11y.py @@ -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 diff --git a/src/browser/__init__.py b/src/browser/__init__.py new file mode 100644 index 0000000..12c1cff --- /dev/null +++ b/src/browser/__init__.py @@ -0,0 +1 @@ +"""Browser chrome and tab orchestration.""" diff --git a/src/browser/browser.py b/src/browser/browser.py new file mode 100644 index 0000000..3fd4d4b --- /dev/null +++ b/src/browser/browser.py @@ -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 diff --git a/src/browser/chrome.py b/src/browser/chrome.py new file mode 100644 index 0000000..b434f56 --- /dev/null +++ b/src/browser/chrome.py @@ -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 diff --git a/src/browser/tab.py b/src/browser/tab.py new file mode 100644 index 0000000..4f20720 --- /dev/null +++ b/src/browser/tab.py @@ -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) diff --git a/src/layout/__init__.py b/src/layout/__init__.py new file mode 100644 index 0000000..fa0839e --- /dev/null +++ b/src/layout/__init__.py @@ -0,0 +1 @@ +"""Layout: block/inline layout objects.""" diff --git a/src/layout/block.py b/src/layout/block.py new file mode 100644 index 0000000..4aba8d5 --- /dev/null +++ b/src/layout/block.py @@ -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 diff --git a/src/layout/document.py b/src/layout/document.py new file mode 100644 index 0000000..83bf665 --- /dev/null +++ b/src/layout/document.py @@ -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 diff --git a/src/layout/embed.py b/src/layout/embed.py new file mode 100644 index 0000000..207d671 --- /dev/null +++ b/src/layout/embed.py @@ -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 diff --git a/src/layout/inline.py b/src/layout/inline.py new file mode 100644 index 0000000..69d9fdf --- /dev/null +++ b/src/layout/inline.py @@ -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) diff --git a/src/network/__init__.py b/src/network/__init__.py new file mode 100644 index 0000000..feaa508 --- /dev/null +++ b/src/network/__init__.py @@ -0,0 +1 @@ +"""Networking: URL parsing, HTTP, cookies.""" diff --git a/src/network/cookies.py b/src/network/cookies.py new file mode 100644 index 0000000..0e30f92 --- /dev/null +++ b/src/network/cookies.py @@ -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 "" diff --git a/src/network/http.py b/src/network/http.py new file mode 100644 index 0000000..72653df --- /dev/null +++ b/src/network/http.py @@ -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() diff --git a/src/network/url.py b/src/network/url.py new file mode 100644 index 0000000..d70ae1e --- /dev/null +++ b/src/network/url.py @@ -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() diff --git a/src/parser/__init__.py b/src/parser/__init__.py new file mode 100644 index 0000000..169c26d --- /dev/null +++ b/src/parser/__init__.py @@ -0,0 +1 @@ +"""Parsing: HTML and CSS parsers.""" diff --git a/src/parser/css.py b/src/parser/css.py new file mode 100644 index 0000000..004dd86 --- /dev/null +++ b/src/parser/css.py @@ -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 diff --git a/src/parser/html.py b/src/parser/html.py new file mode 100644 index 0000000..7293db9 --- /dev/null +++ b/src/parser/html.py @@ -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) diff --git a/src/render/__init__.py b/src/render/__init__.py new file mode 100644 index 0000000..5e0aecd --- /dev/null +++ b/src/render/__init__.py @@ -0,0 +1 @@ +"""Rendering and painting primitives.""" diff --git a/src/render/composite.py b/src/render/composite.py new file mode 100644 index 0000000..7ad59ed --- /dev/null +++ b/src/render/composite.py @@ -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) diff --git a/src/render/fonts.py b/src/render/fonts.py new file mode 100644 index 0000000..5509657 --- /dev/null +++ b/src/render/fonts.py @@ -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) diff --git a/src/render/paint.py b/src/render/paint.py new file mode 100644 index 0000000..7c3fcd0 --- /dev/null +++ b/src/render/paint.py @@ -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 diff --git a/src/script/__init__.py b/src/script/__init__.py new file mode 100644 index 0000000..cf56e40 --- /dev/null +++ b/src/script/__init__.py @@ -0,0 +1 @@ +"""JavaScript integration and bindings.""" diff --git a/src/script/bindings.py b/src/script/bindings.py new file mode 100644 index 0000000..306baa0 --- /dev/null +++ b/src/script/bindings.py @@ -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 diff --git a/src/script/context.py b/src/script/context.py new file mode 100644 index 0000000..52f28e1 --- /dev/null +++ b/src/script/context.py @@ -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 diff --git a/src/script/runtime.js b/src/script/runtime.js new file mode 100644 index 0000000..4c0dae7 --- /dev/null +++ b/src/script/runtime.js @@ -0,0 +1,2 @@ +// Runtime helpers injected into JS contexts. +// Placeholder: add DOM polyfills and event loop shims here. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29