1. Add basic Micron parser and link support
2. Improve styling/layout
3. Add hot reloading for RNS
This commit is contained in:
2025-11-16 00:34:51 -06:00
parent e36bfec4a0
commit 3cddaeb2b9
15 changed files with 1079 additions and 207 deletions

View File

@@ -15,6 +15,7 @@ from ren_browser.ui.ui import build_ui
RENDERER = "plaintext" RENDERER = "plaintext"
RNS_CONFIG_DIR = None RNS_CONFIG_DIR = None
RNS_INSTANCE = None
async def main(page: Page): async def main(page: Page):
@@ -79,7 +80,8 @@ async def main(page: Page):
import ren_browser.logs import ren_browser.logs
ren_browser.logs.setup_rns_logging() ren_browser.logs.setup_rns_logging()
RNS.Reticulum(str(config_dir)) global RNS_INSTANCE
RNS_INSTANCE = RNS.Reticulum(str(config_dir))
except (OSError, ValueError): except (OSError, ValueError):
pass pass
page.controls.clear() page.controls.clear()
@@ -89,6 +91,75 @@ async def main(page: Page):
page.run_thread(init_ret) 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(): def run():
"""Run Ren Browser with command line argument parsing.""" """Run Ren Browser with command line argument parsing."""
global RENDERER, RNS_CONFIG_DIR global RENDERER, RNS_CONFIG_DIR
@@ -101,10 +172,10 @@ def run():
help="Select renderer (plaintext or micron)", help="Select renderer (plaintext or micron)",
) )
parser.add_argument( 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( 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( parser.add_argument(
"-c", "-c",

View File

@@ -45,7 +45,7 @@ class PageFetcher:
""" """
RNS.log( 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) dest_bytes = bytes.fromhex(req.destination_hash)
if not RNS.Transport.has_path(dest_bytes): if not RNS.Transport.has_path(dest_bytes):
@@ -87,11 +87,11 @@ class PageFetcher:
req.field_data, req.field_data,
response_callback=on_response, response_callback=on_response,
failed_callback=on_failed, failed_callback=on_failed,
) ),
) )
ev.wait(timeout=15) ev.wait(timeout=15)
data_str = result["data"] or "No content received" data_str = result["data"] or "No content received"
RNS.log( 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 return data_str

View File

@@ -1,27 +1,289 @@
"""Micron markup renderer for Ren Browser. """Micron markup renderer for Ren Browser.
Provides rendering capabilities for micron markup content, Provides rendering capabilities for micron markup content.
currently implemented as a placeholder.
""" """
import re
import flet as ft 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: Args:
content: Micron markup content to render. content: Micron markup content to render.
on_link_click: Optional callback function(url) called when a link is clicked.
Returns: Returns:
ft.Control: Rendered content as a Flet control. ft.Control: Rendered content as a Flet control.
""" """
return ft.Text( try:
content, return _render_micron_internal(content, on_link_click)
selectable=True, except Exception as e:
font_family="monospace", 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, 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,
)

View File

@@ -7,7 +7,7 @@ and other application data across different platforms.
import json import json
import os import os
import pathlib import pathlib
from typing import Any, Dict, Optional from typing import Any
import flet as ft import flet as ft
@@ -19,7 +19,7 @@ class StorageManager:
with platform-specific storage locations. with platform-specific storage locations.
""" """
def __init__(self, page: Optional[ft.Page] = None): def __init__(self, page: ft.Page | None = None):
"""Initialize storage manager. """Initialize storage manager.
Args: Args:
@@ -45,11 +45,10 @@ class StorageManager:
else: else:
storage_dir = pathlib.Path("/data/local/tmp/ren_browser") storage_dir = pathlib.Path("/data/local/tmp/ren_browser")
elif hasattr(os, "uname") and "iOS" in str( elif hasattr(os, "uname") and "iOS" in str(
getattr(os, "uname", lambda: "")() getattr(os, "uname", lambda: "")(),
).replace("iPhone", "iOS"): ).replace("iPhone", "iOS"):
storage_dir = pathlib.Path.home() / "Documents" / "ren_browser" storage_dir = pathlib.Path.home() / "Documents" / "ren_browser"
else: elif "APPDATA" in os.environ: # Windows
if "APPDATA" in os.environ: # Windows
storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser" storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser"
elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard
storage_dir = ( storage_dir = (
@@ -127,7 +126,7 @@ class StorageManager:
if self.page and hasattr(self.page, "client_storage"): 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", config_content)
self.page.client_storage.set( 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 return True
@@ -194,7 +193,7 @@ class StorageManager:
if self.page and hasattr(self.page, "client_storage"): if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set( self.page.client_storage.set(
"ren_browser_bookmarks", json.dumps(bookmarks) "ren_browser_bookmarks", json.dumps(bookmarks),
) )
return True return True
@@ -206,7 +205,7 @@ class StorageManager:
try: try:
bookmarks_path = self._storage_dir / "bookmarks.json" bookmarks_path = self._storage_dir / "bookmarks.json"
if bookmarks_path.exists(): 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) return json.load(f)
if self.page and hasattr(self.page, "client_storage"): if self.page and hasattr(self.page, "client_storage"):
@@ -238,7 +237,7 @@ class StorageManager:
try: try:
history_path = self._storage_dir / "history.json" history_path = self._storage_dir / "history.json"
if history_path.exists(): 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) return json.load(f)
if self.page and hasattr(self.page, "client_storage"): if self.page and hasattr(self.page, "client_storage"):
@@ -251,7 +250,46 @@ class StorageManager:
return [] 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.""" """Get information about the storage system."""
return { return {
"storage_dir": str(self._storage_dir), "storage_dir": str(self._storage_dir),
@@ -275,10 +313,10 @@ class StorageManager:
# Global storage instance # 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.""" """Get the global storage manager instance."""
global _storage_manager global _storage_manager
if _storage_manager is None: if _storage_manager is None:

View File

@@ -8,8 +8,10 @@ from types import SimpleNamespace
import flet as ft 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.micron import render_micron
from ren_browser.renderer.plaintext import render_plaintext from ren_browser.renderer.plaintext import render_plaintext
from ren_browser.storage.storage import get_storage_manager
class TabsManager: class TabsManager:
@@ -30,28 +32,53 @@ class TabsManager:
self.page = page self.page = page
self.page.on_resize = self._on_resize self.page.on_resize = self._on_resize
self.manager = SimpleNamespace(tabs=[], index=0) 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.overflow_menu = None
self.content_container = ft.Container( 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 = ( 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" if app_module.RENDERER == "micron"
else render_plaintext("Welcome to Ren Browser") else render_plaintext("Welcome to Ren Browser")
) )
self._add_tab_internal("Home", default_content) self._add_tab_internal("Home", default_content)
self.add_btn = ft.IconButton( 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( 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.content.controls.append(self.add_btn)
self.tab_bar.controls.append(self.close_btn) self.tab_bar.content.controls.append(self.close_btn)
self.select_tab(0) self.select_tab(0)
self._update_tab_visibility() self._update_tab_visibility()
@@ -59,6 +86,30 @@ class TabsManager:
"""Handle page resize event and update tab visibility.""" """Handle page resize event and update tab visibility."""
self._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: def _update_tab_visibility(self) -> None:
"""Dynamically adjust tab visibility based on page width. """Dynamically adjust tab visibility based on page width.
@@ -67,23 +118,20 @@ class TabsManager:
if not self.page.width or self.page.width == 0: if not self.page.width or self.page.width == 0:
return return
if self.overflow_menu and self.overflow_menu in self.tab_bar.controls: if self.overflow_menu and self.overflow_menu in self.tab_bar.content.controls:
self.tab_bar.controls.remove(self.overflow_menu) self.tab_bar.content.controls.remove(self.overflow_menu)
self.overflow_menu = None self.overflow_menu = None
"""Estimate available width for tabs (Page width - buttons - padding)."""
available_width = self.page.width - 100 available_width = self.page.width - 100
cumulative_width = 0 cumulative_width = 0
visible_tabs_count = 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): 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.content.spacing
estimated_width = len(tab["title"]) * 10 + 32 + self.tab_bar.spacing
"""Always show at least one tab."""
if cumulative_width + estimated_width <= available_width or i == 0: if cumulative_width + estimated_width <= available_width or i == 0:
cumulative_width += estimated_width cumulative_width += estimated_width
if i < len(tab_containers): if i < len(tab_containers):
@@ -93,7 +141,6 @@ class TabsManager:
tab_containers[i].visible = False tab_containers[i].visible = False
if len(self.manager.tabs) > visible_tabs_count: if len(self.manager.tabs) > visible_tabs_count:
"""Move extra tabs to overflow menu."""
overflow_items = [] overflow_items = []
for i in range(visible_tabs_count, len(self.manager.tabs)): for i in range(visible_tabs_count, len(self.manager.tabs)):
tab_data = self.manager.tabs[i] tab_data = self.manager.tabs[i]
@@ -110,7 +157,7 @@ class TabsManager:
items=overflow_items, 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: def _add_tab_internal(self, title: str, content: ft.Control) -> None:
"""Add a new tab to the manager with the given title and content.""" """Add a new tab to the manager with the given title and content."""
@@ -118,17 +165,28 @@ class TabsManager:
url_field = ft.TextField( url_field = ft.TextField(
value=title, value=title,
expand=True, expand=True,
text_style=ft.TextStyle(size=12), text_style=ft.TextStyle(size=14),
content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8), 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( go_btn = ft.IconButton(
ft.Icons.OPEN_IN_BROWSER, ft.Icons.ARROW_FORWARD,
tooltip="Load URL", tooltip="Go",
on_click=lambda e, i=idx: self._on_tab_go(e, i), 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 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( tab_content = ft.Column(
expand=True, expand=True,
scroll=scroll_mode,
controls=[ controls=[
content_control, content_control,
], ],
@@ -143,15 +201,26 @@ class TabsManager:
}, },
) )
tab_container = ft.Container( 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 on_click=lambda e, i=idx: self.select_tab(i), # type: ignore
padding=ft.padding.symmetric(horizontal=12, vertical=6), padding=ft.padding.symmetric(horizontal=16, vertical=10),
border_radius=5, border_radius=8,
bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, 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.content.controls) - 2)
insert_pos = max(0, len(self.tab_bar.controls) - 2) self.tab_bar.content.controls.insert(insert_pos, tab_container)
self.tab_bar.controls.insert(insert_pos, tab_container)
self._update_tab_visibility() self._update_tab_visibility()
def _on_add_click(self, e) -> None: # type: ignore def _on_add_click(self, e) -> None: # type: ignore
@@ -160,8 +229,18 @@ class TabsManager:
content_text = f"Content for {title}" content_text = f"Content for {title}"
import ren_browser.app as app_module 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 = ( content = (
render_micron(content_text) render_micron(content_text, on_link_click=handle_link_click_new)
if app_module.RENDERER == "micron" if app_module.RENDERER == "micron"
else render_plaintext(content_text) else render_plaintext(content_text)
) )
@@ -175,13 +254,13 @@ class TabsManager:
return return
idx = self.manager.index 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] control_to_remove = tab_containers[idx]
self.manager.tabs.pop(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): for i, control in enumerate(updated_tab_containers):
control.on_click = lambda e, i=i: self.select_tab(i) # type: ignore control.on_click = lambda e, i=i: self.select_tab(i) # type: ignore
@@ -199,12 +278,14 @@ class TabsManager:
""" """
self.manager.index = idx 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): for i, control in enumerate(tab_containers):
if i == idx: 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: 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.content_container.content = self.manager.tabs[idx]["content"]
self.page.update() self.page.update()
@@ -215,16 +296,68 @@ class TabsManager:
url = tab["url_field"].value.strip() url = tab["url_field"].value.strip()
if not url: if not url:
return return
placeholder_text = f"Loading content for {url}"
placeholder_text = f"Loading content for {url}..."
import ren_browser.app as app_module import ren_browser.app as app_module
new_control = ( current_node_hash = None
render_micron(placeholder_text) 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" if app_module.RENDERER == "micron"
else render_plaintext(placeholder_text) else render_plaintext(placeholder_text)
) )
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_control"] = new_control
tab["content"].controls[0] = new_control tab["content"].controls[0] = new_control
if self.manager.index == idx: if self.manager.index == idx:
self.content_container.content = tab["content"] self.content_container.content = tab["content"]
self.page.update() self.page.update()
self.page.run_thread(fetch_and_update)

