From 31b67c9dfd9b30639765d2f6aa3b2d06cb58344d Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Sun, 27 Jul 2025 16:38:56 +0200 Subject: [PATCH] add tab support by having separate renderers and http clients, make link clicking open a new tab, add better default headers, fix style elements showing as text, fix crash if there are no needs but needs_render is True, fix view-source scheme not working, --- http_client/connection.py | 24 +++--- http_client/html_parser.py | 6 +- http_client/renderer.py | 76 ++++++------------- menus/main.py | 147 +++++++++++++++++++++++++++++-------- utils/constants.py | 8 ++ 5 files changed, 163 insertions(+), 98 deletions(-) diff --git a/http_client/connection.py b/http_client/connection.py index 36a8034..b1574a2 100644 --- a/http_client/connection.py +++ b/http_client/connection.py @@ -28,7 +28,7 @@ class HTTPClient(): self.response_headers = {} self.response_http_version = None self.response_status = None - self.nodes = [] + self.nodes = None self.css_rules = [] self.content_response = "" self.view_source = False @@ -71,7 +71,7 @@ class HTTPClient(): if "Host" not in self.request_headers: self.request_headers["Host"] = self.host - cache_filename = f"{self.scheme}_{self.host}_{self.port}_{self.path.replace('/', '_')}.json" + cache_filename = f"{self.scheme}_{self.host}_{self.port}_{self.path.replace('/', '_')}.html" if os.path.exists(f"html_cache/{cache_filename}"): threading.Thread(target=self.parse, daemon=True).start() return @@ -190,7 +190,14 @@ class HTTPClient(): def parse(self): self.css_rules = [] - html_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('/', '_')}.html" + + if html_cache_filename in os.listdir("html_cache"): + with open(f"html_cache/{html_cache_filename}", "r") as file: + self.content_response = file.read() + else: + with open(f"html_cache/{html_cache_filename}", "w") as file: + file.write(self.content_response) original_scheme = self.scheme original_host = self.host @@ -198,15 +205,8 @@ class HTTPClient(): original_path = self.path original_response = self.content_response - 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"html_cache/{html_cache_filename}", "w") as file: - json_list = HTML.to_json(self.nodes) - file.write(ujson.dumps(json_list)) - + self.nodes = HTML(self.content_response).parse() + css_links = [ node.attributes["href"] for node in tree_to_list(self.nodes, []) diff --git a/http_client/html_parser.py b/http_client/html_parser.py index 8053431..00c6456 100644 --- a/http_client/html_parser.py +++ b/http_client/html_parser.py @@ -36,7 +36,8 @@ class HTML(): for c in self.raw_html: if c == "<": in_tag = True - if text: self.add_text(text) # start of new tag means before everything was content/text + if (not self.unfinished or not self.unfinished[-1].tag == "style") and text: + self.add_text(text) # start of new tag means before everything was content/text text = "" elif c == ">": in_tag = False @@ -173,6 +174,9 @@ def get_inline_styles(node): for node in node.children: if isinstance(node, Element) and node.tag == "style": + if not node.children: + continue + if isinstance(node.children[0], Text): all_rules.extend(CSSParser(node.children[0].text).parse()) # node's first children will just be a text element that contains the css diff --git a/http_client/renderer.py b/http_client/renderer.py index a08ba92..c35472a 100644 --- a/http_client/renderer.py +++ b/http_client/renderer.py @@ -3,8 +3,8 @@ import arcade, pyglet, platform from utils.constants import BLOCK_ELEMENTS, token_pattern, emoji_pattern, INHERITED_PROPERTIES from utils.utils import get_color_from_name, hex_to_rgb -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 http_client.connection import HTTPClient +from http_client.html_parser import CSSParser, Text, Element, style, cascade_priority from pyglet.font.base import Font as BaseFont @@ -245,11 +245,11 @@ def paint_tree(layout_object, display_list): paint_tree(child, display_list) class Renderer(): - def __init__(self, http_client: HTTPClient, view_class): + def __init__(self, http_client: HTTPClient, window): self.content = '' self.request_scheme = 'http' - self.view_class = view_class - self.window: arcade.Window = view_class.window + self.window: arcade.Window = window + self.current_window_size = self.window.size self.http_client = http_client self.scroll_y = 0 @@ -261,10 +261,6 @@ class Renderer(): self.widgets: list[pyglet.text.Label] = [] 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() def hide_out_of_bounds_labels(self): @@ -279,8 +275,8 @@ class Renderer(): widget.visible = True def on_resize(self, width, height): - if self.http_client.css_rules: - self.http_client.needs_render = True + self.current_window_size = self.window.size + self.http_client.needs_render = True def on_mouse_scroll(self, x, y, scroll_x, scroll_y): if not self.allow_scroll: @@ -289,40 +285,11 @@ 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): - if not self.document: - return - - 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) - self.view_class.search_bar.text = url - - elt = elt.parent - def add_text(self, x, y, text, font, color, multiline=False): self.widgets.append( pyglet.text.Label( @@ -331,6 +298,7 @@ class Renderer(): italic=font.italic, weight=font.weight, font_size=font.size, + width=self.window.width * 0.5 if multiline else None, multiline=multiline, color=color, x=x, @@ -369,19 +337,21 @@ class Renderer(): self.smallest_y = 0 if self.http_client.view_source or self.http_client.scheme == "file": - self.add_text(x=HSTEP, y=0, text=self.http_client.content_response, font=pyglet.font.load("Roboto", 16), multiline=True) + self.add_text(x=HSTEP, y=self.window.height * 0.05, text=self.http_client.content_response, font=pyglet.font.load("Roboto", 16), color=arcade.color.BLACK, multiline=True) + elif self.http_client.scheme == "http" or self.http_client.scheme == "https": - style(self.http_client.nodes, sorted(self.http_client.css_rules + CSSParser(open("assets/css/browser.css").read()).parse(), key=cascade_priority)) + if self.http_client.nodes: + style(self.http_client.nodes, sorted(self.http_client.css_rules + CSSParser(open("assets/css/browser.css").read()).parse(), key=cascade_priority)) - self.document = DocumentLayout(self.http_client.nodes) - self.document.layout() - self.cmds = [] - paint_tree(self.document, self.cmds) - - for cmd in self.cmds: - if isinstance(cmd, DrawText): - self.add_text(cmd.left, cmd.top, cmd.text, cmd.font, cmd.color) - elif isinstance(cmd, DrawRect): - self.add_background(cmd.left, cmd.top, cmd.width, cmd.height, cmd.color) + self.document = DocumentLayout(self.http_client.nodes) + self.document.layout() + self.cmds = [] + paint_tree(self.document, self.cmds) + + for cmd in self.cmds: + if isinstance(cmd, DrawText): + self.add_text(cmd.left, cmd.top, cmd.text, cmd.font, cmd.color) + elif isinstance(cmd, DrawRect): + self.add_background(cmd.left, cmd.top, cmd.width, cmd.height, cmd.color) - self.hide_out_of_bounds_labels() \ No newline at end of file + self.hide_out_of_bounds_labels() \ No newline at end of file diff --git a/menus/main.py b/menus/main.py index dec236f..8d9c99a 100644 --- a/menus/main.py +++ b/menus/main.py @@ -1,11 +1,44 @@ import arcade, arcade.gui, asyncio, pypresence, time, copy, json, asyncio -from utils.constants import discord_presence_id +from utils.constants import discord_presence_id, DEFAULT_HEADERS from utils.utils import FakePyPresence -from http_client.connection import HTTPClient +from http_client.connection import HTTPClient, resolve_url +from http_client.html_parser import tree_to_list, Text from http_client.renderer import Renderer +class Tab(): + def __init__(self, url, window, tab_button, pypresence_client): + self.pypresence_client = pypresence_client + self.tab_button = tab_button + self.window = window + self.http_client = HTTPClient() + self.renderer = Renderer(self.http_client, window) + + self.request(url) + + def request(self, url): + if url.startswith("http://") or url.startswith("https://") or url.startswith("view-source:"): + self.http_client.get_request(url, DEFAULT_HEADERS) + elif url.startswith("file://"): + self.http_client.file_request(url) + elif url.startswith("data:text/html,"): + self.http_client.content_response = url.split("data:text/html,")[1] + self.http_client.scheme = "http" + elif url == "about:blank": + self.http_client.content_response = "" + self.http_client.scheme = "http" + elif url == "about:config" or url == "about:settings": + self.settings() + else: + self.http_client.get_request(f"https://{url}", DEFAULT_HEADERS) + + self.tab_button.text = url + + def settings(self): + from menus.settings import Settings + self.window.show_view(Settings(self.pypresence_client)) + class Main(arcade.gui.UIView): def __init__(self, pypresence_client=None): super().__init__() @@ -47,17 +80,65 @@ class Main(arcade.gui.UIView): self.pypresence_client.update(state='Browsing', details='In the browser', start=self.pypresence_client.start_time) - self.http_client = HTTPClient() - - def on_resize(self, width, height): - self.ui.clear() - self.on_show_view() + self.tabs: list[Tab] = [] + self.tab_buttons = [] + self.active_tab = None def on_show_view(self): super().on_show_view() - self.search_bar = self.add_widget(arcade.gui.UIInputText(x=self.window.width / 4, y=self.window.height * 0.95, width=self.window.width / 2, height=self.window.height * 0.035, font_name="Roboto", font_size=14, text_color=arcade.color.BLACK, caret_color=arcade.color.BLACK, border_color=arcade.color.BLACK)) - self.renderer = Renderer(self.http_client, self) + self.anchor = self.add_widget(arcade.gui.UIAnchorLayout(size_hint=(1, 1))) + self.navigation_box = self.anchor.add(arcade.gui.UIBoxLayout(space_between=10), anchor_x="center", anchor_y="top") + + self.tab_box = self.navigation_box.add(arcade.gui.UIBoxLayout(space_between=5, vertical=False)) + + self.new_tab_button = self.tab_box.add(arcade.gui.UIFlatButton(text="+", width=self.window.width / 25, height=30, style=arcade.gui.UIFlatButton.DEFAULT_STYLE)) + self.new_tab_button.on_click = lambda event: self.new_tab() + + self.tab_buttons.append(self.tab_box.add(arcade.gui.UIFlatButton(text="about:blank", width=self.window.width / 7, height=30, style=arcade.gui.UIFlatButton.STYLE_BLUE))) + + self.search_bar = self.navigation_box.add(arcade.gui.UIInputText(width=self.window.width * 0.5, height=30, font_name="Roboto", font_size=14, text_color=arcade.color.BLACK, caret_color=arcade.color.BLACK, border_color=arcade.color.BLACK)) + + default_tab = Tab("about:blank", self.window, self.tab_buttons[0], self.pypresence_client) + self.tabs.append(default_tab) + self.tab_buttons[-1].on_click = lambda event, tab=self.tabs[0]: self.switch_to_tab(tab) + + self.switch_to_tab(default_tab) + + def search(self): + url = self.search_bar.text + + self.active_tab.request(url) + + def switch_to_tab(self, tab): + if self.active_tab: + self.active_tab.tab_button.style = arcade.gui.UIFlatButton.DEFAULT_STYLE + + self.active_tab = tab + self.active_tab.tab_button.style = arcade.gui.UIFlatButton.STYLE_BLUE + + if self.active_tab.renderer.current_window_size != self.window.size: + self.active_tab.renderer.on_resize(self.window.width, self.window.height) + + self.window.on_mouse_scroll = self.active_tab.renderer.on_mouse_scroll + + http_client = self.active_tab.http_client + if http_client.scheme and http_client.host and http_client.path: + port_str = f':{http_client.port}' if not http_client.port in [80, 443, 0] else '' + self.search_bar.text = f"{http_client.scheme}://{http_client.host}{port_str}{http_client.path}" + + def new_tab(self, url="about:blank"): + self.tab_buttons.append(self.tab_box.add(arcade.gui.UIFlatButton(text=url, width=self.window.width / 7, height=30, style=arcade.gui.UIFlatButton.STYLE_BLUE))) + self.tabs.append(Tab(url, self.window, self.tab_buttons[-1], self.pypresence_client)) + self.tab_buttons[-1].on_click = lambda event, tab=self.tabs[-1]: self.switch_to_tab(tab) + + self.switch_to_tab(self.tabs[-1]) + + def on_resize(self, width, height): + for tab_button in self.tab_buttons: + tab_button.rect = tab_button.rect.resize(width / 7, 30) + + self.active_tab.renderer.on_resize(width, height) def on_key_press(self, symbol, modifiers): self.search_bar.text = self.search_bar.text.encode("ascii", "ignore").decode().strip("\n") @@ -65,34 +146,36 @@ class Main(arcade.gui.UIView): self.search() def on_update(self, delta_time): - self.renderer.update() + self.active_tab.renderer.update() - def search(self): - url = self.search_bar.text + def on_mouse_press(self, x: int, y: int, button: int, modifiers: int): + if not self.active_tab.renderer.document: + return + + y -= self.active_tab.renderer.scroll_y + + objs = [ + obj for obj in tree_to_list(self.active_tab.renderer.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 url.startswith("http://") or url.startswith("https://") or url.startswith("view-source:"): - self.http_client.get_request(url, {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"}) - elif url.startswith("file://"): - self.http_client.file_request(url) - elif url.startswith("data:text/html,"): - self.http_client.content_response = url.split("data:text/html,")[1] - self.http_client.scheme = "http" - elif url == "about:blank": - self.http_client.content_response = "" - self.http_client.scheme = "http" - elif url == "about:config" or url == "about:settings": - self.settings() - else: - self.http_client.get_request(f"https://{url}", {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"}) - self.search_bar.text = f"https://{url}" + if not objs: + return + + elt = objs[-1].node - self.search_bar.text = self.search_bar.text.encode("ascii", "ignore").decode().strip("\n") + while elt: + if isinstance(elt, Text): + pass + elif elt.tag == "a" and "href" in elt.attributes: + url = resolve_url(self.active_tab.http_client.scheme, self.active_tab.http_client.host, self.active_tab.http_client.port, self.active_tab.http_client.path, elt.attributes["href"]) + self.new_tab(url) + return - def settings(self): - from menus.settings import Settings - self.window.show_view(Settings(self.pypresence_client)) + elt = elt.parent def on_draw(self): super().on_draw() - self.renderer.batch.draw() \ No newline at end of file + self.active_tab.renderer.batch.draw() \ No newline at end of file diff --git a/utils/constants.py b/utils/constants.py index 2eddac0..6fba706 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -62,6 +62,14 @@ INHERITED_PROPERTIES = { "height": "auto" } +DEFAULT_HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Accept": "text/html", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none" +} + button_style = {'normal': UITextureButtonStyle(font_name="Roboto", font_color=arcade.color.BLACK), 'hover': UITextureButtonStyle(font_name="Roboto", font_color=arcade.color.BLACK), 'press': UITextureButtonStyle(font_name="Roboto", font_color=arcade.color.BLACK), 'disabled': UITextureButtonStyle(font_name="Roboto", font_color=arcade.color.BLACK)} big_button_style = {'normal': UITextureButtonStyle(font_name="Roboto", font_color=arcade.color.BLACK, font_size=26), 'hover': UITextureButtonStyle(font_name="Roboto", font_color=arcade.color.BLACK, font_size=26),