browser.py

Working through the exercises at browser.engineering
git clone https://git.sr.ht/~jbauer/browser.py
Log | Files | Refs | README | LICENSE

commit cf799aa49bba7637f4f0a7bfece023a8559f6047
parent b6c26acd163aa5fdb4341bd8dd8dde6d4fc909eb
Author: Jake Bauer <jbauer@paritybit.ca>
Date:   Tue, 28 Feb 2023 13:10:38 -0500

Finish Chapter 7

Also add some hacks to handle cases that would cause the browser
to crash.

Diffstat:
Mbrowser.py | 371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
1 file changed, 278 insertions(+), 93 deletions(-)

diff --git a/browser.py b/browser.py @@ -9,6 +9,7 @@ FONTS = {} WIDTH, HEIGHT = 800, 600 HSTEP, VSTEP = 13, 18 SCROLLSTEP = 100 +CHROME_PX = 100 BLOCK_ELEMENTS = [ "html", "body", "article", "section", "nav", "aside", @@ -28,7 +29,6 @@ INHERITED_PROPERTIES = { class Browser: def __init__(self): - self.scroll = 0 self.window = tkinter.Tk() self.canvas = tkinter.Canvas( self.window, @@ -37,77 +37,92 @@ class Browser: bg="white", ) self.canvas.pack() - self.window.bind("<Down>", self.scrolldown) - self.window.bind("<Up>", self.scrollup) - self.document = None - with open("browser.css") as f: - self.default_style_sheet = CSSParser(f.read()).parse() - - - def scrolldown(self, e): - max_y = self.document.height - HEIGHT - self.scroll = min(self.scroll + SCROLLSTEP, max_y) + self.window.bind("<Down>", self.handle_down) + self.window.bind("<Up>", self.handle_up) + self.window.bind("<Button-1>", self.handle_click) + self.window.bind("<Key>", self.handle_key) + self.window.bind("<Return>", self.handle_enter) + self.tabs = [] + self.active_tab = None + self.focus = None + self.address_bar = "" + + + def handle_down(self, e): + self.tabs[self.active_tab].scrolldown() self.draw() - - def scrollup(self, e): - self.scroll = max(self.scroll - SCROLLSTEP, 0) + def handle_up(self, e): + self.tabs[self.active_tab].scrollup() self.draw() - - def load(self, url): - time_to_first_draw = timer() - - start = timer() - headers, body = request(url) - end = timer() - print('{0:.5f}'.format(end - start), "- Request to", url) - - start = timer() - self.nodes = HTMLParser(body).parse() - end = timer() - print('{0:.5f}'.format(end - start), "- Lexed response body") - - start = timer() - rules = self.default_style_sheet.copy() - links = [node.attributes["href"] - for node in tree_to_list(self.nodes, []) - if isinstance(node, Element) - and node.tag == "link" - and "href" in node.attributes - and node.attributes.get("rel") == "stylesheet"] - for link in links: - try: - header, body = request(resolve_url(link, url)) - except: - continue - rules.extend(CSSParser(body).parse()) - style(self.nodes, sorted(rules, key=cascade_priority)) - end = timer() - print('{0:.5f}'.format(end - start), "- Styled nodes") - - start = timer() - self.document = DocumentLayout(self.nodes) - self.document.layout() - end = timer() - print('{0:.5f}'.format(end - start), "- Computed page layout") - - start = timer() - self.display_list = [] - self.document.paint(self.display_list) + def handle_click(self, e): + self.focus = None + if e.y < CHROME_PX: + if 40 <= e.x < 40 + 80 * len(self.tabs) and 0 <= e.y < 40: + self.active_tab = int((e.x - 40) / 80) + elif 10 <= e.x < 30 and 10 <= e.y < 30: + self.load("http://www.paritybit.ca/") + elif 10 <= e.x < 35 and 40 <= e.y < 90: + self.tabs[self.active_tab].go_back() + elif 50 <= e.x < WIDTH - 10 and 40 <= e.y < 90: + self.focus = "address bar" + self.address_bar = "" + else: + self.tabs[self.active_tab].click(e.x, e.y - CHROME_PX) self.draw() - end = timer() - print('{0:.5f}'.format(end - start), "- Canvas drawn") - print('{0:.5f}'.format(timer() - time_to_first_draw), "- Time to first draw") + def handle_key(self, e): + if len(e.char) == 0: return + if not (0x20 <= ord(e.char) < 0x7f): return + if self.focus == "address bar": + self.address_bar += e.char + self.draw() + def handle_enter(self, e): + if self.focus == "address bar": + self.tabs[self.active_tab].load(self.address_bar) + self.focus = None + self.draw() def draw(self): self.canvas.delete("all") - for cmd in self.display_list: - if cmd.top > self.scroll + HEIGHT: continue - if cmd.bottom < self.scroll: continue - cmd.execute(self.scroll, self.canvas) + self.tabs[self.active_tab].draw(self.canvas) + self.canvas.create_rectangle(0, 0, WIDTH, CHROME_PX, fill="white", outline="black") + tabfont = get_font(16, "normal", "roman") + for i, tab in enumerate(self.tabs): + name = "Tab {}".format(i) + x1, x2 = 40 + 80 * i, 120 + 80 * i + self.canvas.create_line(x1, 0, x1, 40, fill="black") + self.canvas.create_line(x2, 0, x2, 40, fill="black") + self.canvas.create_text(x1 + 10, 10, anchor="nw", text=name, font=tabfont, fill="black") + if i == self.active_tab: + self.canvas.create_line(0, 40, x1, 40, fill="black") + self.canvas.create_line(x2, 40, WIDTH, 40, fill="black") + # New Tab Button + buttonfont = get_font(16, "normal", "roman") + self.canvas.create_rectangle(10, 10, 30, 30, outline="black", width=1) + self.canvas.create_text(11, 0, anchor="nw", text="+", font=buttonfont, fill="black") + # URL Bar + self.canvas.create_rectangle(40, 50, WIDTH - 10, 90, outline="black", width=1) + if self.focus == "address bar": + self.canvas.create_text( 55, 55, anchor='nw', text=self.address_bar, font=buttonfont, fill="black") + w = buttonfont.measure(self.address_bar) + self.canvas.create_line(55 + w, 55, 55 + w, 85, fill="black") + else: + url = self.tabs[self.active_tab].url + self.canvas.create_text(55, 55, anchor='nw', text=url, font=buttonfont, fill="black") + # Back Button + self.canvas.create_rectangle(10, 50, 35, 90, outline="black", width=1) + self.canvas.create_polygon( 15, 70, 30, 55, 30, 85, fill='black') + + + def load(self, url): + new_tab = Tab() + new_tab.load(url) + self.active_tab = len(self.tabs) + self.tabs.append(new_tab) + self.draw() class DocumentLayout: @@ -144,6 +159,7 @@ class BlockLayout: self.width = None self.height = None self.display_list = [] + self.previous_word = None def layout(self): @@ -164,20 +180,13 @@ class BlockLayout: previous = next else: self.cursor_x = 0 - self.cursor_y = 0 - - self.line = [] + self.new_line() self.recurse(self.node) - self.flush() for child in self.children: child.layout() - if mode == "block": - self.height = sum([ - child.height for child in self.children]) - else: - self.height = self.cursor_y + self.height = sum([child.height for child in self.children]) def paint(self, display_list): @@ -186,26 +195,17 @@ class BlockLayout: x2, y2 = self.x + self.width, self.y + self.height rect = DrawRect(self.x, self.y, x2, y2, bgcolor) display_list.append(rect) - for x, y, word, font, color in self.display_list: - display_list.append(DrawText(x, y, word, font, color)) + for child in self.children: child.paint(display_list) - def flush(self): - if not self.line: return - metrics = [font.metrics() for x, word, font, color in self.line] - max_ascent = max([metric["ascent"] for metric in metrics]) - baseline = self.cursor_y + 1.25 * max_ascent - for rel_x, word, font, color in self.line: - x = self.x + rel_x - y = self.y + baseline - font.metrics("ascent") - self.display_list.append((x, y, word, font, color)) + def new_line(self): + self.previous_word = None self.cursor_x = 0 - self.line = [] - max_descent = max([metric["descent"] for metric in metrics]) - self.cursor_y = baseline + 1.25 * max_descent - + last_line = self.children[-1] if self.children else None + new_line = LineLayout(self.node, self, last_line) + self.children.append(new_line) def text(self, node): color = node.style["color"] @@ -219,8 +219,11 @@ class BlockLayout: for word in node.text.split(): w = font.measure(word) if self.cursor_x + w >= self.width: - self.flush() - self.line.append((self.cursor_x, word, font, color)) + self.new_line() + line = self.children[-1] + text = TextLayout(node, word, line, self.previous_word) + line.children.append(text) + self.previous_word = text self.cursor_x += w + font.measure(" ") @@ -229,11 +232,81 @@ class BlockLayout: self.text(node) else: if node.tag == "br": - self.flush() + self.new_line() for child in node.children: self.recurse(child) +class LineLayout: + def __init__(self, node, parent, previous): + self.node = node + self.parent = parent + self.previous = previous + self.children = [] + + + def layout(self): + self.width = self.parent.width + self.x = self.parent.x + + if self.previous: + self.y = self.previous.y + self.previous.height + else: + self.y = self.parent.y + + for word in self.children: + word.layout() + + try: + max_ascent = max([word.font.metrics("ascent") for word in self.children]) + except: + max_ascent = 0 + baseline = self.y + 1.25 * max_ascent + for word in self.children: + word.y = baseline - word.font.metrics("ascent") + try: + max_descent = max([word.font.metrics("descent") for word in self.children]) + except: + max_descent = 0 + self.height = 1.25 * (max_ascent + max_descent) + + + def paint(self, display_list): + for child in self.children: + child.paint(display_list) + + +class TextLayout: + def __init__(self, node, word, parent, previous): + self.node = node + self.word = word + self.children = [] + self.parent = parent + self.previous = previous + + + def layout(self): + weight = self.node.style["font-weight"] + style = self.node.style["font-style"] + if style == "normal": style = "roman" + size = int(float(self.node.style["font-size"][:-2]) * .75) + self.font = get_font(size, weight, style) + self.width = self.font.measure(self.word) + + if self.previous: + space = self.previous.font.measure(" ") + self.x = self.previous.x + space + self.previous.width + else: + self.x = self.parent.x + + self.height = self.font.metrics("linespace") + + + def paint(self, display_list): + color = self.node.style["color"] + display_list.append(DrawText(self.x, self.y, self.word, self.font, color)) + + class DrawText: def __init__(self, x1, y1, text, font, color): self.top = y1 @@ -518,14 +591,112 @@ class DescendantSelector: return False +class Tab: + def __init__(self): + self.document = None + self.url = None + self.scroll = 0 + self.history = [] + with open("browser.css") as f: + self.default_style_sheet = CSSParser(f.read()).parse() + + + def scrolldown(self): + max_y = self.document.height - (HEIGHT - CHROME_PX) + self.scroll = min(self.scroll + SCROLLSTEP, max_y) + + + def scrollup(self): + self.scroll = max(self.scroll - SCROLLSTEP, CHROME_PX) + + + def click(self, x, y): + y += self.scroll + objs = [obj for obj in tree_to_list(self.document, []) + if obj.x <= x < obj.x + obj.width + and obj.y <= y < obj.y + obj.height] + if not objs: return + elt = objs[-1].node + while elt: + if isinstance(elt, Text): + pass + elif elt.tag == "a" and "href" in elt.attributes: + url = resolve_url(elt.attributes["href"], self.url) + return self.load(url) + elt = elt.parent + + + def go_back(self): + if len(self.history) > 1: + self.history.pop() + back = self.history.pop() + self.load(back) + + + + def load(self, url): + time_to_first_draw = timer() + + start = timer() + self.url = url + self.history.append(url) + headers, body = request(url) + end = timer() + print('{0:.5f}'.format(end - start), "- Request to", url) + + start = timer() + self.nodes = HTMLParser(body).parse() + end = timer() + print('{0:.5f}'.format(end - start), "- Lexed response body") + + start = timer() + rules = self.default_style_sheet.copy() + links = [node.attributes["href"] + for node in tree_to_list(self.nodes, []) + if isinstance(node, Element) + and node.tag == "link" + and "href" in node.attributes + and node.attributes.get("rel") == "stylesheet"] + for link in links: + try: + header, body = request(resolve_url(link, url)) + except: + continue + rules.extend(CSSParser(body).parse()) + style(self.nodes, sorted(rules, key=cascade_priority)) + end = timer() + print('{0:.5f}'.format(end - start), "- Styled nodes") + + start = timer() + self.document = DocumentLayout(self.nodes) + self.document.layout() + end = timer() + print('{0:.5f}'.format(end - start), "- Computed page layout") + + start = timer() + self.display_list = [] + self.document.paint(self.display_list) + end = timer() + print('{0:.5f}'.format(end - start), "- Canvas drawn") + + print('{0:.5f}'.format(timer() - time_to_first_draw), "- Time to first draw") + + + def draw(self, canvas): + for cmd in self.display_list: + if cmd.top > self.scroll + HEIGHT - CHROME_PX: continue + if cmd.bottom < self.scroll: continue + cmd.execute(self.scroll - CHROME_PX, canvas) + + def request(url): + if not "://" in url: url = "http://" + url scheme,url = url.split("://", 1) assert scheme in ["http", "https"], \ "Unknown scheme {}".format(scheme) url_components = url.split("/", 1) host = url_components[0] path = "/" + (url_components[1] if len(url_components) > 1 else "") - s = socket.socket( family=socket.AF_INET, type=socket.SOCK_STREAM, @@ -542,6 +713,11 @@ def request(url): host, port = host.split(":", 1) port = int(port) + print("DEBUG:\n Scheme:", scheme, + "\n Port:", port, + "\n Host:", host, + "\n Path:", path) + s.connect((host, port)) s.send("GET {} HTTP/1.1\r\n".format(path).encode("utf8") + @@ -551,8 +727,6 @@ def request(url): response = s.makefile("r", encoding="utf8", newline="\r\n") statusline = response.readline() version, status, explanation = statusline.split(" ", 2) - assert status == "200", "{}: {}".format(status, explanation) - headers = {} while True: line = response.readline() @@ -563,6 +737,12 @@ def request(url): assert "transfer-encoding" not in headers assert "content-encoding" not in headers + if (status == "301" or status == "302") and "location" in headers: + s.close() + return request(headers["location"]) + else: + assert status == "200", "{}: {}".format(status, explanation) + body = response.read() s.close() @@ -641,11 +821,16 @@ def tree_to_list(tree, list): def resolve_url(url, current): + print("DEBUG:") if "://" in url: return url elif url.startswith("/"): scheme, hostpath = current.split("://", 1) - host, oldpath = hostpath.split("/",) + print(" Scheme:", scheme) + print(" Hostpath:", hostpath) + host, oldpath = hostpath.split("/", 1) + print(" Host:", host) + print(" Oldpath:", oldpath) return scheme + "://" + host + url else: dir, _ = current.rsplit("/", 1)