View File

@@ -25,50 +25,286 @@ def open_settings_tab(page: ft.Page, tab_manager):
except Exception as ex: except Exception as ex:
config_text = f"Error reading config: {ex}" config_text = f"Error reading config: {ex}"
app_settings = storage.load_app_settings()
config_field = ft.TextField( config_field = ft.TextField(
label="Reticulum config", label="Reticulum Configuration",
value=config_text, value=config_text,
expand=True, expand=True,
multiline=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): def on_save_config(ev):
try: try:
success = storage.save_config(config_field.value) success = storage.save_config(config_field.value)
if success: if success:
print("Config saved successfully. Please restart the app.") snack = ft.SnackBar(
page.snack_bar = ft.SnackBar( content=ft.Row(
ft.Text("Config saved successfully. Please restart the app."), controls=[
open=True, 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:
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:
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_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: else:
print("Error saving config: Storage operation failed") snack = ft.SnackBar(
page.snack_bar = ft.SnackBar( content=ft.Row(
ft.Text("Error saving config: Storage operation failed"), open=True 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: except Exception as ex:
print(f"Error saving config: {ex}") snack = ft.SnackBar(
page.snack_bar = ft.SnackBar( content=ft.Row(
ft.Text(f"Error saving config: {ex}"), open=True 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_btn = ft.ElevatedButton("Save Config", on_click=on_save_config) 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( error_field = ft.TextField(
label="Error Logs", label="Error Logs",
value="", value="",
expand=True, expand=True,
multiline=True, multiline=True,
read_only=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( ret_field = ft.TextField(
label="Reticulum logs", label="Reticulum Logs",
value="", value="",
expand=True, expand=True,
multiline=True, multiline=True,
read_only=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_info = storage.get_storage_info()
storage_text = "\n".join([f"{key}: {value}" for key, value in storage_info.items()]) storage_text = "\n".join([f"{key}: {value}" for key, value in storage_info.items()])
storage_field = ft.TextField( storage_field = ft.TextField(
@@ -77,14 +313,39 @@ def open_settings_tab(page: ft.Page, tab_manager):
expand=True, expand=True,
multiline=True, multiline=True,
read_only=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) 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): def show_config(ev):
content_placeholder.content = config_field content_placeholder.content = config_field
page.update() page.update()
def show_appearance(ev):
content_placeholder.content = appearance_content
page.update()
def show_errors(ev): def show_errors(ev):
error_field.value = "\n".join(ERROR_LOGS) or "No errors logged." error_field.value = "\n".join(ERROR_LOGS) or "No errors logged."
content_placeholder.content = error_field content_placeholder.content = error_field
@@ -98,37 +359,100 @@ def open_settings_tab(page: ft.Page, tab_manager):
def show_storage_info(ev): def show_storage_info(ev):
storage_info = storage.get_storage_info() storage_info = storage.get_storage_info()
storage_field.value = "\n".join( 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 content_placeholder.content = storage_field
page.update() page.update()
def refresh_current_view(ev): def refresh_current_view(ev):
# Refresh the currently displayed content
if content_placeholder.content == error_field: if content_placeholder.content == error_field:
show_errors(ev) show_errors(ev)
elif content_placeholder.content == ret_field: elif content_placeholder.content == ret_field:
show_ret_logs(ev) show_ret_logs(ev)
elif content_placeholder.content == storage_field: elif content_placeholder.content == storage_field:
show_storage_info(ev) show_storage_info(ev)
elif content_placeholder.content == appearance_content:
show_appearance(ev)
elif content_placeholder.content == config_field: elif content_placeholder.content == config_field:
show_config(ev) show_config(ev)
btn_config = ft.ElevatedButton("Config", on_click=show_config) btn_config = ft.FilledButton(
btn_errors = ft.ElevatedButton("Errors", on_click=show_errors) "Configuration",
btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs) icon=ft.Icons.SETTINGS,
btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info) on_click=show_config,
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_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 content_placeholder.content = config_field
settings_content = ft.Column( settings_content = ft.Column(
expand=True, expand=True,
spacing=16,
controls=[ controls=[
button_row, ft.Container(
content_placeholder, content=ft.Text(
ft.Row([save_btn]), "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) tab_manager._add_tab_internal("Settings", settings_content)

View File

@@ -23,11 +23,26 @@ def build_ui(page: Page):
""" """
page.theme_mode = ft.ThemeMode.DARK 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.window.maximized = True
page.padding = 0
page_fetcher = PageFetcher() 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): def update_announces(ann_list):
announce_list.controls.clear() announce_list.controls.clear()
@@ -58,8 +73,18 @@ def build_ui(page: Page):
tab = tab_manager.manager.tabs[idx] tab = tab_manager.manager.tabs[idx]
except IndexError: except IndexError:
return 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"): if req.page_path.endswith(".mu"):
new_control = render_micron(result) new_control = render_micron(result, on_link_click=handle_link_click)
else: else:
new_control = render_plaintext(result) new_control = render_plaintext(result)
tab["content_control"] = new_control tab["content_control"] = new_control
@@ -70,25 +95,50 @@ def build_ui(page: Page):
page.run_thread(fetch_and_update) 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() page.update()
AnnounceService(update_callback=update_announces) AnnounceService(update_callback=update_announces)
page.drawer = ft.NavigationDrawer( page.drawer = ft.NavigationDrawer(
bgcolor=ft.Colors.GREY_900,
elevation=8,
controls=[ controls=[
ft.Text( ft.Container(
content=ft.Text(
"Announcements", "Announcements",
size=20,
weight=ft.FontWeight.BOLD, weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER, color=ft.Colors.BLUE_400,
expand=True,
), ),
ft.Divider(), padding=ft.padding.symmetric(horizontal=16, vertical=20),
),
ft.Divider(height=1, color=ft.Colors.GREY_700),
announce_list, announce_list,
], ],
) )
page.appbar.leading = ft.IconButton( page.appbar.leading = ft.IconButton(
ft.Icons.MENU, ft.Icons.MENU,
tooltip="Toggle sidebar", tooltip="Announcements",
icon_color=ft.Colors.WHITE,
on_click=lambda e: ( on_click=lambda e: (
setattr(page.drawer, "open", not page.drawer.open), setattr(page.drawer, "open", not page.drawer.open),
page.update(), page.update(),
@@ -102,15 +152,21 @@ def build_ui(page: Page):
ft.IconButton( ft.IconButton(
ft.Icons.SETTINGS, ft.Icons.SETTINGS,
tooltip="Settings", tooltip="Settings",
icon_color=ft.Colors.WHITE,
on_click=lambda e: open_settings_tab(page, tab_manager), on_click=lambda e: open_settings_tab(page, tab_manager),
) ),
] ]
Shortcuts(page, tab_manager) Shortcuts(page, tab_manager)
url_bar = ft.Row( url_bar = ft.Container(
content=ft.Row(
controls=[ controls=[
tab_manager.manager.tabs[tab_manager.manager.index]["url_field"], tab_manager.manager.tabs[tab_manager.manager.index]["url_field"],
tab_manager.manager.tabs[tab_manager.manager.index]["go_btn"], 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 page.appbar.title = url_bar
orig_select_tab = tab_manager.select_tab orig_select_tab = tab_manager.select_tab
@@ -118,8 +174,8 @@ def build_ui(page: Page):
def _select_tab_and_update_url(i): def _select_tab_and_update_url(i):
orig_select_tab(i) orig_select_tab(i)
tab = tab_manager.manager.tabs[i] tab = tab_manager.manager.tabs[i]
url_bar.controls.clear() url_bar.content.controls.clear()
url_bar.controls.extend([tab["url_field"], tab["go_btn"]]) url_bar.content.controls.extend([tab["url_field"], tab["go_btn"]])
page.update() page.update()
tab_manager.select_tab = _select_tab_and_update_url tab_manager.select_tab = _select_tab_and_update_url

View File

@@ -62,7 +62,7 @@ def sample_page_request():
from ren_browser.pages.page_request import PageRequest from ren_browser.pages.page_request import PageRequest
return 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,
) )

View File

@@ -19,7 +19,7 @@ class TestAnnounce:
def test_announce_with_none_display_name(self): def test_announce_with_none_display_name(self):
"""Test Announce creation with None display name.""" """Test Announce creation with None display name."""
announce = Announce( announce = Announce(
destination_hash="1234567890abcdef", display_name=None, timestamp=1234567890 destination_hash="1234567890abcdef", display_name=None, timestamp=1234567890,
) )
assert announce.destination_hash == "1234567890abcdef" assert announce.destination_hash == "1234567890abcdef"

View File

@@ -59,7 +59,7 @@ class TestLogsModule:
assert len(logs.RET_LOGS) == 1 assert len(logs.RET_LOGS) == 1
assert logs.RET_LOGS[0] == "[2023-01-01T12:00:00] Test RNS message" assert logs.RET_LOGS[0] == "[2023-01-01T12:00:00] Test RNS message"
logs._original_rns_log.assert_called_once_with( logs._original_rns_log.assert_called_once_with(
"Test RNS message", "arg1", kwarg1="value1" "Test RNS message", "arg1", kwarg1="value1",
) )
assert result == "original_result" assert result == "original_result"

View File

@@ -7,7 +7,7 @@ class TestPageRequest:
def test_page_request_creation(self): def test_page_request_creation(self):
"""Test basic PageRequest creation.""" """Test basic PageRequest creation."""
request = PageRequest( request = PageRequest(
destination_hash="1234567890abcdef", page_path="/page/index.mu" destination_hash="1234567890abcdef", page_path="/page/index.mu",
) )
assert request.destination_hash == "1234567890abcdef" assert request.destination_hash == "1234567890abcdef"

View File

@@ -63,66 +63,58 @@ class TestMicronRenderer:
""" """
def test_render_micron_basic(self): def test_render_micron_basic(self):
"""Test basic micron rendering (currently displays raw content).""" """Test basic micron rendering."""
content = "# Heading\n\nSome content" content = "# Heading\n\nSome content"
result = render_micron(content) result = render_micron(content)
assert isinstance(result, ft.Text) assert isinstance(result, ft.Column)
assert result.value == "# Heading\n\nSome content"
assert result.selectable is True
assert result.font_family == "monospace"
assert result.expand is True assert result.expand is True
assert result.scroll == ft.ScrollMode.AUTO
def test_render_micron_empty(self): def test_render_micron_empty(self):
"""Test micron rendering with empty content.""" """Test micron rendering with empty content."""
content = "" content = ""
result = render_micron(content) result = render_micron(content)
assert isinstance(result, ft.Text) assert isinstance(result, ft.Column)
assert result.value == "" assert len(result.controls) >= 0
assert result.selectable is True
def test_render_micron_unicode(self): def test_render_micron_unicode(self):
"""Test micron rendering with Unicode characters.""" """Test micron rendering with Unicode characters."""
content = "Unicode content: 你好 🌍 αβγ" content = "Unicode content: 你好 🌍 αβγ"
result = render_micron(content) result = render_micron(content)
assert isinstance(result, ft.Text) assert isinstance(result, ft.Column)
assert result.value == content assert len(result.controls) > 0
assert result.selectable is True
class TestRendererComparison: class TestRendererComparison:
"""Test cases comparing both renderers.""" """Test cases comparing both renderers."""
def test_renderers_return_same_type(self): 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" content = "Test content"
plaintext_result = render_plaintext(content) plaintext_result = render_plaintext(content)
micron_result = render_micron(content) micron_result = render_micron(content)
assert type(plaintext_result) is type(micron_result)
assert isinstance(plaintext_result, ft.Text) assert isinstance(plaintext_result, ft.Text)
assert isinstance(micron_result, ft.Text) assert isinstance(micron_result, ft.Column)
def test_renderers_preserve_content(self): 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" content = "Test content with\nmultiple lines"
plaintext_result = render_plaintext(content) plaintext_result = render_plaintext(content)
micron_result = render_micron(content)
assert plaintext_result.value == content assert plaintext_result.value == content
assert micron_result.value == content
def test_renderers_same_properties(self): 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" content = "Test content"
plaintext_result = render_plaintext(content) plaintext_result = render_plaintext(content)
micron_result = render_micron(content) micron_result = render_micron(content)
assert plaintext_result.selectable == micron_result.selectable assert plaintext_result.expand is True
assert plaintext_result.font_family == micron_result.font_family assert micron_result.expand is True
assert plaintext_result.expand == micron_result.expand

View File

@@ -18,7 +18,7 @@ class TestStorageManager:
def test_storage_manager_init_without_page(self): def test_storage_manager_init_without_page(self):
"""Test StorageManager initialization without a page.""" """Test StorageManager initialization without a page."""
with patch( with patch(
"ren_browser.storage.storage.StorageManager._get_storage_directory" "ren_browser.storage.storage.StorageManager._get_storage_directory",
) as mock_get_dir: ) as mock_get_dir:
mock_dir = Path("/mock/storage") mock_dir = Path("/mock/storage")
mock_get_dir.return_value = mock_dir mock_get_dir.return_value = mock_dir
@@ -35,7 +35,7 @@ class TestStorageManager:
mock_page = Mock() mock_page = Mock()
with patch( with patch(
"ren_browser.storage.storage.StorageManager._get_storage_directory" "ren_browser.storage.storage.StorageManager._get_storage_directory",
) as mock_get_dir: ) as mock_get_dir:
mock_dir = Path("/mock/storage") mock_dir = Path("/mock/storage")
mock_get_dir.return_value = mock_dir mock_get_dir.return_value = mock_dir
@@ -51,12 +51,11 @@ class TestStorageManager:
with ( with (
patch("os.name", "posix"), patch("os.name", "posix"),
patch.dict( 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(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory",
), ),
patch("pathlib.Path.mkdir"),
):
with patch(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory"
): ):
storage = StorageManager() storage = StorageManager()
storage._storage_dir = storage._get_storage_directory() storage._storage_dir = storage._get_storage_directory()
@@ -76,7 +75,7 @@ class TestStorageManager:
patch("pathlib.Path.mkdir"), patch("pathlib.Path.mkdir"),
): ):
with patch( with patch(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory" "ren_browser.storage.storage.StorageManager._ensure_storage_directory",
): ):
storage = StorageManager() storage = StorageManager()
storage._storage_dir = storage._get_storage_directory() storage._storage_dir = storage._get_storage_directory()
@@ -91,7 +90,7 @@ class TestStorageManager:
patch("pathlib.Path.mkdir"), patch("pathlib.Path.mkdir"),
): ):
with patch( with patch(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory" "ren_browser.storage.storage.StorageManager._ensure_storage_directory",
): ):
storage = StorageManager() storage = StorageManager()
storage._storage_dir = storage._get_storage_directory() storage._storage_dir = storage._get_storage_directory()
@@ -103,10 +102,9 @@ class TestStorageManager:
with ( with (
patch("os.name", "posix"), patch("os.name", "posix"),
patch.dict("os.environ", {"ANDROID_ROOT": "/system"}, clear=True), 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 = StorageManager()
storage._storage_dir = storage._get_storage_directory() storage._storage_dir = storage._get_storage_directory()
@@ -171,7 +169,7 @@ class TestStorageManager:
assert result is True assert result is True
mock_page.client_storage.set.assert_called_with( mock_page.client_storage.set.assert_called_with(
"ren_browser_config", config_content "ren_browser_config", config_content,
) )
def test_save_config_fallback(self): def test_save_config_fallback(self):
@@ -188,8 +186,7 @@ class TestStorageManager:
storage, storage,
"get_reticulum_config_path", "get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum", return_value=Path(temp_dir) / "reticulum",
): ), patch(
with patch(
"pathlib.Path.write_text", "pathlib.Path.write_text",
side_effect=PermissionError("Access denied"), side_effect=PermissionError("Access denied"),
): ):
@@ -199,7 +196,7 @@ class TestStorageManager:
assert result is True assert result is True
# Check that the config was set to client storage # Check that the config was set to client storage
mock_page.client_storage.set.assert_any_call( mock_page.client_storage.set.assert_any_call(
"ren_browser_config", config_content "ren_browser_config", config_content,
) )
# Verify that client storage was called at least once # Verify that client storage was called at least once
assert mock_page.client_storage.set.call_count >= 1 assert mock_page.client_storage.set.call_count >= 1
@@ -270,7 +267,7 @@ class TestStorageManager:
bookmarks_path = storage._storage_dir / "bookmarks.json" bookmarks_path = storage._storage_dir / "bookmarks.json"
assert bookmarks_path.exists() 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) loaded_bookmarks = json.load(f)
assert loaded_bookmarks == bookmarks assert loaded_bookmarks == bookmarks
@@ -311,7 +308,7 @@ class TestStorageManager:
history_path = storage._storage_dir / "history.json" history_path = storage._storage_dir / "history.json"
assert history_path.exists() 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) loaded_history = json.load(f)
assert loaded_history == history assert loaded_history == history
@@ -360,8 +357,7 @@ class TestStorageManager:
with patch( with patch(
"pathlib.Path.mkdir", "pathlib.Path.mkdir",
side_effect=[PermissionError("Access denied"), None], side_effect=[PermissionError("Access denied"), None],
): ), patch("tempfile.gettempdir", return_value="/tmp"):
with patch("tempfile.gettempdir", return_value="/tmp"):
storage = StorageManager() storage = StorageManager()
expected_fallback = Path("/tmp") / "ren_browser" expected_fallback = Path("/tmp") / "ren_browser"
@@ -448,7 +444,7 @@ class TestStorageManagerEdgeCases:
storage = StorageManager() storage = StorageManager()
with patch( 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") test_path = Path("/mock/path")
result = storage._is_writable(test_path) result = storage._is_writable(test_path)

View File

@@ -34,8 +34,8 @@ class TestTabsManager:
assert isinstance(manager.manager, SimpleNamespace) assert isinstance(manager.manager, SimpleNamespace)
assert len(manager.manager.tabs) == 1 assert len(manager.manager.tabs) == 1
assert manager.manager.index == 0 assert manager.manager.index == 0
assert isinstance(manager.tab_bar, ft.Row) assert isinstance(manager.tab_bar, ft.Container)
assert manager.tab_bar.scroll is None assert isinstance(manager.tab_bar.content, ft.Row)
assert manager.overflow_menu is None assert manager.overflow_menu is None
assert isinstance(manager.content_container, ft.Container) assert isinstance(manager.content_container, ft.Container)
@@ -105,12 +105,12 @@ class TestTabsManager:
"""Test that selecting a tab updates background colors correctly.""" """Test that selecting a tab updates background colors correctly."""
tabs_manager._add_tab_internal("Tab 2", Mock()) 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) tabs_manager.select_tab(1)
assert tab_controls[0].bgcolor == ft.Colors.SURFACE_CONTAINER_HIGHEST assert tab_controls[0].bgcolor == ft.Colors.GREY_800
assert tab_controls[1].bgcolor == ft.Colors.PRIMARY_CONTAINER assert tab_controls[1].bgcolor == ft.Colors.BLUE_900
def test_on_tab_go_empty_url(self, tabs_manager): def test_on_tab_go_empty_url(self, tabs_manager):
"""Test tab go with empty URL.""" """Test tab go with empty URL."""
@@ -146,12 +146,12 @@ class TestTabsManager:
def test_tab_container_properties(self, tabs_manager): def test_tab_container_properties(self, tabs_manager):
"""Test that tab container has correct properties.""" """Test that tab container has correct properties."""
assert tabs_manager.content_container.expand is True assert tabs_manager.content_container.expand is True
assert tabs_manager.content_container.bgcolor == ft.Colors.BLACK assert tabs_manager.content_container.bgcolor in (ft.Colors.BLACK, "#000000")
assert tabs_manager.content_container.padding == ft.padding.all(5) assert tabs_manager.content_container.padding == ft.padding.all(16)
def test_tab_bar_controls(self, tabs_manager): def test_tab_bar_controls(self, tabs_manager):
"""Test that tab bar has correct controls.""" """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) # Should have: home tab, add button, close button (and potentially overflow menu)
assert len(controls) >= 3 assert len(controls) >= 3
@@ -180,7 +180,7 @@ class TestTabsManager:
url_field = tab["url_field"] url_field = tab["url_field"]
assert url_field.expand is True 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 assert url_field.content_padding is not None
def test_go_button_properties(self, tabs_manager): def test_go_button_properties(self, tabs_manager):
@@ -188,14 +188,14 @@ class TestTabsManager:
tab = tabs_manager.manager.tabs[0] tab = tabs_manager.manager.tabs[0]
go_btn = tab["go_btn"] go_btn = tab["go_btn"]
assert go_btn.icon == ft.Icons.OPEN_IN_BROWSER assert go_btn.icon == ft.Icons.ARROW_FORWARD
assert go_btn.tooltip == "Load URL" assert go_btn.tooltip == "Go"
def test_tab_click_handlers(self, tabs_manager): def test_tab_click_handlers(self, tabs_manager):
"""Test that tab click handlers are properly set.""" """Test that tab click handlers are properly set."""
tabs_manager._add_tab_internal("Tab 2", Mock()) 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): for i, control in enumerate(tab_controls):
assert control.on_click is not None assert control.on_click is not None
@@ -249,13 +249,13 @@ class TestTabsManager:
# Simulate a smaller screen, expecting more tabs to overflow # Simulate a smaller screen, expecting more tabs to overflow
tabs_manager.page.width = 400 tabs_manager.page.width = 400
tabs_manager._update_tab_visibility() 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 assert visible_tabs_small < 11
# Simulate a larger screen, expecting all tabs to be visible # Simulate a larger screen, expecting all tabs to be visible
tabs_manager.page.width = 1600 tabs_manager.page.width = 1600
tabs_manager._update_tab_visibility() 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 visible_tabs_large == 11
assert tabs_manager.overflow_menu is None assert tabs_manager.overflow_menu is None

View File

@@ -29,7 +29,7 @@ class TestBuildUI:
@patch("ren_browser.tabs.tabs.TabsManager") @patch("ren_browser.tabs.tabs.TabsManager")
@patch("ren_browser.controls.shortcuts.Shortcuts") @patch("ren_browser.controls.shortcuts.Shortcuts")
def test_build_ui_appbar_setup( 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.""" """Test that build_ui sets up the app bar correctly."""
mock_tab_manager = Mock() mock_tab_manager = Mock()
@@ -51,7 +51,7 @@ class TestBuildUI:
@patch("ren_browser.tabs.tabs.TabsManager") @patch("ren_browser.tabs.tabs.TabsManager")
@patch("ren_browser.controls.shortcuts.Shortcuts") @patch("ren_browser.controls.shortcuts.Shortcuts")
def test_build_ui_drawer_setup( 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.""" """Test that build_ui sets up the drawer correctly."""
mock_tab_manager = Mock() mock_tab_manager = Mock()
@@ -129,14 +129,14 @@ class TestOpenSettingsTab:
# Get the settings content that was added # Get the settings content that was added
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1] 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 save_btn = None
for control in settings_content.controls: for control in settings_content.controls:
if hasattr(control, "controls"): if hasattr(control, "content") and hasattr(control.content, "controls"):
for sub_control in control.controls: for sub_control in control.content.controls:
if ( if (
hasattr(sub_control, "text") hasattr(sub_control, "text")
and sub_control.text == "Save Config" and sub_control.text == "Save Configuration"
): ):
save_btn = sub_control save_btn = sub_control
break break