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"
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",

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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