diff --git a/.gitignore b/.gitignore index 519dc67..f194064 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,5 @@ test*.py logs/ logs settings.json -http_cache/ +html_cache/ +css_cache/ \ No newline at end of file diff --git a/http_client/connection.py b/http_client/connection.py index ece6c16..f2be151 100644 --- a/http_client/connection.py +++ b/http_client/connection.py @@ -2,6 +2,21 @@ import socket, logging, ssl, threading, os, ujson, time from http_client.html_parser import HTML, CSSParser, Element, tree_to_list, get_inline_styles +def resolve_url(scheme, host, port, path, url): + if "://" in url: return url + if not url.startswith("/"): + dir, _ = path.rsplit("/", 1) + while url.startswith("../"): + _, url = url.split("/", 1) + if "/" in dir: + dir, _ = dir.rsplit("/", 1) + url = f"{dir}/{url}" + + if url.startswith("//"): + return f"{scheme}:{url}" + else: + return f"{scheme}://{host}:{port}{url}" + class HTTPClient(): def __init__(self): self.scheme = "http" @@ -57,7 +72,7 @@ class HTTPClient(): self.request_headers["Host"] = self.host cache_filename = f"{self.scheme}_{self.host}_{self.port}_{self.path.replace('/', '_')}.json" - if os.path.exists(f"http_cache/{cache_filename}"): + if os.path.exists(f"html_cache/{cache_filename}"): threading.Thread(target=self.parse, daemon=True).start() return @@ -175,7 +190,7 @@ class HTTPClient(): def parse(self): self.css_rules = [] - cache_filename = f"{self.scheme}_{self.host}_{self.port}_{self.path.replace('/', '_')}.json" + html_cache_filename = f"{self.scheme}_{self.host}_{self.port}_{self.path.replace('/', '_')}.json" original_scheme = self.scheme original_host = self.host @@ -183,12 +198,12 @@ class HTTPClient(): original_path = self.path original_response = self.content_response - if cache_filename in os.listdir("http_cache"): - with open(f"http_cache/{cache_filename}", "r") as file: + if html_cache_filename in os.listdir("html_cache"): + with open(f"html_cache/{html_cache_filename}", "r") as file: self.nodes = HTML.from_json(ujson.load(file)) else: self.nodes = HTML(self.content_response).parse() - with open(f"http_cache/{cache_filename}", "w") as file: + with open(f"html_cache/{html_cache_filename}", "w") as file: json_list = HTML.to_json(self.nodes) file.write(ujson.dumps(json_list)) @@ -204,22 +219,22 @@ class HTTPClient(): for css_link in css_links: self.content_response = "" - if "://" in css_link: - self.get_request(css_link, self.request_headers, True) - - if not css_link.startswith("/"): - dir, _ = self.path.rsplit("/", 1) - css_link = dir + "/" + css_link + css_cache_filename = f"{self.scheme}_{self.host}_{self.port}_{self.path.replace('/', '_')}_{css_link.replace('/', '_')}.json" # we need to include the other variables so for example /styles.css wouldnt be cached for all websites - if css_link.startswith("//"): - self.get_request(self.scheme + ":" + css_link, self.request_headers, True) + if css_cache_filename in os.listdir("css_cache"): + with open(f"css_cache/{css_cache_filename}", "r") as file: + rules = CSSParser.from_json(ujson.load(file)) else: - self.get_request(self.scheme + "://" + self.host + ":" + str(self.port) + css_link, self.request_headers, True) - - while not self.content_response: - time.sleep(0.025) + self.get_request(resolve_url(self.scheme, self.host, self.port, self.path, css_link), self.request_headers, css=True) + while not self.content_response: + time.sleep(0.025) - self.css_rules.extend(CSSParser(self.content_response).parse()) + rules = CSSParser(self.content_response).parse() + + with open(f"css_cache/{css_cache_filename}", "w") as file: + ujson.dump(CSSParser.to_json(rules), file) + + self.css_rules.extend(rules) self.css_rules.extend(get_inline_styles(self.nodes)) diff --git a/http_client/html_parser.py b/http_client/html_parser.py index 902d5ba..e0c900c 100644 --- a/http_client/html_parser.py +++ b/http_client/html_parser.py @@ -281,6 +281,30 @@ class CSSParser: break return rules + @classmethod + def convert_selector_to_json(self, selector): + if isinstance(selector, TagSelector): + return ["tag", selector.tag, selector.priority] + elif isinstance(selector, DescendantSelector): + return ["descendant", self.convert_selector_to_json(selector.ancestor), self.convert_selector_to_json(selector.descendant)] + + @classmethod + def get_selector_from_json(self, selector_list): + if selector_list[0] == "tag": + selector = TagSelector(selector_list[1]) + selector.priority = selector_list[2] + return selector + elif selector_list[0] == "descendant": + return DescendantSelector(self.get_selector_from_json(selector_list[1]), self.get_selector_from_json(selector_list[2])) + + @classmethod + def to_json(self, rules_list: list[tuple[TagSelector | DescendantSelector, dict[str, str]]]): + return [[self.convert_selector_to_json(rule[0]), rule[1]] for rule in rules_list] + + @classmethod + def from_json(self, rules_list): + return [(self.get_selector_from_json(rule[0]), rule[1]) for rule in rules_list] + def style(node, rules): node.style = {} diff --git a/http_client/renderer.py b/http_client/renderer.py index 8710543..efe36ec 100644 --- a/http_client/renderer.py +++ b/http_client/renderer.py @@ -3,13 +3,28 @@ import arcade, pyglet from utils.constants import BLOCK_ELEMENTS, token_pattern, emoji_pattern from utils.utils import get_color_from_name, hex_to_rgb -from http_client.connection import HTTPClient -from http_client.html_parser import CSSParser, Text, Element, style, cascade_priority, replace_symbols +from http_client.connection import HTTPClient, resolve_url +from http_client.html_parser import CSSParser, Text, Element, style, cascade_priority, replace_symbols, tree_to_list + +from pyglet.font.base import Font as BaseFont + +from functools import lru_cache HSTEP = 13 VSTEP = 18 font_cache = {} + +def ensure_font(font_family, size, weight, style, emoji): + if not (font_family, size, weight, style, emoji) in font_cache: + font_cache[(font_family, size, weight, style, emoji)] = pyglet.font.load(font_family if pyglet.font.have_font(font_family) else "Arial", size, weight, style == "italic") if not emoji else pyglet.font.load("OpenMoji Color", size, weight, style == "italic") + + return font_cache[(font_family, size, weight, style, emoji)] + +@lru_cache +def get_space_width(font: BaseFont): + return font.get_text_size(" ")[0] + class DrawText: def __init__(self, x1, y1, text, font, color): self.top = y1 @@ -26,6 +41,75 @@ class DrawRect: self.height = height self.color = color +class LineLayout: + def __init__(self, node, parent, previous): + self.node = node + self.parent = parent + self.previous = previous + self.children = [] + + def paint(self): + return [] + + 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() + + if not self.children: + self.height = 0 + return + + fonts_on_line = [word.font for word in self.children] + max_ascent = max(font.ascent for font in fonts_on_line) + + baseline = self.y + 2 * max_ascent + + for word in self.children: + word.y = baseline - word.font.ascent + + max_descent = min(font.descent for font in fonts_on_line) + + self.height = 2 * (max_ascent + max_descent) + +class TextLayout(): + def __init__(self, node, word, emoji, parent, previous): + self.node = node + self.word = word + self.children = [] + self.parent = parent + self.previous = previous + self.emoji = emoji + + def paint(self): + color = get_color_from_name(self.node.style["color"]) + return [DrawText(self.x, self.y, self.word, self.font, color)] + + def layout(self): + weight = self.node.style["font-weight"] + style = self.node.style["font-style"] + font_family = self.node.style["font-family"] + style = "roman" if style == "normal" else style + size = int(float(self.node.style["font-size"][:-2])) + self.font = ensure_font(font_family, size, weight, style, self.emoji) + + self.width = self.font.get_text_size(self.word + (" " if not self.emoji else " "))[0] + + if self.previous: + space = get_space_width(self.previous.font) + self.x = self.previous.x + space + self.previous.width + else: + self.x = self.parent.x + + self.height = self.font.ascent + self.font.descent + class BlockLayout: def __init__(self, node, parent, previous): self.node = node @@ -33,8 +117,7 @@ class BlockLayout: self.previous = previous self.children = [] - self.display_list = [] - self.line = [] + self.cursor_x = 0 self.x, self.y, self.width, self.height = None, None, None, None @@ -46,17 +129,12 @@ class BlockLayout: rect = DrawRect(self.x, self.y, self.width, self.height, hex_to_rgb(bgcolor) if bgcolor.startswith("#") else get_color_from_name(bgcolor)) cmds.append(rect) - for x, y, word, font, color in self.display_list: - cmds.append(DrawText(x, y, word, font, color)) - return cmds def layout_mode(self): if isinstance(self.node, Text): return "inline" - elif any([isinstance(child, Element) and \ - child.tag in BLOCK_ELEMENTS - for child in self.node.children]): + elif any([isinstance(child, Element) and child.tag in BLOCK_ELEMENTS for child in self.node.children]): return "block" elif self.node.children: return "inline" @@ -80,26 +158,13 @@ class BlockLayout: self.children.append(next) 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 - - def ensure_font(self, font_family, size, weight, style, emoji): - if not (font_family, size, weight, style, emoji) in font_cache: - font_cache[(font_family, size, weight, style, emoji)] = pyglet.font.load(font_family, size, weight, style == "italic") if not emoji else pyglet.font.load("OpenMoji Color", size, weight, style == "italic") - - return font_cache[(font_family, size, weight, style, emoji)] + self.height = sum([child.height for child in self.children]) def recurse(self, node): if isinstance(node, Text): @@ -109,10 +174,10 @@ class BlockLayout: if emoji_pattern.fullmatch(word): self.word(self.node, word, emoji=True) else: - self.word(self.node, replace_symbols(word)) + self.word(self.node, word) else: if node.tag == "br": - self.flush() + self.new_line() for child in node.children: self.recurse(child) @@ -123,36 +188,28 @@ class BlockLayout: font_family = node.style["font-family"] style = "roman" if style == "normal" else style size = int(float(node.style["font-size"][:-2])) - color = get_color_from_name(node.style["color"]) - font = self.ensure_font(font_family, size, weight, style, emoji) + font = ensure_font(font_family, size, weight, style, emoji) w = font.get_text_size(word + (" " if not emoji else " "))[0] if self.cursor_x + w > self.width: - self.flush() + self.new_line() - self.line.append((self.cursor_x, word, font, color)) - self.cursor_x += w + font.get_text_size(" ")[0] - - def flush(self): - if not self.line: - return + line = self.children[-1] + previous_word = line.children[-1] if line.children else None + text = TextLayout(node, word, emoji, line, previous_word) + line.children.append(text) - fonts_on_line = [font for x, word, font, color in self.line] - max_ascent = max(font.ascent for font in fonts_on_line) - max_descent = min(font.descent for font in fonts_on_line) - - baseline = self.cursor_y + 2 * max_ascent - - for rel_x, word, font, color in self.line: - x = self.x + rel_x - y = self.y + baseline - font.ascent - self.display_list.append((x, y, word, font, color)) + self.cursor_x += w + get_space_width(font) + def new_line(self): self.cursor_x = 0 - self.line = [] - self.cursor_y = baseline + 2 * 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) class DocumentLayout: def __init__(self, node): @@ -169,7 +226,6 @@ class DocumentLayout: self.y = VSTEP child.layout() self.height = child.height - self.display_list = child.display_list def paint(self): return [] @@ -196,6 +252,7 @@ class Renderer(): self.text_to_create = [] self.window.on_mouse_scroll = self.on_mouse_scroll + self.window.on_mouse_press = self.on_mouse_press self.window.on_resize = self.on_resize self.batch = pyglet.graphics.Batch() @@ -222,11 +279,36 @@ class Renderer(): old_y = self.scroll_y self.scroll_y = max(0, min(abs(self.scroll_y - (scroll_y * self.scroll_y_speed)), abs(self.smallest_y) - (self.window.height * 0.925) + 5)) # flip scroll direction + for widget in self.widgets: widget.y += (self.scroll_y - old_y) self.hide_out_of_bounds_labels() + def on_mouse_press(self, x: int, y: int, button: int, modifiers: int): + y -= self.scroll_y + + objs = [ + obj for obj in tree_to_list(self.document, []) + if obj.x <= x < obj.x + obj.width + and ((self.window.height * 0.925) - obj.y - obj.height) <= y < ((self.window.height * 0.925) - obj.y) + ] + + 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(self.http_client.scheme, self.http_client.host, self.http_client.port, self.http_client.path, elt.attributes["href"]) + self.http_client.get_request(url, self.http_client.request_headers) + + elt = elt.parent + def add_text(self, x, y, text, font, color, multiline=False): self.widgets.append( pyglet.text.Label( diff --git a/run.py b/run.py index 2c3d446..86e2e6a 100644 --- a/run.py +++ b/run.py @@ -20,8 +20,11 @@ pyglet.font.add_directory('./assets/fonts') if not os.path.exists(log_dir): os.makedirs(log_dir) -if not os.path.exists("http_cache"): - os.makedirs("http_cache") +if not os.path.exists("html_cache"): + os.makedirs("html_cache") + +if not os.path.exists("css_cache"): + os.makedirs("css_cache") while len(os.listdir(log_dir)) >= 5: files = [(file, os.path.getctime(os.path.join(log_dir, file))) for file in os.listdir(log_dir)] diff --git a/utils/utils.py b/utils/utils.py index 269b66b..4d1671a 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -1,5 +1,7 @@ import logging, traceback +from functools import lru_cache + import pyglet.display, arcade def dump_platform(): @@ -64,19 +66,21 @@ class FakePyPresence(): def close(self, *args, **kwargs): ... +@lru_cache def hex_to_rgb(hex_color): hex_color = hex_color.lstrip('#') if len(hex_color) != 6: return (127, 127, 127) return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) +@lru_cache def get_color_from_name(rgb_name): rgb_name = rgb_name.upper() if rgb_name.startswith("LIGHT"): color_name = rgb_name.split("LIGHT")[1] color_value = arcade.csscolor.__dict__.get(f"LIGHT_{color_name}") if not color_value: - arcade.color.__dict__.get(f"LIGHT_{color_name}") + color_value = arcade.color.__dict__.get(f"LIGHT_{color_name}") if color_value: return color_value