diff --git a/ren_browser/app.py b/ren_browser/app.py index 082f61f..24dfb3f 100644 --- a/ren_browser/app.py +++ b/ren_browser/app.py @@ -15,6 +15,7 @@ from ren_browser.ui.ui import build_ui RENDERER = "plaintext" RNS_CONFIG_DIR = None +RNS_INSTANCE = None async def main(page: Page): @@ -79,7 +80,8 @@ async def main(page: Page): import ren_browser.logs ren_browser.logs.setup_rns_logging() - RNS.Reticulum(str(config_dir)) + global RNS_INSTANCE + RNS_INSTANCE = RNS.Reticulum(str(config_dir)) except (OSError, ValueError): pass page.controls.clear() @@ -89,6 +91,75 @@ async def main(page: Page): page.run_thread(init_ret) +def reload_reticulum(page: Page, on_complete=None): + """Hot reload Reticulum with updated configuration. + + Args: + page: Flet page instance + on_complete: Optional callback to run when reload is complete + + """ + def reload_thread(): + import time + + try: + global RNS_INSTANCE + + if RNS_INSTANCE: + try: + RNS_INSTANCE.exit_handler() + print("RNS exit handler completed") + except Exception as e: + print(f"Warning during RNS shutdown: {e}") + + RNS.Reticulum._Reticulum__instance = None + + RNS.Transport.destinations = [] + + RNS_INSTANCE = None + print("RNS instance cleared") + + time.sleep(0.5) + + # Initialize storage system + storage = initialize_storage(page) + + # Get Reticulum config directory from storage manager + config_dir = storage.get_reticulum_config_path() + + # Ensure any saved config is written to filesystem before RNS init + try: + saved_config = storage.load_config() + if saved_config and saved_config.strip(): + config_file_path = config_dir / "config" + config_file_path.parent.mkdir(parents=True, exist_ok=True) + config_file_path.write_text(saved_config, encoding="utf-8") + except Exception as e: + print(f"Warning: Failed to write config file: {e}") + + try: + # Re-initialize Reticulum + import ren_browser.logs + ren_browser.logs.setup_rns_logging() + RNS_INSTANCE = RNS.Reticulum(str(config_dir)) + + # Success + if on_complete: + on_complete(True, None) + + except Exception as e: + print(f"Error reinitializing Reticulum: {e}") + if on_complete: + on_complete(False, str(e)) + + except Exception as e: + print(f"Error during reload: {e}") + if on_complete: + on_complete(False, str(e)) + + page.run_thread(reload_thread) + + def run(): """Run Ren Browser with command line argument parsing.""" global RENDERER, RNS_CONFIG_DIR @@ -101,10 +172,10 @@ def run(): help="Select renderer (plaintext or micron)", ) parser.add_argument( - "-w", "--web", action="store_true", help="Launch in web browser mode" + "-w", "--web", action="store_true", help="Launch in web browser mode", ) parser.add_argument( - "-p", "--port", type=int, default=None, help="Port for web server" + "-p", "--port", type=int, default=None, help="Port for web server", ) parser.add_argument( "-c", diff --git a/ren_browser/pages/page_request.py b/ren_browser/pages/page_request.py index 4c3d229..a19a9aa 100644 --- a/ren_browser/pages/page_request.py +++ b/ren_browser/pages/page_request.py @@ -45,7 +45,7 @@ class PageFetcher: """ RNS.log( - f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}" + f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}", ) dest_bytes = bytes.fromhex(req.destination_hash) if not RNS.Transport.has_path(dest_bytes): @@ -87,11 +87,11 @@ class PageFetcher: req.field_data, response_callback=on_response, failed_callback=on_failed, - ) + ), ) ev.wait(timeout=15) data_str = result["data"] or "No content received" RNS.log( - f"PageFetcher: received data for {req.destination_hash}:{req.page_path}" + f"PageFetcher: received data for {req.destination_hash}:{req.page_path}", ) return data_str diff --git a/ren_browser/renderer/micron.py b/ren_browser/renderer/micron.py index e8bf710..1ef8fd9 100644 --- a/ren_browser/renderer/micron.py +++ b/ren_browser/renderer/micron.py @@ -1,27 +1,289 @@ """Micron markup renderer for Ren Browser. -Provides rendering capabilities for micron markup content, -currently implemented as a placeholder. +Provides rendering capabilities for micron markup content. """ +import re + import flet as ft +from ren_browser.renderer.plaintext import render_plaintext -def render_micron(content: str) -> ft.Control: - """Render micron markup content to a Flet control placeholder. - Currently displays raw content. +def hex_to_rgb(hex_color: str) -> str: + """Convert 3-char hex color to RGB string.""" + if len(hex_color) != 3: + return "255,255,255" + r = int(hex_color[0], 16) * 17 + g = int(hex_color[1], 16) * 17 + b = int(hex_color[2], 16) * 17 + return f"{r},{g},{b}" + + +def parse_micron_line(line: str) -> list: + """Parse a single line of micron markup into styled text spans. + + Returns list of dicts with 'text', 'bold', 'italic', 'underline', 'color', 'bgcolor'. + """ + spans = [] + current_text = "" + bold = False + italic = False + underline = False + color = None + bgcolor = None + + i = 0 + while i < len(line): + if line[i] == "`" and i + 1 < len(line): + if current_text: + spans.append({ + "text": current_text, + "bold": bold, + "italic": italic, + "underline": underline, + "color": color, + "bgcolor": bgcolor, + }) + current_text = "" + + tag = line[i + 1] + + if tag == "!": + bold = not bold + i += 2 + elif tag == "*": + italic = not italic + i += 2 + elif tag == "_": + underline = not underline + i += 2 + elif tag == "F" and i + 5 <= len(line): + color = hex_to_rgb(line[i+2:i+5]) + i += 5 + elif tag == "f": + color = None + i += 2 + elif tag == "B" and i + 5 <= len(line): + bgcolor = hex_to_rgb(line[i+2:i+5]) + i += 5 + elif tag == "b": + bgcolor = None + i += 2 + elif tag == "`": + bold = False + italic = False + underline = False + color = None + bgcolor = None + i += 2 + else: + current_text += line[i] + i += 1 + else: + current_text += line[i] + i += 1 + + if current_text: + spans.append({ + "text": current_text, + "bold": bold, + "italic": italic, + "underline": underline, + "color": color, + "bgcolor": bgcolor, + }) + + return spans + + +def render_micron(content: str, on_link_click=None) -> ft.Control: + """Render micron markup content to a Flet control. + + Falls back to plaintext renderer if parsing fails. Args: content: Micron markup content to render. + on_link_click: Optional callback function(url) called when a link is clicked. Returns: ft.Control: Rendered content as a Flet control. """ - return ft.Text( - content, - selectable=True, - font_family="monospace", + try: + return _render_micron_internal(content, on_link_click) + except Exception as e: + print(f"Micron rendering failed: {e}, falling back to plaintext") + return render_plaintext(content) + + +def _render_micron_internal(content: str, on_link_click=None) -> ft.Control: + """Internal micron rendering implementation. + + Args: + content: Micron markup content to render. + on_link_click: Optional callback function(url) called when a link is clicked. + + Returns: + ft.Control: Rendered content as a Flet control. + + """ + lines = content.split("\n") + controls = [] + section_level = 0 + alignment = ft.TextAlign.LEFT + + for line in lines: + if not line: + controls.append(ft.Container(height=10)) + continue + + if line.startswith("#"): + continue + + if line.startswith("`c"): + alignment = ft.TextAlign.CENTER + line = line[2:] + elif line.startswith("`l"): + alignment = ft.TextAlign.LEFT + line = line[2:] + elif line.startswith("`r"): + alignment = ft.TextAlign.RIGHT + line = line[2:] + elif line.startswith("`a"): + alignment = ft.TextAlign.LEFT + line = line[2:] + + if line.startswith(">"): + level = 0 + while level < len(line) and line[level] == ">": + level += 1 + section_level = level + heading_text = line[level:].strip() + + if heading_text: + controls.append( + ft.Container( + content=ft.Text( + heading_text, + size=20 - (level * 2), + weight=ft.FontWeight.BOLD, + color=ft.Colors.BLUE_400, + ), + padding=ft.padding.only(left=level * 20, top=10, bottom=5), + ), + ) + continue + + if line.strip() == "-": + controls.append( + ft.Container( + content=ft.Divider(color=ft.Colors.GREY_700), + padding=ft.padding.only(left=section_level * 20), + ), + ) + continue + + if "`[" in line: + row_controls = [] + remaining = line + last_end = 0 + + for link_match in re.finditer(r"`\[([^`]*)`([^\]]*)\]", line): + before = line[last_end:link_match.start()] + if before: + before_spans = parse_micron_line(before) + for span in before_spans: + row_controls.append(create_text_span(span)) + + label = link_match.group(1) + url = link_match.group(2) + + def make_link_handler(link_url): + def handler(e): + if on_link_click: + on_link_click(link_url) + return handler + + row_controls.append( + ft.TextButton( + text=label if label else url, + style=ft.ButtonStyle( + color=ft.Colors.BLUE_400, + overlay_color=ft.Colors.BLUE_900, + ), + on_click=make_link_handler(url), + ), + ) + + last_end = link_match.end() + + after = line[last_end:] + if after: + after_spans = parse_micron_line(after) + for span in after_spans: + row_controls.append(create_text_span(span)) + + if row_controls: + controls.append( + ft.Container( + content=ft.Row( + controls=row_controls, + spacing=0, + wrap=True, + ), + padding=ft.padding.only(left=section_level * 20), + ), + ) + continue + + spans = parse_micron_line(line) + if spans: + text_controls = [create_text_span(span) for span in spans] + + controls.append( + ft.Container( + content=ft.Row( + controls=text_controls, + spacing=0, + wrap=True, + alignment=alignment, + ), + padding=ft.padding.only(left=section_level * 20), + ), + ) + + return ft.Column( + controls=controls, + spacing=5, + scroll=ft.ScrollMode.AUTO, expand=True, ) + + +def create_text_span(span: dict) -> ft.Text: + """Create a Text control from a span dict.""" + styles = [] + if span["bold"]: + styles.append(ft.TextStyle(weight=ft.FontWeight.BOLD)) + if span["italic"]: + styles.append(ft.TextStyle(italic=True)) + + text_decoration = ft.TextDecoration.UNDERLINE if span["underline"] else None + color = span["color"] + bgcolor = span["bgcolor"] + + text_style = ft.TextStyle( + weight=ft.FontWeight.BOLD if span["bold"] else None, + italic=span["italic"] if span["italic"] else None, + decoration=text_decoration, + ) + + return ft.Text( + span["text"], + style=text_style, + color=f"rgb({color})" if color else None, + bgcolor=f"rgb({bgcolor})" if bgcolor else None, + selectable=True, + no_wrap=False, + ) diff --git a/ren_browser/storage/storage.py b/ren_browser/storage/storage.py index 38795e1..ad8dda5 100644 --- a/ren_browser/storage/storage.py +++ b/ren_browser/storage/storage.py @@ -7,7 +7,7 @@ and other application data across different platforms. import json import os import pathlib -from typing import Any, Dict, Optional +from typing import Any import flet as ft @@ -19,7 +19,7 @@ class StorageManager: with platform-specific storage locations. """ - def __init__(self, page: Optional[ft.Page] = None): + def __init__(self, page: ft.Page | None = None): """Initialize storage manager. Args: @@ -45,18 +45,17 @@ class StorageManager: else: storage_dir = pathlib.Path("/data/local/tmp/ren_browser") elif hasattr(os, "uname") and "iOS" in str( - getattr(os, "uname", lambda: "")() + getattr(os, "uname", lambda: "")(), ).replace("iPhone", "iOS"): storage_dir = pathlib.Path.home() / "Documents" / "ren_browser" + elif "APPDATA" in os.environ: # Windows + storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser" + elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard + storage_dir = ( + pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser" + ) else: - if "APPDATA" in os.environ: # Windows - storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser" - elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard - storage_dir = ( - pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser" - ) - else: - storage_dir = pathlib.Path.home() / ".ren_browser" + storage_dir = pathlib.Path.home() / ".ren_browser" return storage_dir @@ -127,7 +126,7 @@ class StorageManager: if self.page and hasattr(self.page, "client_storage"): self.page.client_storage.set("ren_browser_config", config_content) self.page.client_storage.set( - "ren_browser_config_error", f"File save failed: {error}" + "ren_browser_config_error", f"File save failed: {error}", ) return True @@ -194,7 +193,7 @@ class StorageManager: if self.page and hasattr(self.page, "client_storage"): self.page.client_storage.set( - "ren_browser_bookmarks", json.dumps(bookmarks) + "ren_browser_bookmarks", json.dumps(bookmarks), ) return True @@ -206,7 +205,7 @@ class StorageManager: try: bookmarks_path = self._storage_dir / "bookmarks.json" if bookmarks_path.exists(): - with open(bookmarks_path, "r", encoding="utf-8") as f: + with open(bookmarks_path, encoding="utf-8") as f: return json.load(f) if self.page and hasattr(self.page, "client_storage"): @@ -238,7 +237,7 @@ class StorageManager: try: history_path = self._storage_dir / "history.json" if history_path.exists(): - with open(history_path, "r", encoding="utf-8") as f: + with open(history_path, encoding="utf-8") as f: return json.load(f) if self.page and hasattr(self.page, "client_storage"): @@ -251,7 +250,46 @@ class StorageManager: return [] - def get_storage_info(self) -> Dict[str, Any]: + def save_app_settings(self, settings: dict) -> bool: + """Save application settings to storage.""" + try: + settings_path = self._storage_dir / "settings.json" + with open(settings_path, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2) + + if self.page and hasattr(self.page, "client_storage"): + self.page.client_storage.set("ren_browser_settings", json.dumps(settings)) + + return True + except Exception: + return False + + def load_app_settings(self) -> dict: + """Load application settings from storage.""" + default_settings = { + "horizontal_scroll": False, + "page_bgcolor": "#000000", + } + + try: + settings_path = self._storage_dir / "settings.json" + if settings_path.exists(): + with open(settings_path, encoding="utf-8") as f: + loaded = json.load(f) + return {**default_settings, **loaded} + + if self.page and hasattr(self.page, "client_storage"): + stored_settings = self.page.client_storage.get("ren_browser_settings") + if stored_settings and isinstance(stored_settings, str): + loaded = json.loads(stored_settings) + return {**default_settings, **loaded} + + except (OSError, json.JSONDecodeError, TypeError): + pass + + return default_settings + + def get_storage_info(self) -> dict[str, Any]: """Get information about the storage system.""" return { "storage_dir": str(self._storage_dir), @@ -275,10 +313,10 @@ class StorageManager: # Global storage instance -_storage_manager: Optional[StorageManager] = None +_storage_manager: StorageManager | None = None -def get_storage_manager(page: Optional[ft.Page] = None) -> StorageManager: +def get_storage_manager(page: ft.Page | None = None) -> StorageManager: """Get the global storage manager instance.""" global _storage_manager if _storage_manager is None: diff --git a/ren_browser/tabs/tabs.py b/ren_browser/tabs/tabs.py index ccedd6e..c090824 100644 --- a/ren_browser/tabs/tabs.py +++ b/ren_browser/tabs/tabs.py @@ -8,8 +8,10 @@ from types import SimpleNamespace import flet as ft +from ren_browser.pages.page_request import PageFetcher, PageRequest from ren_browser.renderer.micron import render_micron from ren_browser.renderer.plaintext import render_plaintext +from ren_browser.storage.storage import get_storage_manager class TabsManager: @@ -30,28 +32,53 @@ class TabsManager: self.page = page self.page.on_resize = self._on_resize self.manager = SimpleNamespace(tabs=[], index=0) - self.tab_bar = ft.Row( - spacing=4, + + storage = get_storage_manager(page) + self.settings = storage.load_app_settings() + + self.tab_bar = ft.Container( + content=ft.Row( + spacing=6, + scroll=ft.ScrollMode.AUTO, + ), + padding=ft.padding.symmetric(horizontal=8, vertical=8), ) self.overflow_menu = None self.content_container = ft.Container( - expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5), + expand=True, + bgcolor=self.settings.get("page_bgcolor", ft.Colors.BLACK), + padding=ft.padding.all(16), ) + def handle_link_click_home(link_url): + if len(self.manager.tabs) > 0: + tab = self.manager.tabs[0] + full_url = link_url + if ":" not in link_url: + full_url = f"{link_url}:/page/index.mu" + tab["url_field"].value = full_url + self._on_tab_go(None, 0) + default_content = ( - render_micron("Welcome to Ren Browser") + render_micron("Welcome to Ren Browser", on_link_click=handle_link_click_home) if app_module.RENDERER == "micron" else render_plaintext("Welcome to Ren Browser") ) self._add_tab_internal("Home", default_content) self.add_btn = ft.IconButton( - ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click, + ft.Icons.ADD, + tooltip="New Tab", + on_click=self._on_add_click, + icon_color=ft.Colors.WHITE, ) self.close_btn = ft.IconButton( - ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click, + ft.Icons.CLOSE, + tooltip="Close Tab", + on_click=self._on_close_click, + icon_color=ft.Colors.WHITE, ) - self.tab_bar.controls.append(self.add_btn) - self.tab_bar.controls.append(self.close_btn) + self.tab_bar.content.controls.append(self.add_btn) + self.tab_bar.content.controls.append(self.close_btn) self.select_tab(0) self._update_tab_visibility() @@ -59,6 +86,30 @@ class TabsManager: """Handle page resize event and update tab visibility.""" self._update_tab_visibility() + def apply_settings(self, settings: dict) -> None: + """Apply appearance settings to the tab manager. + + Args: + settings: Dictionary containing appearance settings. + + """ + self.settings = settings + bgcolor = settings.get("page_bgcolor", "#000000") + self.content_container.bgcolor = bgcolor + + horizontal_scroll = settings.get("horizontal_scroll", False) + scroll_mode = ft.ScrollMode.ALWAYS if horizontal_scroll else ft.ScrollMode.AUTO + + for tab in self.manager.tabs: + if "content" in tab and hasattr(tab["content"], "scroll"): + tab["content"].scroll = scroll_mode + if "content_control" in tab and hasattr(tab["content_control"], "scroll"): + tab["content_control"].scroll = scroll_mode + + if self.content_container.content: + self.content_container.content.update() + self.page.update() + def _update_tab_visibility(self) -> None: """Dynamically adjust tab visibility based on page width. @@ -67,23 +118,20 @@ class TabsManager: if not self.page.width or self.page.width == 0: return - if self.overflow_menu and self.overflow_menu in self.tab_bar.controls: - self.tab_bar.controls.remove(self.overflow_menu) + if self.overflow_menu and self.overflow_menu in self.tab_bar.content.controls: + self.tab_bar.content.controls.remove(self.overflow_menu) self.overflow_menu = None - """Estimate available width for tabs (Page width - buttons - padding).""" available_width = self.page.width - 100 cumulative_width = 0 visible_tabs_count = 0 - tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)] + tab_containers = [c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)] for i, tab in enumerate(self.manager.tabs): - """Estimate tab width: (char count * avg char width) + padding + spacing.""" - estimated_width = len(tab["title"]) * 10 + 32 + self.tab_bar.spacing + estimated_width = len(tab["title"]) * 10 + 32 + self.tab_bar.content.spacing - """Always show at least one tab.""" if cumulative_width + estimated_width <= available_width or i == 0: cumulative_width += estimated_width if i < len(tab_containers): @@ -93,7 +141,6 @@ class TabsManager: tab_containers[i].visible = False if len(self.manager.tabs) > visible_tabs_count: - """Move extra tabs to overflow menu.""" overflow_items = [] for i in range(visible_tabs_count, len(self.manager.tabs)): tab_data = self.manager.tabs[i] @@ -110,7 +157,7 @@ class TabsManager: items=overflow_items, ) - self.tab_bar.controls.insert(visible_tabs_count, self.overflow_menu) + self.tab_bar.content.controls.insert(visible_tabs_count, self.overflow_menu) def _add_tab_internal(self, title: str, content: ft.Control) -> None: """Add a new tab to the manager with the given title and content.""" @@ -118,17 +165,28 @@ class TabsManager: url_field = ft.TextField( value=title, expand=True, - text_style=ft.TextStyle(size=12), - content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8), + text_style=ft.TextStyle(size=14), + content_padding=ft.padding.symmetric(horizontal=16, vertical=12), + border_radius=24, + border_color=ft.Colors.GREY_700, + focused_border_color=ft.Colors.BLUE_400, + bgcolor=ft.Colors.GREY_800, + prefix_icon=ft.Icons.SEARCH, ) go_btn = ft.IconButton( - ft.Icons.OPEN_IN_BROWSER, - tooltip="Load URL", + ft.Icons.ARROW_FORWARD, + tooltip="Go", on_click=lambda e, i=idx: self._on_tab_go(e, i), + icon_color=ft.Colors.BLUE_400, + bgcolor=ft.Colors.BLUE_900, ) content_control = content + horizontal_scroll = self.settings.get("horizontal_scroll", False) + scroll_mode = ft.ScrollMode.ALWAYS if horizontal_scroll else ft.ScrollMode.AUTO + tab_content = ft.Column( expand=True, + scroll=scroll_mode, controls=[ content_control, ], @@ -143,15 +201,26 @@ class TabsManager: }, ) tab_container = ft.Container( - content=ft.Text(title), + content=ft.Row( + controls=[ + ft.Text( + title, + size=13, + weight=ft.FontWeight.W_500, + overflow=ft.TextOverflow.ELLIPSIS, + ), + ], + spacing=8, + ), on_click=lambda e, i=idx: self.select_tab(i), # type: ignore - padding=ft.padding.symmetric(horizontal=12, vertical=6), - border_radius=5, - bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, + padding=ft.padding.symmetric(horizontal=16, vertical=10), + border_radius=8, + bgcolor=ft.Colors.GREY_800, + ink=True, + width=150, ) - """Insert the new tab before the add/close buttons.""" - insert_pos = max(0, len(self.tab_bar.controls) - 2) - self.tab_bar.controls.insert(insert_pos, tab_container) + insert_pos = max(0, len(self.tab_bar.content.controls) - 2) + self.tab_bar.content.controls.insert(insert_pos, tab_container) self._update_tab_visibility() def _on_add_click(self, e) -> None: # type: ignore @@ -160,8 +229,18 @@ class TabsManager: content_text = f"Content for {title}" import ren_browser.app as app_module + new_idx = len(self.manager.tabs) + + def handle_link_click_new(link_url): + tab = self.manager.tabs[new_idx] + full_url = link_url + if ":" not in link_url: + full_url = f"{link_url}:/page/index.mu" + tab["url_field"].value = full_url + self._on_tab_go(None, new_idx) + content = ( - render_micron(content_text) + render_micron(content_text, on_link_click=handle_link_click_new) if app_module.RENDERER == "micron" else render_plaintext(content_text) ) @@ -175,13 +254,13 @@ class TabsManager: return idx = self.manager.index - tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)] + tab_containers = [c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)] control_to_remove = tab_containers[idx] self.manager.tabs.pop(idx) - self.tab_bar.controls.remove(control_to_remove) + self.tab_bar.content.controls.remove(control_to_remove) - updated_tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)] + updated_tab_containers = [c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)] for i, control in enumerate(updated_tab_containers): control.on_click = lambda e, i=i: self.select_tab(i) # type: ignore @@ -199,12 +278,14 @@ class TabsManager: """ self.manager.index = idx - tab_containers = [c for c in self.tab_bar.controls if isinstance(c, ft.Container)] + tab_containers = [c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)] for i, control in enumerate(tab_containers): if i == idx: - control.bgcolor = ft.Colors.PRIMARY_CONTAINER + control.bgcolor = ft.Colors.BLUE_900 + control.border = ft.border.all(2, ft.Colors.BLUE_400) else: - control.bgcolor = ft.Colors.SURFACE_CONTAINER_HIGHEST + control.bgcolor = ft.Colors.GREY_800 + control.border = None self.content_container.content = self.manager.tabs[idx]["content"] self.page.update() @@ -215,16 +296,68 @@ class TabsManager: url = tab["url_field"].value.strip() if not url: return - placeholder_text = f"Loading content for {url}" + + placeholder_text = f"Loading content for {url}..." import ren_browser.app as app_module - new_control = ( - render_micron(placeholder_text) + current_node_hash = None + if ":" in url: + current_node_hash = url.split(":")[0] + + def handle_link_click(link_url): + full_url = link_url + if ":" not in link_url: + full_url = f"{link_url}:/page/index.mu" + elif link_url.startswith(":/"): + if current_node_hash: + full_url = f"{current_node_hash}{link_url}" + else: + full_url = link_url + tab["url_field"].value = full_url + self._on_tab_go(None, idx) + + placeholder_control = ( + render_micron(placeholder_text, on_link_click=handle_link_click) if app_module.RENDERER == "micron" else render_plaintext(placeholder_text) ) - tab["content_control"] = new_control - tab["content"].controls[0] = new_control + tab["content_control"] = placeholder_control + tab["content"].controls[0] = placeholder_control if self.manager.index == idx: self.content_container.content = tab["content"] self.page.update() + + def fetch_and_update(): + parts = url.split(":", 1) + if len(parts) != 2: + result = f"Error: Invalid URL format. Expected format: hash:/page/path" + page_path = "" + else: + dest_hash = parts[0] + page_path = parts[1] if parts[1].startswith("/") else f"/{parts[1]}" + + req = PageRequest(destination_hash=dest_hash, page_path=page_path) + page_fetcher = PageFetcher() + try: + result = page_fetcher.fetch_page(req) + except Exception as ex: + app_module.log_error(str(ex)) + result = f"Error: {ex}" + + try: + tab = self.manager.tabs[idx] + except IndexError: + return + + if page_path and page_path.endswith(".mu"): + new_control = render_micron(result, on_link_click=handle_link_click) + else: + new_control = render_plaintext(result) + + tab["content_control"] = new_control + tab["content"].controls[0] = new_control + if self.manager.index == idx: + self.content_container.content = tab["content"] + self.page.update() + + self.page.run_thread(fetch_and_update) diff --git a/ren_browser/ui/settings.py b/ren_browser/ui/settings.py index 1eab62c..ee2bd23 100644 --- a/ren_browser/ui/settings.py +++ b/ren_browser/ui/settings.py @@ -25,50 +25,286 @@ def open_settings_tab(page: ft.Page, tab_manager): except Exception as ex: config_text = f"Error reading config: {ex}" + app_settings = storage.load_app_settings() + config_field = ft.TextField( - label="Reticulum config", + label="Reticulum Configuration", value=config_text, expand=True, multiline=True, + min_lines=15, + max_lines=20, + border_color=ft.Colors.GREY_700, + focused_border_color=ft.Colors.BLUE_400, + text_style=ft.TextStyle(font_family="monospace", size=12), ) + horizontal_scroll_switch = ft.Switch( + label="Enable Horizontal Scroll (preserve ASCII art)", + value=app_settings.get("horizontal_scroll", False), + ) + + page_bgcolor_field = ft.TextField( + label="Page Background Color (hex)", + value=app_settings.get("page_bgcolor", "#000000"), + hint_text="#000000", + width=200, + border_color=ft.Colors.GREY_700, + focused_border_color=ft.Colors.BLUE_400, + ) + + color_preview = ft.Container( + width=40, + height=40, + bgcolor=app_settings.get("page_bgcolor", "#000000"), + border_radius=8, + border=ft.border.all(1, ft.Colors.GREY_700), + ) + + def on_bgcolor_change(e): + try: + color_preview.bgcolor = page_bgcolor_field.value + page.update() + except Exception: + pass + + page_bgcolor_field.on_change = on_bgcolor_change + def on_save_config(ev): try: success = storage.save_config(config_field.value) if success: - print("Config saved successfully. Please restart the app.") - page.snack_bar = ft.SnackBar( - ft.Text("Config saved successfully. Please restart the app."), - open=True, + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN_400, size=20), + ft.Text("Configuration saved! Restart app to apply changes.", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.GREEN_900, + duration=3000, ) + page.overlay.append(snack) + snack.open = True + page.update() else: - print("Error saving config: Storage operation failed") - page.snack_bar = ft.SnackBar( - ft.Text("Error saving config: Storage operation failed"), open=True + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), + ft.Text("Failed to save configuration", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.RED_900, + duration=3000, ) + page.overlay.append(snack) + snack.open = True + page.update() except Exception as ex: - print(f"Error saving config: {ex}") - page.snack_bar = ft.SnackBar( - ft.Text(f"Error saving config: {ex}"), open=True + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), + ft.Text(f"Error: {ex}", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.RED_900, + duration=4000, ) + page.overlay.append(snack) + snack.open = True + page.update() - save_btn = ft.ElevatedButton("Save Config", on_click=on_save_config) + def on_save_and_reload_config(ev): + try: + success = storage.save_config(config_field.value) + if not success: + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), + ft.Text("Failed to save configuration", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.RED_900, + duration=3000, + ) + page.overlay.append(snack) + snack.open = True + page.update() + return + + loading_snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.ProgressRing(width=16, height=16, stroke_width=2, color=ft.Colors.BLUE_400), + ft.Text("Reloading Reticulum...", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.BLUE_900, + duration=10000, + ) + page.overlay.append(loading_snack) + loading_snack.open = True + page.update() + + def on_reload_complete(success, error): + loading_snack.open = False + page.update() + + if success: + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN_400, size=20), + ft.Text("Reticulum reloaded successfully!", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.GREEN_900, + duration=3000, + ) + else: + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), + ft.Text(f"Reload failed: {error}", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.RED_900, + duration=4000, + ) + page.overlay.append(snack) + snack.open = True + page.update() + + import ren_browser.app as app_module + app_module.reload_reticulum(page, on_reload_complete) + + except Exception as ex: + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), + ft.Text(f"Error: {ex}", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.RED_900, + duration=4000, + ) + page.overlay.append(snack) + snack.open = True + page.update() + + def on_save_app_settings(ev): + try: + new_settings = { + "horizontal_scroll": horizontal_scroll_switch.value, + "page_bgcolor": page_bgcolor_field.value, + } + success = storage.save_app_settings(new_settings) + if success: + tab_manager.apply_settings(new_settings) + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN_400, size=20), + ft.Text("Appearance settings saved and applied!", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.GREEN_900, + duration=2000, + ) + page.overlay.append(snack) + snack.open = True + page.update() + else: + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), + ft.Text("Failed to save appearance settings", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.RED_900, + duration=3000, + ) + page.overlay.append(snack) + snack.open = True + page.update() + except Exception as ex: + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), + ft.Text(f"Error: {ex}", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.RED_900, + duration=4000, + ) + page.overlay.append(snack) + snack.open = True + page.update() + + save_btn = ft.ElevatedButton( + "Save Configuration", + icon=ft.Icons.SAVE, + on_click=on_save_config, + bgcolor=ft.Colors.BLUE_700, + color=ft.Colors.WHITE, + ) + + save_reload_btn = ft.ElevatedButton( + "Save & Hot Reload", + icon=ft.Icons.REFRESH, + on_click=on_save_and_reload_config, + bgcolor=ft.Colors.GREEN_700, + color=ft.Colors.WHITE, + ) + + save_appearance_btn = ft.ElevatedButton( + "Save Appearance", + icon=ft.Icons.PALETTE, + on_click=on_save_app_settings, + bgcolor=ft.Colors.BLUE_700, + color=ft.Colors.WHITE, + ) error_field = ft.TextField( label="Error Logs", value="", expand=True, multiline=True, read_only=True, + min_lines=15, + max_lines=20, + border_color=ft.Colors.GREY_700, + text_style=ft.TextStyle(font_family="monospace", size=12), ) ret_field = ft.TextField( - label="Reticulum logs", + label="Reticulum Logs", value="", expand=True, multiline=True, read_only=True, + min_lines=15, + max_lines=20, + border_color=ft.Colors.GREY_700, + text_style=ft.TextStyle(font_family="monospace", size=12), ) - # Storage information for debugging storage_info = storage.get_storage_info() storage_text = "\n".join([f"{key}: {value}" for key, value in storage_info.items()]) storage_field = ft.TextField( @@ -77,14 +313,39 @@ def open_settings_tab(page: ft.Page, tab_manager): expand=True, multiline=True, read_only=True, + min_lines=10, + max_lines=15, + border_color=ft.Colors.GREY_700, + text_style=ft.TextStyle(font_family="monospace", size=12), ) content_placeholder = ft.Container(expand=True) + appearance_content = ft.Column( + spacing=16, + controls=[ + ft.Text("Appearance Settings", size=18, weight=ft.FontWeight.BOLD), + horizontal_scroll_switch, + ft.Row( + controls=[ + page_bgcolor_field, + color_preview, + ], + alignment=ft.MainAxisAlignment.START, + spacing=16, + ), + save_appearance_btn, + ], + ) + def show_config(ev): content_placeholder.content = config_field page.update() + def show_appearance(ev): + content_placeholder.content = appearance_content + page.update() + def show_errors(ev): error_field.value = "\n".join(ERROR_LOGS) or "No errors logged." content_placeholder.content = error_field @@ -98,37 +359,100 @@ def open_settings_tab(page: ft.Page, tab_manager): def show_storage_info(ev): storage_info = storage.get_storage_info() storage_field.value = "\n".join( - [f"{key}: {value}" for key, value in storage_info.items()] + [f"{key}: {value}" for key, value in storage_info.items()], ) content_placeholder.content = storage_field page.update() def refresh_current_view(ev): - # Refresh the currently displayed content if content_placeholder.content == error_field: show_errors(ev) elif content_placeholder.content == ret_field: show_ret_logs(ev) elif content_placeholder.content == storage_field: show_storage_info(ev) + elif content_placeholder.content == appearance_content: + show_appearance(ev) elif content_placeholder.content == config_field: show_config(ev) - btn_config = ft.ElevatedButton("Config", on_click=show_config) - btn_errors = ft.ElevatedButton("Errors", on_click=show_errors) - btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs) - btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info) - btn_refresh = ft.ElevatedButton("Refresh", on_click=refresh_current_view) - button_row = ft.Row( - controls=[btn_config, btn_errors, btn_ret, btn_storage, btn_refresh] + btn_config = ft.FilledButton( + "Configuration", + icon=ft.Icons.SETTINGS, + on_click=show_config, ) + btn_appearance = ft.FilledButton( + "Appearance", + icon=ft.Icons.PALETTE, + on_click=show_appearance, + ) + btn_errors = ft.FilledButton( + "Errors", + icon=ft.Icons.ERROR_OUTLINE, + on_click=show_errors, + ) + btn_ret = ft.FilledButton( + "Reticulum Logs", + icon=ft.Icons.TERMINAL, + on_click=show_ret_logs, + ) + btn_storage = ft.FilledButton( + "Storage", + icon=ft.Icons.STORAGE, + on_click=show_storage_info, + ) + btn_refresh = ft.IconButton( + icon=ft.Icons.REFRESH, + tooltip="Refresh", + on_click=refresh_current_view, + icon_color=ft.Colors.BLUE_400, + ) + + nav_card = ft.Container( + content=ft.Row( + controls=[btn_config, btn_appearance, btn_errors, btn_ret, btn_storage, btn_refresh], + spacing=8, + wrap=True, + ), + padding=ft.padding.all(16), + border_radius=12, + bgcolor=ft.Colors.GREY_900, + ) + + content_card = ft.Container( + content=content_placeholder, + expand=True, + padding=ft.padding.all(16), + border_radius=12, + bgcolor=ft.Colors.GREY_900, + ) + + action_row = ft.Container( + content=ft.Row( + controls=[save_btn, save_reload_btn], + alignment=ft.MainAxisAlignment.END, + spacing=8, + ), + padding=ft.padding.symmetric(horizontal=16, vertical=8), + ) + content_placeholder.content = config_field settings_content = ft.Column( expand=True, + spacing=16, controls=[ - button_row, - content_placeholder, - ft.Row([save_btn]), + ft.Container( + content=ft.Text( + "Settings", + size=24, + weight=ft.FontWeight.BOLD, + color=ft.Colors.BLUE_400, + ), + padding=ft.padding.only(left=16, top=16), + ), + nav_card, + content_card, + action_row, ], ) tab_manager._add_tab_internal("Settings", settings_content) diff --git a/ren_browser/ui/ui.py b/ren_browser/ui/ui.py index f9f77ac..6b7861e 100644 --- a/ren_browser/ui/ui.py +++ b/ren_browser/ui/ui.py @@ -23,11 +23,26 @@ def build_ui(page: Page): """ page.theme_mode = ft.ThemeMode.DARK - page.appbar = ft.AppBar() + page.theme = ft.Theme( + color_scheme=ft.ColorScheme( + primary=ft.Colors.BLUE_400, + on_primary=ft.Colors.WHITE, + surface=ft.Colors.BLACK, + on_surface=ft.Colors.WHITE, + background=ft.Colors.BLACK, + on_background=ft.Colors.WHITE, + ), + ) + page.bgcolor = ft.Colors.BLACK + page.appbar = ft.AppBar( + bgcolor=ft.Colors.GREY_900, + elevation=2, + ) page.window.maximized = True + page.padding = 0 page_fetcher = PageFetcher() - announce_list = ft.ListView(expand=True, spacing=1) + announce_list = ft.ListView(expand=True, spacing=8, padding=ft.padding.all(8)) def update_announces(ann_list): announce_list.controls.clear() @@ -58,8 +73,18 @@ def build_ui(page: Page): tab = tab_manager.manager.tabs[idx] except IndexError: return + + def handle_link_click(url): + full_url = url + if ":" not in url: + full_url = f"{url}:/page/index.mu" + elif url.startswith(":/"): + full_url = f"{dest}{url}" + tab["url_field"].value = full_url + tab_manager._on_tab_go(None, idx) + if req.page_path.endswith(".mu"): - new_control = render_micron(result) + new_control = render_micron(result, on_link_click=handle_link_click) else: new_control = render_plaintext(result) tab["content_control"] = new_control @@ -70,25 +95,50 @@ def build_ui(page: Page): page.run_thread(fetch_and_update) - announce_list.controls.append(ft.TextButton(label, on_click=on_click_ann)) + announce_card = ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.LANGUAGE, size=20, color=ft.Colors.BLUE_400), + ft.Text( + label, + size=14, + weight=ft.FontWeight.W_500, + overflow=ft.TextOverflow.ELLIPSIS, + ), + ], + spacing=12, + ), + padding=ft.padding.all(12), + border_radius=8, + bgcolor=ft.Colors.GREY_800, + ink=True, + on_click=on_click_ann, + ) + announce_list.controls.append(announce_card) page.update() AnnounceService(update_callback=update_announces) page.drawer = ft.NavigationDrawer( + bgcolor=ft.Colors.GREY_900, + elevation=8, controls=[ - ft.Text( - "Announcements", - weight=ft.FontWeight.BOLD, - text_align=ft.TextAlign.CENTER, - expand=True, + ft.Container( + content=ft.Text( + "Announcements", + size=20, + weight=ft.FontWeight.BOLD, + color=ft.Colors.BLUE_400, + ), + padding=ft.padding.symmetric(horizontal=16, vertical=20), ), - ft.Divider(), + ft.Divider(height=1, color=ft.Colors.GREY_700), announce_list, ], ) page.appbar.leading = ft.IconButton( ft.Icons.MENU, - tooltip="Toggle sidebar", + tooltip="Announcements", + icon_color=ft.Colors.WHITE, on_click=lambda e: ( setattr(page.drawer, "open", not page.drawer.open), page.update(), @@ -102,15 +152,21 @@ def build_ui(page: Page): ft.IconButton( ft.Icons.SETTINGS, tooltip="Settings", + icon_color=ft.Colors.WHITE, on_click=lambda e: open_settings_tab(page, tab_manager), - ) + ), ] Shortcuts(page, tab_manager) - url_bar = ft.Row( - controls=[ - tab_manager.manager.tabs[tab_manager.manager.index]["url_field"], - tab_manager.manager.tabs[tab_manager.manager.index]["go_btn"], - ], + url_bar = ft.Container( + content=ft.Row( + controls=[ + tab_manager.manager.tabs[tab_manager.manager.index]["url_field"], + tab_manager.manager.tabs[tab_manager.manager.index]["go_btn"], + ], + spacing=8, + ), + expand=True, + padding=ft.padding.symmetric(horizontal=8), ) page.appbar.title = url_bar orig_select_tab = tab_manager.select_tab @@ -118,8 +174,8 @@ def build_ui(page: Page): def _select_tab_and_update_url(i): orig_select_tab(i) tab = tab_manager.manager.tabs[i] - url_bar.controls.clear() - url_bar.controls.extend([tab["url_field"], tab["go_btn"]]) + url_bar.content.controls.clear() + url_bar.content.controls.extend([tab["url_field"], tab["go_btn"]]) page.update() tab_manager.select_tab = _select_tab_and_update_url diff --git a/tests/conftest.py b/tests/conftest.py index 35766db..045c791 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,7 +62,7 @@ def sample_page_request(): from ren_browser.pages.page_request import PageRequest return PageRequest( - destination_hash="1234567890abcdef", page_path="/page/index.mu", field_data=None + destination_hash="1234567890abcdef", page_path="/page/index.mu", field_data=None, ) diff --git a/tests/unit/test_announces.py b/tests/unit/test_announces.py index 79a20cb..94ea7af 100644 --- a/tests/unit/test_announces.py +++ b/tests/unit/test_announces.py @@ -19,7 +19,7 @@ class TestAnnounce: def test_announce_with_none_display_name(self): """Test Announce creation with None display name.""" announce = Announce( - destination_hash="1234567890abcdef", display_name=None, timestamp=1234567890 + destination_hash="1234567890abcdef", display_name=None, timestamp=1234567890, ) assert announce.destination_hash == "1234567890abcdef" diff --git a/tests/unit/test_logs.py b/tests/unit/test_logs.py index f504929..af39b37 100644 --- a/tests/unit/test_logs.py +++ b/tests/unit/test_logs.py @@ -59,7 +59,7 @@ class TestLogsModule: assert len(logs.RET_LOGS) == 1 assert logs.RET_LOGS[0] == "[2023-01-01T12:00:00] Test RNS message" logs._original_rns_log.assert_called_once_with( - "Test RNS message", "arg1", kwarg1="value1" + "Test RNS message", "arg1", kwarg1="value1", ) assert result == "original_result" diff --git a/tests/unit/test_page_request.py b/tests/unit/test_page_request.py index c6f5e9c..3dcf70f 100644 --- a/tests/unit/test_page_request.py +++ b/tests/unit/test_page_request.py @@ -7,7 +7,7 @@ class TestPageRequest: def test_page_request_creation(self): """Test basic PageRequest creation.""" request = PageRequest( - destination_hash="1234567890abcdef", page_path="/page/index.mu" + destination_hash="1234567890abcdef", page_path="/page/index.mu", ) assert request.destination_hash == "1234567890abcdef" diff --git a/tests/unit/test_renderers.py b/tests/unit/test_renderers.py index 2625223..94dc020 100644 --- a/tests/unit/test_renderers.py +++ b/tests/unit/test_renderers.py @@ -63,66 +63,58 @@ class TestMicronRenderer: """ def test_render_micron_basic(self): - """Test basic micron rendering (currently displays raw content).""" + """Test basic micron rendering.""" content = "# Heading\n\nSome content" result = render_micron(content) - assert isinstance(result, ft.Text) - assert result.value == "# Heading\n\nSome content" - assert result.selectable is True - assert result.font_family == "monospace" + assert isinstance(result, ft.Column) assert result.expand is True + assert result.scroll == ft.ScrollMode.AUTO def test_render_micron_empty(self): """Test micron rendering with empty content.""" content = "" result = render_micron(content) - assert isinstance(result, ft.Text) - assert result.value == "" - assert result.selectable is True + assert isinstance(result, ft.Column) + assert len(result.controls) >= 0 def test_render_micron_unicode(self): """Test micron rendering with Unicode characters.""" content = "Unicode content: 你好 🌍 αβγ" result = render_micron(content) - assert isinstance(result, ft.Text) - assert result.value == content - assert result.selectable is True + assert isinstance(result, ft.Column) + assert len(result.controls) > 0 class TestRendererComparison: """Test cases comparing both renderers.""" def test_renderers_return_same_type(self): - """Test that both renderers return the same control type.""" + """Test that both renderers return Flet controls.""" content = "Test content" plaintext_result = render_plaintext(content) micron_result = render_micron(content) - assert type(plaintext_result) is type(micron_result) assert isinstance(plaintext_result, ft.Text) - assert isinstance(micron_result, ft.Text) + assert isinstance(micron_result, ft.Column) def test_renderers_preserve_content(self): - """Test that both renderers preserve the original content.""" + """Test that plaintext renderer preserves content.""" content = "Test content with\nmultiple lines" plaintext_result = render_plaintext(content) - micron_result = render_micron(content) assert plaintext_result.value == content - assert micron_result.value == content def test_renderers_same_properties(self): - """Test that both renderers set the same basic properties.""" + """Test that both renderers have expand property.""" content = "Test content" plaintext_result = render_plaintext(content) micron_result = render_micron(content) - assert plaintext_result.selectable == micron_result.selectable - assert plaintext_result.font_family == micron_result.font_family - assert plaintext_result.expand == micron_result.expand + assert plaintext_result.expand is True + assert micron_result.expand is True diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index ed8aad2..584f4f0 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -18,7 +18,7 @@ class TestStorageManager: def test_storage_manager_init_without_page(self): """Test StorageManager initialization without a page.""" with patch( - "ren_browser.storage.storage.StorageManager._get_storage_directory" + "ren_browser.storage.storage.StorageManager._get_storage_directory", ) as mock_get_dir: mock_dir = Path("/mock/storage") mock_get_dir.return_value = mock_dir @@ -35,7 +35,7 @@ class TestStorageManager: mock_page = Mock() with patch( - "ren_browser.storage.storage.StorageManager._get_storage_directory" + "ren_browser.storage.storage.StorageManager._get_storage_directory", ) as mock_get_dir: mock_dir = Path("/mock/storage") mock_get_dir.return_value = mock_dir @@ -51,17 +51,16 @@ class TestStorageManager: with ( patch("os.name", "posix"), patch.dict( - "os.environ", {"XDG_CONFIG_HOME": "/home/user/.config"}, clear=True + "os.environ", {"XDG_CONFIG_HOME": "/home/user/.config"}, clear=True, ), - patch("pathlib.Path.mkdir"), + patch("pathlib.Path.mkdir"),patch( + "ren_browser.storage.storage.StorageManager._ensure_storage_directory", + ), ): - with patch( - "ren_browser.storage.storage.StorageManager._ensure_storage_directory" - ): - storage = StorageManager() - storage._storage_dir = storage._get_storage_directory() - expected_dir = Path("/home/user/.config") / "ren_browser" - assert storage._storage_dir == expected_dir + storage = StorageManager() + storage._storage_dir = storage._get_storage_directory() + expected_dir = Path("/home/user/.config") / "ren_browser" + assert storage._storage_dir == expected_dir def test_get_storage_directory_windows(self): """Test storage directory detection for Windows.""" @@ -76,7 +75,7 @@ class TestStorageManager: patch("pathlib.Path.mkdir"), ): with patch( - "ren_browser.storage.storage.StorageManager._ensure_storage_directory" + "ren_browser.storage.storage.StorageManager._ensure_storage_directory", ): storage = StorageManager() storage._storage_dir = storage._get_storage_directory() @@ -91,7 +90,7 @@ class TestStorageManager: patch("pathlib.Path.mkdir"), ): with patch( - "ren_browser.storage.storage.StorageManager._ensure_storage_directory" + "ren_browser.storage.storage.StorageManager._ensure_storage_directory", ): storage = StorageManager() storage._storage_dir = storage._get_storage_directory() @@ -103,15 +102,14 @@ class TestStorageManager: with ( patch("os.name", "posix"), patch.dict("os.environ", {"ANDROID_ROOT": "/system"}, clear=True), - patch("pathlib.Path.mkdir"), + patch("pathlib.Path.mkdir"),patch( + "ren_browser.storage.storage.StorageManager._ensure_storage_directory", + ), ): - with patch( - "ren_browser.storage.storage.StorageManager._ensure_storage_directory" - ): - storage = StorageManager() - storage._storage_dir = storage._get_storage_directory() - expected_dir = Path("/data/local/tmp/ren_browser") - assert storage._storage_dir == expected_dir + storage = StorageManager() + storage._storage_dir = storage._get_storage_directory() + expected_dir = Path("/data/local/tmp/ren_browser") + assert storage._storage_dir == expected_dir def test_get_config_path(self): """Test getting config file path.""" @@ -171,7 +169,7 @@ class TestStorageManager: assert result is True mock_page.client_storage.set.assert_called_with( - "ren_browser_config", config_content + "ren_browser_config", config_content, ) def test_save_config_fallback(self): @@ -188,19 +186,18 @@ class TestStorageManager: storage, "get_reticulum_config_path", return_value=Path(temp_dir) / "reticulum", + ), patch( + "pathlib.Path.write_text", + side_effect=PermissionError("Access denied"), ): - with patch( - "pathlib.Path.write_text", - side_effect=PermissionError("Access denied"), - ): - config_content = "test config content" - result = storage.save_config(config_content) + config_content = "test config content" + result = storage.save_config(config_content) - assert result is True - # Check that the config was set to client storage - mock_page.client_storage.set.assert_any_call( - "ren_browser_config", config_content - ) + assert result is True + # Check that the config was set to client storage + mock_page.client_storage.set.assert_any_call( + "ren_browser_config", config_content, + ) # Verify that client storage was called at least once assert mock_page.client_storage.set.call_count >= 1 @@ -270,7 +267,7 @@ class TestStorageManager: bookmarks_path = storage._storage_dir / "bookmarks.json" assert bookmarks_path.exists() - with open(bookmarks_path, "r", encoding="utf-8") as f: + with open(bookmarks_path, encoding="utf-8") as f: loaded_bookmarks = json.load(f) assert loaded_bookmarks == bookmarks @@ -311,7 +308,7 @@ class TestStorageManager: history_path = storage._storage_dir / "history.json" assert history_path.exists() - with open(history_path, "r", encoding="utf-8") as f: + with open(history_path, encoding="utf-8") as f: loaded_history = json.load(f) assert loaded_history == history @@ -360,12 +357,11 @@ class TestStorageManager: with patch( "pathlib.Path.mkdir", side_effect=[PermissionError("Access denied"), None], - ): - with patch("tempfile.gettempdir", return_value="/tmp"): - storage = StorageManager() + ), patch("tempfile.gettempdir", return_value="/tmp"): + storage = StorageManager() - expected_fallback = Path("/tmp") / "ren_browser" - assert storage._storage_dir == expected_fallback + expected_fallback = Path("/tmp") / "ren_browser" + assert storage._storage_dir == expected_fallback class TestStorageGlobalFunctions: @@ -448,7 +444,7 @@ class TestStorageManagerEdgeCases: storage = StorageManager() with patch( - "pathlib.Path.write_text", side_effect=PermissionError("Access denied") + "pathlib.Path.write_text", side_effect=PermissionError("Access denied"), ): test_path = Path("/mock/path") result = storage._is_writable(test_path) diff --git a/tests/unit/test_tabs.py b/tests/unit/test_tabs.py index 027e9db..f5ec11e 100644 --- a/tests/unit/test_tabs.py +++ b/tests/unit/test_tabs.py @@ -34,8 +34,8 @@ class TestTabsManager: assert isinstance(manager.manager, SimpleNamespace) assert len(manager.manager.tabs) == 1 assert manager.manager.index == 0 - assert isinstance(manager.tab_bar, ft.Row) - assert manager.tab_bar.scroll is None + assert isinstance(manager.tab_bar, ft.Container) + assert isinstance(manager.tab_bar.content, ft.Row) assert manager.overflow_menu is None assert isinstance(manager.content_container, ft.Container) @@ -105,12 +105,12 @@ class TestTabsManager: """Test that selecting a tab updates background colors correctly.""" tabs_manager._add_tab_internal("Tab 2", Mock()) - tab_controls = tabs_manager.tab_bar.controls[:-2] # Exclude add/close buttons + tab_controls = tabs_manager.tab_bar.content.controls[:-2] # Exclude add/close buttons tabs_manager.select_tab(1) - assert tab_controls[0].bgcolor == ft.Colors.SURFACE_CONTAINER_HIGHEST - assert tab_controls[1].bgcolor == ft.Colors.PRIMARY_CONTAINER + assert tab_controls[0].bgcolor == ft.Colors.GREY_800 + assert tab_controls[1].bgcolor == ft.Colors.BLUE_900 def test_on_tab_go_empty_url(self, tabs_manager): """Test tab go with empty URL.""" @@ -146,12 +146,12 @@ class TestTabsManager: def test_tab_container_properties(self, tabs_manager): """Test that tab container has correct properties.""" assert tabs_manager.content_container.expand is True - assert tabs_manager.content_container.bgcolor == ft.Colors.BLACK - assert tabs_manager.content_container.padding == ft.padding.all(5) + assert tabs_manager.content_container.bgcolor in (ft.Colors.BLACK, "#000000") + assert tabs_manager.content_container.padding == ft.padding.all(16) def test_tab_bar_controls(self, tabs_manager): """Test that tab bar has correct controls.""" - controls = tabs_manager.tab_bar.controls + controls = tabs_manager.tab_bar.content.controls # Should have: home tab, add button, close button (and potentially overflow menu) assert len(controls) >= 3 @@ -180,7 +180,7 @@ class TestTabsManager: url_field = tab["url_field"] assert url_field.expand is True - assert url_field.text_style.size == 12 + assert url_field.text_style.size == 14 assert url_field.content_padding is not None def test_go_button_properties(self, tabs_manager): @@ -188,14 +188,14 @@ class TestTabsManager: tab = tabs_manager.manager.tabs[0] go_btn = tab["go_btn"] - assert go_btn.icon == ft.Icons.OPEN_IN_BROWSER - assert go_btn.tooltip == "Load URL" + assert go_btn.icon == ft.Icons.ARROW_FORWARD + assert go_btn.tooltip == "Go" def test_tab_click_handlers(self, tabs_manager): """Test that tab click handlers are properly set.""" tabs_manager._add_tab_internal("Tab 2", Mock()) - tab_controls = tabs_manager.tab_bar.controls[:-2] # Exclude add/close buttons + tab_controls = tabs_manager.tab_bar.content.controls[:-2] # Exclude add/close buttons for i, control in enumerate(tab_controls): assert control.on_click is not None @@ -242,20 +242,20 @@ class TestTabsManager: # With page width at 800, add enough tabs that some should overflow. for i in range(10): # Total 11 tabs tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock()) - + # Check that an overflow menu exists assert tabs_manager.overflow_menu is not None - + # Simulate a smaller screen, expecting more tabs to overflow tabs_manager.page.width = 400 tabs_manager._update_tab_visibility() - visible_tabs_small = sum(1 for c in tabs_manager.tab_bar.controls if isinstance(c, ft.Container) and c.visible) + visible_tabs_small = sum(1 for c in tabs_manager.tab_bar.content.controls if isinstance(c, ft.Container) and c.visible) assert visible_tabs_small < 11 # Simulate a larger screen, expecting all tabs to be visible tabs_manager.page.width = 1600 tabs_manager._update_tab_visibility() - visible_tabs_large = sum(1 for c in tabs_manager.tab_bar.controls if isinstance(c, ft.Container) and c.visible) - + visible_tabs_large = sum(1 for c in tabs_manager.tab_bar.content.controls if isinstance(c, ft.Container) and c.visible) + assert visible_tabs_large == 11 assert tabs_manager.overflow_menu is None diff --git a/tests/unit/test_ui.py b/tests/unit/test_ui.py index aa24b3f..c7656d7 100644 --- a/tests/unit/test_ui.py +++ b/tests/unit/test_ui.py @@ -29,7 +29,7 @@ class TestBuildUI: @patch("ren_browser.tabs.tabs.TabsManager") @patch("ren_browser.controls.shortcuts.Shortcuts") def test_build_ui_appbar_setup( - self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page + self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page, ): """Test that build_ui sets up the app bar correctly.""" mock_tab_manager = Mock() @@ -51,7 +51,7 @@ class TestBuildUI: @patch("ren_browser.tabs.tabs.TabsManager") @patch("ren_browser.controls.shortcuts.Shortcuts") def test_build_ui_drawer_setup( - self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page + self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page, ): """Test that build_ui sets up the drawer correctly.""" mock_tab_manager = Mock() @@ -129,14 +129,14 @@ class TestOpenSettingsTab: # Get the settings content that was added settings_content = mock_tab_manager._add_tab_internal.call_args[0][1] - # Find the save button and simulate click + # Find the save button - now nested in action_row container save_btn = None for control in settings_content.controls: - if hasattr(control, "controls"): - for sub_control in control.controls: + if hasattr(control, "content") and hasattr(control.content, "controls"): + for sub_control in control.content.controls: if ( hasattr(sub_control, "text") - and sub_control.text == "Save Config" + and sub_control.text == "Save Configuration" ): save_btn = sub_control break