Compare commits
39 Commits
v0.2.0
...
micron-ren
| Author | SHA1 | Date | |
|---|---|---|---|
| b41c966bf3 | |||
| a15f03ad8a | |||
| ad567cfac0 | |||
| 615a54852d | |||
| dc18d57547 | |||
| e8ee623a82 | |||
| 79c3351fb4 | |||
| 6508a89443 | |||
| 219e6822e8 | |||
| 910fe3c8aa | |||
| 3f40828707 | |||
| 4d3e3f6688 | |||
| c0f60d52db | |||
| 57c6b8ce3d | |||
| 9fc912fba4 | |||
| 0d878e8491 | |||
| 2796059aef | |||
| a5ae444b6c | |||
| d354b96334 | |||
| f511b60361 | |||
| b8386a60c6 | |||
| e20d6fe214 | |||
| 8b45e5d72b | |||
| 047169f3af | |||
| e0939e70f8 | |||
| 63e93d0cff | |||
| 926b3a198d | |||
| 8db441612f | |||
| b34b8f23ff | |||
| 13ad0bcef6 | |||
| 64b9ac3df4 | |||
| ee521a9f60 | |||
| fd4e0c8a14 | |||
| b056271da7 | |||
| 189256edd7 | |||
| 62d3502f99 | |||
| cb218f2b29 | |||
| 871f626555 | |||
| 9a20152a70 |
47
.github/workflows/build.yml
vendored
47
.github/workflows/build.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-linux:
|
build-linux:
|
||||||
@@ -20,7 +21,7 @@ jobs:
|
|||||||
sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
|
sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ jobs:
|
|||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00
|
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
@@ -60,7 +61,7 @@ jobs:
|
|||||||
sudo apt-get install -y cmake ninja-build clang pkg-config
|
sudo apt-get install -y cmake ninja-build clang pkg-config
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
|
|
||||||
@@ -78,4 +79,42 @@ jobs:
|
|||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||||
with:
|
with:
|
||||||
name: ren-browser-apk
|
name: ren-browser-apk
|
||||||
path: build/apk
|
path: build/apk
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
needs: [build-linux, build-android]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||||
|
|
||||||
|
- name: Download Linux artifact
|
||||||
|
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
|
||||||
|
with:
|
||||||
|
name: ren-browser-linux
|
||||||
|
path: ./artifacts/linux
|
||||||
|
|
||||||
|
- name: Download APK artifact
|
||||||
|
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
|
||||||
|
with:
|
||||||
|
name: ren-browser-apk
|
||||||
|
path: ./artifacts/apk
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
with:
|
||||||
|
draft: true
|
||||||
|
files: |
|
||||||
|
./artifacts/linux/*
|
||||||
|
./artifacts/apk/*
|
||||||
|
name: Release ${{ github.ref_name }}
|
||||||
|
body: |
|
||||||
|
## Release ${{ github.ref_name }}
|
||||||
|
|
||||||
|
This release contains:
|
||||||
|
- Linux binary package
|
||||||
|
- Android APK package
|
||||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
|||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|||||||
2
.github/workflows/safety.yml
vendored
2
.github/workflows/safety.yml
vendored
@@ -12,6 +12,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||||
- name: Run Safety CLI to check for vulnerabilities
|
- name: Run Safety CLI to check for vulnerabilities
|
||||||
uses: pyupio/safety-action@7baf6605473beffc874c1313ddf2db085c0cacf2
|
uses: pyupio/safety-action@2591cf2f3e67ba68b923f4c92f0d36e281c65023 # v1.0.1
|
||||||
with:
|
with:
|
||||||
api-key: ${{ secrets.SAFETY_API_KEY }}
|
api-key: ${{ secrets.SAFETY_API_KEY }}
|
||||||
|
|||||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
poetry config virtualenvs.in-project true
|
poetry config virtualenvs.in-project true
|
||||||
|
|
||||||
- name: Cache Poetry dependencies
|
- name: Cache Poetry dependencies
|
||||||
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
poetry run pytest -v --cov=ren_browser --cov-report=xml --cov-report=term
|
poetry run pytest -v --cov=ren_browser --cov-report=xml --cov-report=term
|
||||||
|
|
||||||
- name: Upload coverage reports
|
- name: Upload coverage reports
|
||||||
uses: codecov/codecov-action@ab904c41d6ece82784817410c45d8b8c02684457
|
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||||
if: matrix.python-version == '3.13'
|
if: matrix.python-version == '3.13'
|
||||||
with:
|
with:
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
|
|||||||
@@ -7,13 +7,51 @@ I welcome all contributions to the project.
|
|||||||
- Styling/Design (I am bad at this)
|
- Styling/Design (I am bad at this)
|
||||||
- Documentation
|
- Documentation
|
||||||
- Micron Renderer/Parser
|
- Micron Renderer/Parser
|
||||||
|
- Android and Flet (config/permissions/etc)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Last Updated: 2025-09-28
|
||||||
|
|
||||||
|
```
|
||||||
|
Ren-Browser/
|
||||||
|
├── ren_browser/ # Main Python application package
|
||||||
|
│ ├── announces/ # Reticulum network announce handling
|
||||||
|
│ │ ├── announces.py
|
||||||
|
│ ├── app.py # Main application entry point
|
||||||
|
│ ├── controls/ # UI controls and interactions
|
||||||
|
│ │ ├── shortcuts.py # Keyboard shortcuts handling
|
||||||
|
│ ├── logs.py # Centralized logging system
|
||||||
|
│ ├── pages/ # Page fetching and request handling
|
||||||
|
│ │ ├── page_request.py
|
||||||
|
│ ├── profiler/ # Performance profiling (placeholder)
|
||||||
|
│ ├── renderer/ # Content rendering system
|
||||||
|
│ │ ├── micron.py # Micron markup renderer (WIP)
|
||||||
|
│ │ └── plaintext.py # Plaintext fallback renderer
|
||||||
|
│ ├── storage/ # Cross-platform storage management
|
||||||
|
│ │ ├── storage.py
|
||||||
|
│ ├── tabs/ # Tab management system
|
||||||
|
│ │ ├── tabs.py
|
||||||
|
│ ├── ui/ # User interface components
|
||||||
|
│ │ ├── settings.py # Settings interface
|
||||||
|
│ │ └── ui.py # Main UI construction
|
||||||
|
├── tests/ # Test suite
|
||||||
|
│ ├── unit/ # Unit tests
|
||||||
|
│ ├── integration/ # Integration tests
|
||||||
|
│ └── conftest.py # Test configuration
|
||||||
|
```
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
1. Be nice to each other.
|
1. Be nice to each other.
|
||||||
2. If you use an AI tool that generates the code, such as a LLM, please indicate that in the PR.
|
|
||||||
3. Add or update docstrings and tests if necessary.
|
## Generative AI Usage
|
||||||
4. Make sure you run the tests before submitting the PR.
|
|
||||||
|
You are allowed to use generative AI tools to help learn and contribute. You do not need to disclose you used a AI tool, although that would help me scrutinize the PR more for bugs, errors or security flaws.
|
||||||
|
|
||||||
|
## Linting, Security and Tests
|
||||||
|
|
||||||
|
You are not required to run the linting, security and tests before submitting the PR as those will be run by the CI/CD pipeline.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
35
Dockerfile
35
Dockerfile
@@ -1,18 +1,29 @@
|
|||||||
FROM python:3.13-alpine
|
ARG PYTHON_VERSION=3.13
|
||||||
|
FROM python:${PYTHON_VERSION}-alpine
|
||||||
|
|
||||||
# Install build dependencies for cryptography
|
LABEL org.opencontainers.image.source="https://github.com/Sudo-Ivan/Ren-Browser"
|
||||||
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
|
LABEL org.opencontainers.image.description="A browser for the Reticulum Network."
|
||||||
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
LABEL org.opencontainers.image.authors="Sudo-Ivan"
|
||||||
|
|
||||||
# Upgrade pip and install application dependencies
|
|
||||||
RUN pip install --upgrade pip \
|
|
||||||
&& pip install --no-cache-dir "flet>=0.28.3,<0.29.0" "rns>=0.9.6,<0.10.0"
|
|
||||||
|
|
||||||
# Copy application source
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
# Expose the web port
|
RUN apk add --no-cache gcc python3-dev musl-dev linux-headers libffi-dev openssl-dev
|
||||||
|
|
||||||
|
RUN pip install --no-cache poetry
|
||||||
|
ENV POETRY_VIRTUALENVS_IN_PROJECT=true
|
||||||
|
|
||||||
|
COPY pyproject.toml poetry.lock* ./
|
||||||
|
COPY README.md ./
|
||||||
|
COPY ren_browser ./ren_browser
|
||||||
|
|
||||||
|
RUN poetry install --no-interaction --no-ansi --no-cache
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV FLET_WEB_PORT=8550
|
||||||
|
ENV FLET_WEB_HOST=0.0.0.0
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
|
||||||
EXPOSE 8550
|
EXPOSE 8550
|
||||||
|
|
||||||
# Run the web version of Ren Browser
|
ENTRYPOINT ["poetry", "run", "ren-browser-web"]
|
||||||
CMD ["python3", "-u", "-m", "ren_browser.app", "--web", "--port", "8550"]
|
|
||||||
80
Makefile
Normal file
80
Makefile
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Ren Browser Makefile
|
||||||
|
.PHONY: help build poetry-build linux apk docker-build docker-build-multi docker-run docker-stop clean test lint format
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help:
|
||||||
|
@echo "Ren Browser Build System"
|
||||||
|
@echo ""
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " build - Build the project (alias for poetry-build)"
|
||||||
|
@echo " poetry-build - Build project with Poetry"
|
||||||
|
@echo " linux - Build Linux package"
|
||||||
|
@echo " apk - Build Android APK"
|
||||||
|
@echo " docker-build - Build Docker image with Buildx"
|
||||||
|
@echo " docker-build-multi - Build multi-platform Docker image"
|
||||||
|
@echo " docker-run - Run Docker container"
|
||||||
|
@echo " docker-stop - Stop Docker container"
|
||||||
|
@echo " test - Run tests"
|
||||||
|
@echo " lint - Run linter"
|
||||||
|
@echo " format - Format code"
|
||||||
|
@echo " clean - Clean build artifacts"
|
||||||
|
@echo " help - Show this help"
|
||||||
|
|
||||||
|
# Main build target
|
||||||
|
build: poetry-build
|
||||||
|
|
||||||
|
# Poetry build
|
||||||
|
poetry-build:
|
||||||
|
@echo "Building project with Poetry..."
|
||||||
|
poetry build
|
||||||
|
|
||||||
|
# Linux package build
|
||||||
|
linux:
|
||||||
|
@echo "Building Linux package..."
|
||||||
|
poetry run flet build linux
|
||||||
|
|
||||||
|
# Android APK build
|
||||||
|
apk:
|
||||||
|
@echo "Building Android APK..."
|
||||||
|
poetry run flet build apk
|
||||||
|
|
||||||
|
# Docker targets
|
||||||
|
docker-build:
|
||||||
|
@echo "Building Docker image with Buildx..."
|
||||||
|
docker buildx build -t ren-browser --load .
|
||||||
|
|
||||||
|
docker-build-multi:
|
||||||
|
@echo "Building multi-platform Docker image..."
|
||||||
|
docker buildx build -t ren-browser-multi --platform linux/amd64,linux/arm64 --push .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
@echo "Running Docker container..."
|
||||||
|
docker run -p 8550:8550 --name ren-browser-container ren-browser
|
||||||
|
|
||||||
|
docker-stop:
|
||||||
|
@echo "Stopping Docker container..."
|
||||||
|
docker stop ren-browser-container || true
|
||||||
|
docker rm ren-browser-container || true
|
||||||
|
|
||||||
|
# Development targets
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
poetry run pytest
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@echo "Running linter..."
|
||||||
|
poetry run ruff check .
|
||||||
|
|
||||||
|
format:
|
||||||
|
@echo "Formatting code..."
|
||||||
|
poetry run ruff format .
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning build artifacts..."
|
||||||
|
rm -rf build/
|
||||||
|
rm -rf dist/
|
||||||
|
rm -rf *.egg-info/
|
||||||
|
find . -type d -name __pycache__ -exec rm -rf {} +
|
||||||
|
find . -type f -name "*.pyc" -delete
|
||||||
|
docker rmi ren-browser || true
|
||||||
15
README.md
15
README.md
@@ -1,13 +1,14 @@
|
|||||||
# Ren Browser
|
# Ren Browser
|
||||||
|
|
||||||
A browser for the [Reticulum Network](https://reticulum.network/). Work-in-progress.
|
A browser for the [Reticulum Network](https://reticulum.network/).
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This is a work-in-progress.
|
||||||
|
|
||||||
Target platforms: Web, Linux, Windows, MacOS, Android, iOS.
|
Target platforms: Web, Linux, Windows, MacOS, Android, iOS.
|
||||||
|
|
||||||
Built using [Flet](https://flet.dev/).
|
Built using [Flet](https://flet.dev/).
|
||||||
|
|
||||||
Currently, you can find `Linux` and `Android` builds in action artifacts in the [GitHub Actions](https://github.com/Sudo-Ivan/Ren-Browser/actions/workflows/build.yml) page, click on the latest workflow run. More platforms will be added in the future.
|
|
||||||
|
|
||||||
## Renderers
|
## Renderers
|
||||||
|
|
||||||
- Micron (default) (WIP)
|
- Micron (default) (WIP)
|
||||||
@@ -31,13 +32,13 @@ poetry install
|
|||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run ren-browser-dev
|
poetry run ren-browser
|
||||||
```
|
```
|
||||||
|
|
||||||
### Web
|
### Web
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run ren-browser-web-dev
|
poetry run ren-browser-web
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mobile
|
### Mobile
|
||||||
@@ -45,13 +46,13 @@ poetry run ren-browser-web-dev
|
|||||||
**Android**
|
**Android**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run ren-browser-android-dev
|
poetry run ren-browser-android
|
||||||
```
|
```
|
||||||
|
|
||||||
**iOS**
|
**iOS**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run ren-browser-ios-dev
|
poetry run ren-browser-ios
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker/Podman
|
### Docker/Podman
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ren-browser"
|
name = "ren-browser"
|
||||||
version = "0.2.0"
|
version = "0.2.2"
|
||||||
description = "A browser for the Reticulum Network."
|
description = "A browser for the Reticulum Network."
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Sudo-Ivan"}
|
{name = "Sudo-Ivan"}
|
||||||
@@ -22,10 +22,6 @@ ren-browser = "ren_browser.app:run"
|
|||||||
ren-browser-web = "ren_browser.app:web"
|
ren-browser-web = "ren_browser.app:web"
|
||||||
ren-browser-android = "ren_browser.app:android"
|
ren-browser-android = "ren_browser.app:android"
|
||||||
ren-browser-ios = "ren_browser.app:ios"
|
ren-browser-ios = "ren_browser.app:ios"
|
||||||
ren-browser-dev = "ren_browser.app:run_dev"
|
|
||||||
ren-browser-web-dev = "ren_browser.app:web_dev"
|
|
||||||
ren-browser-android-dev = "ren_browser.app:android_dev"
|
|
||||||
ren-browser-ios-dev = "ren_browser.app:ios_dev"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
ruff = "^0.11.11"
|
ruff = "^0.11.11"
|
||||||
@@ -34,3 +30,6 @@ pytest-cov = "^7.0.0"
|
|||||||
pytest-mock = "^3.15.1"
|
pytest-mock = "^3.15.1"
|
||||||
pytest-asyncio = "^1.2.0"
|
pytest-asyncio = "^1.2.0"
|
||||||
|
|
||||||
|
[tool.flet.flutter.pubspec.dependency_overrides]
|
||||||
|
webview_flutter_android = "4.10.1"
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
This module provides services for listening to and collecting network
|
This module provides services for listening to and collecting network
|
||||||
announces from the Reticulum network.
|
announces from the Reticulum network.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Announce:
|
class Announce:
|
||||||
"""Represents a Reticulum network announce.
|
"""Represents a Reticulum network announce.
|
||||||
@@ -21,6 +21,7 @@ class Announce:
|
|||||||
display_name: str | None
|
display_name: str | None
|
||||||
timestamp: int
|
timestamp: int
|
||||||
|
|
||||||
|
|
||||||
class AnnounceService:
|
class AnnounceService:
|
||||||
"""Service to listen for Reticulum announces and collect them.
|
"""Service to listen for Reticulum announces and collect them.
|
||||||
|
|
||||||
@@ -60,7 +61,11 @@ class AnnounceService:
|
|||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
announce = Announce(destination_hash.hex(), display_name, ts)
|
announce = Announce(destination_hash.hex(), display_name, ts)
|
||||||
self.announces = [ann for ann in self.announces if ann.destination_hash != announce.destination_hash]
|
self.announces = [
|
||||||
|
ann
|
||||||
|
for ann in self.announces
|
||||||
|
if ann.destination_hash != announce.destination_hash
|
||||||
|
]
|
||||||
self.announces.insert(0, announce)
|
self.announces.insert(0, announce)
|
||||||
if self.update_callback:
|
if self.update_callback:
|
||||||
self.update_callback(self.announces)
|
self.update_callback(self.announces)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
This module provides the entry point and platform-specific launchers for the
|
This module provides the entry point and platform-specific launchers for the
|
||||||
Ren Browser, a browser for the Reticulum Network built with Flet.
|
Ren Browser, a browser for the Reticulum Network built with Flet.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
@@ -15,36 +16,68 @@ from ren_browser.ui.ui import build_ui
|
|||||||
RENDERER = "plaintext"
|
RENDERER = "plaintext"
|
||||||
RNS_CONFIG_DIR = None
|
RNS_CONFIG_DIR = None
|
||||||
|
|
||||||
|
|
||||||
async def main(page: Page):
|
async def main(page: Page):
|
||||||
"""Initialize and launch the Ren Browser application.
|
"""Initialize and launch the Ren Browser application.
|
||||||
|
|
||||||
Sets up the loading screen, initializes Reticulum network,
|
Sets up the loading screen, initializes Reticulum network,
|
||||||
and builds the main UI.
|
and builds the main UI.
|
||||||
"""
|
"""
|
||||||
|
page.title = "Ren Browser"
|
||||||
|
page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
|
||||||
loader = ft.Container(
|
loader = ft.Container(
|
||||||
expand=True,
|
expand=True,
|
||||||
alignment=ft.alignment.center,
|
alignment=ft.alignment.center,
|
||||||
|
bgcolor=ft.Colors.SURFACE,
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
[ft.ProgressRing(), ft.Text("Initializing reticulum network")],
|
[
|
||||||
|
ft.ProgressRing(color=ft.Colors.PRIMARY, width=50, height=50),
|
||||||
|
ft.Container(height=20),
|
||||||
|
ft.Text(
|
||||||
|
"Initializing Reticulum Network...",
|
||||||
|
size=16,
|
||||||
|
color=ft.Colors.ON_SURFACE,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
),
|
||||||
|
],
|
||||||
alignment=ft.MainAxisAlignment.CENTER,
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=10,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
page.add(loader)
|
page.add(loader)
|
||||||
page.update()
|
page.update()
|
||||||
|
|
||||||
def init_ret():
|
def init_ret():
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Initialize storage system
|
# Initialize storage system
|
||||||
storage = initialize_storage(page)
|
storage = initialize_storage(page)
|
||||||
|
|
||||||
# Get Reticulum config directory
|
# Get Reticulum config directory from storage manager
|
||||||
if RNS_CONFIG_DIR:
|
config_dir = storage.get_reticulum_config_path()
|
||||||
config_dir = RNS_CONFIG_DIR
|
|
||||||
else:
|
# Update the global RNS_CONFIG_DIR so RNS uses the right path
|
||||||
config_dir = storage.get_reticulum_config_path()
|
global RNS_CONFIG_DIR
|
||||||
|
RNS_CONFIG_DIR = str(config_dir)
|
||||||
|
|
||||||
|
# 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:
|
try:
|
||||||
# Set up logging capture first, before RNS init
|
# Set up logging capture first, before RNS init
|
||||||
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))
|
RNS.Reticulum(str(config_dir))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -55,14 +88,31 @@ async def main(page: Page):
|
|||||||
|
|
||||||
page.run_thread(init_ret)
|
page.run_thread(init_ret)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
parser = argparse.ArgumentParser(description="Ren Browser")
|
parser = argparse.ArgumentParser(description="Ren Browser")
|
||||||
parser.add_argument("-r", "--renderer", choices=["plaintext", "micron"], default=RENDERER, help="Select renderer (plaintext or micron)")
|
parser.add_argument(
|
||||||
parser.add_argument("-w", "--web", action="store_true", help="Launch in web browser mode")
|
"-r",
|
||||||
parser.add_argument("-p", "--port", type=int, default=None, help="Port for web server")
|
"--renderer",
|
||||||
parser.add_argument("-c", "--config-dir", type=str, default=None, help="RNS config directory (default: ~/.reticulum/)")
|
choices=["plaintext", "micron"],
|
||||||
|
default=RENDERER,
|
||||||
|
help="Select renderer (plaintext or micron)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-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"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--config-dir",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="RNS config directory (default: ~/.reticulum/)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
RENDERER = args.renderer
|
RENDERER = args.renderer
|
||||||
|
|
||||||
@@ -71,6 +121,7 @@ def run():
|
|||||||
RNS_CONFIG_DIR = args.config_dir
|
RNS_CONFIG_DIR = args.config_dir
|
||||||
else:
|
else:
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
RNS_CONFIG_DIR = str(pathlib.Path.home() / ".reticulum")
|
RNS_CONFIG_DIR = str(pathlib.Path.home() / ".reticulum")
|
||||||
|
|
||||||
if args.web:
|
if args.web:
|
||||||
@@ -81,33 +132,41 @@ def run():
|
|||||||
else:
|
else:
|
||||||
ft.app(main)
|
ft.app(main)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run()
|
run()
|
||||||
|
|
||||||
|
|
||||||
def web():
|
def web():
|
||||||
"""Launch Ren Browser in web mode."""
|
"""Launch Ren Browser in web mode."""
|
||||||
ft.app(main, view=AppView.WEB_BROWSER)
|
ft.app(main, view=AppView.WEB_BROWSER)
|
||||||
|
|
||||||
|
|
||||||
def android():
|
def android():
|
||||||
"""Launch Ren Browser in Android mode."""
|
"""Launch Ren Browser in Android mode."""
|
||||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||||
|
|
||||||
|
|
||||||
def ios():
|
def ios():
|
||||||
"""Launch Ren Browser in iOS mode."""
|
"""Launch Ren Browser in iOS mode."""
|
||||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||||
|
|
||||||
|
|
||||||
def run_dev():
|
def run_dev():
|
||||||
"""Launch Ren Browser in desktop mode."""
|
"""Launch Ren Browser in desktop mode."""
|
||||||
ft.app(main)
|
ft.app(main)
|
||||||
|
|
||||||
|
|
||||||
def web_dev():
|
def web_dev():
|
||||||
"""Launch Ren Browser in web mode."""
|
"""Launch Ren Browser in web mode."""
|
||||||
ft.app(main, view=AppView.WEB_BROWSER)
|
ft.app(main, view=AppView.WEB_BROWSER)
|
||||||
|
|
||||||
|
|
||||||
def android_dev():
|
def android_dev():
|
||||||
"""Launch Ren Browser in Android mode."""
|
"""Launch Ren Browser in Android mode."""
|
||||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||||
|
|
||||||
|
|
||||||
def ios_dev():
|
def ios_dev():
|
||||||
"""Launch Ren Browser in iOS mode."""
|
"""Launch Ren Browser in iOS mode."""
|
||||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Provides keyboard event handling and delegation to tab manager
|
Provides keyboard event handling and delegation to tab manager
|
||||||
and UI components.
|
and UI components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Provides centralized logging for application events, errors, and
|
Provides centralized logging for application events, errors, and
|
||||||
Reticulum network activities.
|
Reticulum network activities.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
@@ -12,6 +13,7 @@ ERROR_LOGS: list[str] = []
|
|||||||
RET_LOGS: list[str] = []
|
RET_LOGS: list[str] = []
|
||||||
_original_rns_log = RNS.log
|
_original_rns_log = RNS.log
|
||||||
|
|
||||||
|
|
||||||
def log_ret(msg, *args, **kwargs):
|
def log_ret(msg, *args, **kwargs):
|
||||||
"""Log Reticulum messages with timestamp.
|
"""Log Reticulum messages with timestamp.
|
||||||
|
|
||||||
@@ -25,14 +27,16 @@ def log_ret(msg, *args, **kwargs):
|
|||||||
RET_LOGS.append(f"[{timestamp}] {msg}")
|
RET_LOGS.append(f"[{timestamp}] {msg}")
|
||||||
return _original_rns_log(msg, *args, **kwargs)
|
return _original_rns_log(msg, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def setup_rns_logging():
|
def setup_rns_logging():
|
||||||
"""Set up RNS log replacement. Call this after RNS.Reticulum initialization."""
|
"""Set up RNS log replacement. Call this after RNS.Reticulum initialization."""
|
||||||
global _original_rns_log
|
global _original_rns_log
|
||||||
# Only set up if not already done and if RNS.log is not already our function
|
# Only set up if not already done and if RNS.log is not already our function
|
||||||
if RNS.log != log_ret and _original_rns_log != log_ret:
|
if RNS.log is not log_ret and _original_rns_log is not log_ret:
|
||||||
_original_rns_log = RNS.log
|
_original_rns_log = RNS.log
|
||||||
RNS.log = log_ret
|
RNS.log = log_ret
|
||||||
|
|
||||||
|
|
||||||
def log_error(msg: str):
|
def log_error(msg: str):
|
||||||
"""Log error messages to both error and application logs.
|
"""Log error messages to both error and application logs.
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@ def log_error(msg: str):
|
|||||||
ERROR_LOGS.append(f"[{timestamp}] {msg}")
|
ERROR_LOGS.append(f"[{timestamp}] {msg}")
|
||||||
APP_LOGS.append(f"[{timestamp}] ERROR: {msg}")
|
APP_LOGS.append(f"[{timestamp}] ERROR: {msg}")
|
||||||
|
|
||||||
|
|
||||||
def log_app(msg: str):
|
def log_app(msg: str):
|
||||||
"""Log application messages.
|
"""Log application messages.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Handles downloading pages from the Reticulum network using
|
Handles downloading pages from the Reticulum network using
|
||||||
the nomadnetwork protocol.
|
the nomadnetwork protocol.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -10,7 +11,6 @@ from dataclasses import dataclass
|
|||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PageRequest:
|
class PageRequest:
|
||||||
"""Represents a request for a page from the Reticulum network.
|
"""Represents a request for a page from the Reticulum network.
|
||||||
@@ -22,6 +22,7 @@ class PageRequest:
|
|||||||
page_path: str
|
page_path: str
|
||||||
field_data: dict | None = None
|
field_data: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
class PageFetcher:
|
class PageFetcher:
|
||||||
"""Fetcher to download pages from the Reticulum network."""
|
"""Fetcher to download pages from the Reticulum network."""
|
||||||
|
|
||||||
@@ -43,7 +44,9 @@ class PageFetcher:
|
|||||||
Exception: If no path to destination or identity not found.
|
Exception: If no path to destination or identity not found.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
RNS.log(f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}")
|
RNS.log(
|
||||||
|
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):
|
||||||
RNS.Transport.request_path(dest_bytes)
|
RNS.Transport.request_path(dest_bytes)
|
||||||
@@ -79,9 +82,16 @@ class PageFetcher:
|
|||||||
ev.set()
|
ev.set()
|
||||||
|
|
||||||
link.set_link_established_callback(
|
link.set_link_established_callback(
|
||||||
lambda link: link.request(req.page_path, req.field_data, response_callback=on_response, failed_callback=on_failed)
|
lambda link: link.request(
|
||||||
|
req.page_path,
|
||||||
|
req.field_data,
|
||||||
|
response_callback=on_response,
|
||||||
|
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(f"PageFetcher: received data for {req.destination_hash}:{req.page_path}")
|
RNS.log(
|
||||||
|
f"PageFetcher: received data for {req.destination_hash}:{req.page_path}"
|
||||||
|
)
|
||||||
return data_str
|
return data_str
|
||||||
|
|||||||
@@ -3,24 +3,541 @@
|
|||||||
Provides rendering capabilities for micron markup content,
|
Provides rendering capabilities for micron markup content,
|
||||||
currently implemented as a placeholder.
|
currently implemented as a placeholder.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
|
|
||||||
def render_micron(content: str) -> ft.Control:
|
class MicronParser:
|
||||||
"""Render micron markup content to a Flet control placeholder.
|
"""Parses micron markup and converts it to Flet controls.
|
||||||
|
|
||||||
Currently displays raw content.
|
Supports headings, dividers, inline formatting, ASCII art detection,
|
||||||
|
and color/formatting codes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dark_theme=True, enable_force_monospace=True, ascii_art_scale=0.75):
|
||||||
|
"""Initialize the MicronParser.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dark_theme (bool): Whether to use dark theme styles.
|
||||||
|
enable_force_monospace (bool): If True, force monospace font.
|
||||||
|
ascii_art_scale (float): Scale factor for ASCII art font size.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.dark_theme = dark_theme
|
||||||
|
self.enable_force_monospace = enable_force_monospace
|
||||||
|
self.ascii_art_scale = ascii_art_scale
|
||||||
|
self.DEFAULT_FG_DARK = "ddd"
|
||||||
|
self.DEFAULT_FG_LIGHT = "222"
|
||||||
|
self.DEFAULT_BG = "default"
|
||||||
|
|
||||||
|
self.SELECTED_STYLES = None
|
||||||
|
|
||||||
|
self.STYLES_DARK = {
|
||||||
|
"plain": {"fg": self.DEFAULT_FG_DARK, "bg": self.DEFAULT_BG, "bold": False, "underline": False, "italic": False},
|
||||||
|
"heading1": {"fg": "000", "bg": "bbb", "bold": True, "underline": False, "italic": False},
|
||||||
|
"heading2": {"fg": "000", "bg": "999", "bold": True, "underline": False, "italic": False},
|
||||||
|
"heading3": {"fg": "fff", "bg": "777", "bold": True, "underline": False, "italic": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.STYLES_LIGHT = {
|
||||||
|
"plain": {"fg": self.DEFAULT_FG_LIGHT, "bg": self.DEFAULT_BG, "bold": False, "underline": False, "italic": False},
|
||||||
|
"heading1": {"fg": "fff", "bg": "777", "bold": True, "underline": False, "italic": False},
|
||||||
|
"heading2": {"fg": "000", "bg": "aaa", "bold": True, "underline": False, "italic": False},
|
||||||
|
"heading3": {"fg": "000", "bg": "ccc", "bold": True, "underline": False, "italic": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.dark_theme:
|
||||||
|
self.SELECTED_STYLES = self.STYLES_DARK
|
||||||
|
else:
|
||||||
|
self.SELECTED_STYLES = self.STYLES_LIGHT
|
||||||
|
|
||||||
|
def convert_micron_to_controls(self, markup: str) -> list[ft.Control]:
|
||||||
|
"""Convert micron markup to a list of Flet controls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markup (str): The micron markup string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[ft.Control]: List of Flet controls representing the markup.
|
||||||
|
|
||||||
|
"""
|
||||||
|
controls = []
|
||||||
|
state = self._init_state()
|
||||||
|
lines = markup.split("\n")
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line_controls = self._parse_line(line, state)
|
||||||
|
if line_controls:
|
||||||
|
controls.extend(line_controls)
|
||||||
|
|
||||||
|
return controls
|
||||||
|
|
||||||
|
def _init_state(self) -> dict:
|
||||||
|
"""Initialize the parsing state for a new document.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The initial state dictionary.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"literal": False,
|
||||||
|
"depth": 0,
|
||||||
|
"fg_color": self.SELECTED_STYLES["plain"]["fg"],
|
||||||
|
"bg_color": self.DEFAULT_BG,
|
||||||
|
"formatting": {
|
||||||
|
"bold": False,
|
||||||
|
"underline": False,
|
||||||
|
"italic": False,
|
||||||
|
},
|
||||||
|
"default_align": "left",
|
||||||
|
"align": "left",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_line(self, line: str, state: dict) -> list[ft.Control]:
|
||||||
|
"""Parse a single line of micron markup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line (str): The line to parse.
|
||||||
|
state (dict): The current parsing state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[ft.Control]: Controls for this line, or empty if none.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not line:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if line == "`=":
|
||||||
|
state["literal"] = not state["literal"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not state["literal"] and line.startswith("#"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not state["literal"] and line.startswith("<"):
|
||||||
|
state["depth"] = 0
|
||||||
|
return self._parse_line(line[1:], state)
|
||||||
|
|
||||||
|
if not state["literal"] and line.startswith(">"):
|
||||||
|
return self._parse_heading(line, state)
|
||||||
|
|
||||||
|
if not state["literal"] and line.startswith("-"):
|
||||||
|
return self._parse_divider(line, state)
|
||||||
|
|
||||||
|
return self._parse_inline_formatting(line, state)
|
||||||
|
|
||||||
|
def _parse_inline_formatting(self, line: str, state: dict) -> list[ft.Control]:
|
||||||
|
"""Parse inline formatting codes in a line and return Flet controls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line (str): The line to parse.
|
||||||
|
state (dict): The current parsing state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[ft.Control]: Controls for the formatted line.
|
||||||
|
|
||||||
|
"""
|
||||||
|
spans = []
|
||||||
|
current_text = ""
|
||||||
|
current_style = {
|
||||||
|
"fg": state["fg_color"],
|
||||||
|
"bg": state["bg_color"],
|
||||||
|
"bold": state["formatting"]["bold"],
|
||||||
|
"underline": state["formatting"]["underline"],
|
||||||
|
"italic": state["formatting"]["italic"],
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = "text"
|
||||||
|
i = 0
|
||||||
|
skip = 0
|
||||||
|
|
||||||
|
def flush_current():
|
||||||
|
"""Flush the current text buffer into a span."""
|
||||||
|
nonlocal current_text
|
||||||
|
if current_text:
|
||||||
|
spans.append(MicronParser._create_span(current_text, current_style))
|
||||||
|
current_text = ""
|
||||||
|
|
||||||
|
while i < len(line):
|
||||||
|
if skip > 0:
|
||||||
|
skip -= 1
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
char = line[i]
|
||||||
|
"""
|
||||||
|
Handle backticks for formatting:
|
||||||
|
- Double backtick (``) resets formatting and alignment.
|
||||||
|
- Single backtick toggles formatting mode.
|
||||||
|
"""
|
||||||
|
if char == "`":
|
||||||
|
flush_current()
|
||||||
|
if i + 1 < len(line) and line[i + 1] == "`":
|
||||||
|
current_style["bold"] = False
|
||||||
|
current_style["underline"] = False
|
||||||
|
current_style["italic"] = False
|
||||||
|
current_style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
|
||||||
|
current_style["bg"] = self.DEFAULT_BG
|
||||||
|
state["align"] = state["default_align"]
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
mode = "formatting" if mode == "text" else "text"
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mode == "formatting":
|
||||||
|
handled = False
|
||||||
|
if char == "_":
|
||||||
|
current_style["underline"] = not current_style["underline"]
|
||||||
|
handled = True
|
||||||
|
elif char == "!":
|
||||||
|
current_style["bold"] = not current_style["bold"]
|
||||||
|
handled = True
|
||||||
|
elif char == "*":
|
||||||
|
current_style["italic"] = not current_style["italic"]
|
||||||
|
handled = True
|
||||||
|
elif char == "F":
|
||||||
|
if len(line) >= i + 4:
|
||||||
|
current_style["fg"] = line[i + 1:i + 4]
|
||||||
|
skip = 3
|
||||||
|
handled = True
|
||||||
|
elif char == "f":
|
||||||
|
current_style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
|
||||||
|
handled = True
|
||||||
|
elif char == "B":
|
||||||
|
if len(line) >= i + 4:
|
||||||
|
current_style["bg"] = line[i + 1:i + 4]
|
||||||
|
skip = 3
|
||||||
|
handled = True
|
||||||
|
elif char == "b":
|
||||||
|
current_style["bg"] = self.DEFAULT_BG
|
||||||
|
handled = True
|
||||||
|
elif char == "c":
|
||||||
|
state["align"] = "center"
|
||||||
|
handled = True
|
||||||
|
elif char == "l":
|
||||||
|
state["align"] = "left"
|
||||||
|
handled = True
|
||||||
|
elif char == "r":
|
||||||
|
state["align"] = "right"
|
||||||
|
handled = True
|
||||||
|
elif char == "a":
|
||||||
|
state["align"] = state["default_align"]
|
||||||
|
handled = True
|
||||||
|
|
||||||
|
if not handled:
|
||||||
|
current_text += char
|
||||||
|
|
||||||
|
else:
|
||||||
|
current_text += char
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
flush_current()
|
||||||
|
|
||||||
|
if spans:
|
||||||
|
is_art = MicronParser._is_ascii_art("".join(span.text for span in spans))
|
||||||
|
font_size = 12 * self.ascii_art_scale if is_art else None
|
||||||
|
text_control = ft.Text(spans=spans, text_align=state["align"], selectable=True, enable_interactive_selection=True, expand=True, font_family="monospace", size=font_size)
|
||||||
|
else:
|
||||||
|
is_art = MicronParser._is_ascii_art(line)
|
||||||
|
font_size = 12 * self.ascii_art_scale if is_art else None
|
||||||
|
text_control = ft.Text(line, text_align=state["align"], selectable=True, enable_interactive_selection=True, expand=True, font_family="monospace", size=font_size)
|
||||||
|
|
||||||
|
if state["depth"] > 0:
|
||||||
|
indent_em = (state["depth"] - 1) * 1.2
|
||||||
|
text_control = ft.Container(
|
||||||
|
content=text_control,
|
||||||
|
margin=ft.margin.only(left=indent_em * 16),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [text_control]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_span(text: str, style: dict) -> ft.TextSpan:
|
||||||
|
"""Create a Flet TextSpan with the given style.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): The text for the span.
|
||||||
|
style (dict): The style dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ft.TextSpan: The styled text span.
|
||||||
|
|
||||||
|
"""
|
||||||
|
flet_style = ft.TextStyle(
|
||||||
|
color=MicronParser._color_to_flet(style["fg"]),
|
||||||
|
bgcolor=MicronParser._color_to_flet(style["bg"]),
|
||||||
|
weight=ft.FontWeight.BOLD if style["bold"] else ft.FontWeight.NORMAL,
|
||||||
|
decoration=ft.TextDecoration.UNDERLINE if style["underline"] else ft.TextDecoration.NONE,
|
||||||
|
italic=style["italic"],
|
||||||
|
)
|
||||||
|
return ft.TextSpan(text, flet_style)
|
||||||
|
|
||||||
|
def _apply_format_code_to_style(self, code: str, style: dict, state: dict):
|
||||||
|
"""Apply a micron format code to a style dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code (str): The format code.
|
||||||
|
style (dict): The style dictionary to modify.
|
||||||
|
state (dict): The current parsing state.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not code:
|
||||||
|
return
|
||||||
|
|
||||||
|
if code == "`":
|
||||||
|
style["bold"] = False
|
||||||
|
style["underline"] = False
|
||||||
|
style["italic"] = False
|
||||||
|
style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
|
||||||
|
style["bg"] = self.DEFAULT_BG
|
||||||
|
return
|
||||||
|
|
||||||
|
if "!" in code:
|
||||||
|
style["bold"] = not style["bold"]
|
||||||
|
if "_" in code:
|
||||||
|
style["underline"] = not style["underline"]
|
||||||
|
if "*" in code:
|
||||||
|
style["italic"] = not style["italic"]
|
||||||
|
|
||||||
|
if code.startswith("F") and len(code) >= 4:
|
||||||
|
style["fg"] = code[1:4]
|
||||||
|
elif code.startswith("B") and len(code) >= 4:
|
||||||
|
style["bg"] = code[1:4]
|
||||||
|
|
||||||
|
if "f" in code:
|
||||||
|
style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
|
||||||
|
if "b" in code:
|
||||||
|
style["bg"] = self.DEFAULT_BG
|
||||||
|
|
||||||
|
def _apply_format_code(self, code: str, state: dict):
|
||||||
|
"""Apply a micron format code to the parsing state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code (str): The format code.
|
||||||
|
state (dict): The state dictionary to modify.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not code:
|
||||||
|
return
|
||||||
|
|
||||||
|
if code == "`":
|
||||||
|
state["formatting"]["bold"] = False
|
||||||
|
state["formatting"]["underline"] = False
|
||||||
|
state["formatting"]["italic"] = False
|
||||||
|
state["fg_color"] = self.SELECTED_STYLES["plain"]["fg"]
|
||||||
|
state["bg_color"] = self.DEFAULT_BG
|
||||||
|
state["align"] = state["default_align"]
|
||||||
|
return
|
||||||
|
|
||||||
|
if "!" in code:
|
||||||
|
state["formatting"]["bold"] = not state["formatting"]["bold"]
|
||||||
|
if "_" in code:
|
||||||
|
state["formatting"]["underline"] = not state["formatting"]["underline"]
|
||||||
|
if "*" in code:
|
||||||
|
state["formatting"]["italic"] = not state["formatting"]["italic"]
|
||||||
|
|
||||||
|
if code.startswith("F") and len(code) >= 4:
|
||||||
|
state["fg_color"] = code[1:4]
|
||||||
|
elif code.startswith("B") and len(code) >= 4:
|
||||||
|
state["bg_color"] = code[1:4]
|
||||||
|
|
||||||
|
if "f" in code:
|
||||||
|
state["fg_color"] = self.SELECTED_STYLES["plain"]["fg"]
|
||||||
|
if "b" in code:
|
||||||
|
state["bg_color"] = self.DEFAULT_BG
|
||||||
|
|
||||||
|
if "c" in code:
|
||||||
|
state["align"] = "center"
|
||||||
|
elif "l" in code:
|
||||||
|
state["align"] = "left"
|
||||||
|
elif "r" in code:
|
||||||
|
state["align"] = "right"
|
||||||
|
elif "a" in code:
|
||||||
|
state["align"] = state["default_align"]
|
||||||
|
|
||||||
|
def _parse_divider(self, line: str, state: dict) -> list[ft.Control]:
|
||||||
|
"""Parse a divider line and return a Flet Divider or styled Text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line (str): The divider line.
|
||||||
|
state (dict): The current parsing state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[ft.Control]: Controls for the divider.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if len(line) == 1:
|
||||||
|
return [ft.Divider()]
|
||||||
|
divider_char = line[1] if len(line) > 1 else "-"
|
||||||
|
repeated = divider_char * 80
|
||||||
|
|
||||||
|
is_art = MicronParser._is_ascii_art(repeated)
|
||||||
|
font_size = 12 * self.ascii_art_scale if is_art else None
|
||||||
|
|
||||||
|
divider = ft.Text(
|
||||||
|
repeated,
|
||||||
|
font_family="monospace",
|
||||||
|
color=MicronParser._color_to_flet(state["fg_color"]),
|
||||||
|
bgcolor=MicronParser._color_to_flet(state["bg_color"]),
|
||||||
|
no_wrap=True,
|
||||||
|
overflow=ft.TextOverflow.CLIP,
|
||||||
|
selectable=False,
|
||||||
|
enable_interactive_selection=False,
|
||||||
|
size=font_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [divider]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _color_to_flet(color: str) -> str | None:
|
||||||
|
"""Convert micron color format to Flet color format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color (str): The micron color string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: The Flet color string or None if default/invalid.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not color or color == "default":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(color) == 3 and re.match(r"^[0-9a-fA-F]{3}$", color):
|
||||||
|
return f"#{color[0]*2}{color[1]*2}{color[2]*2}"
|
||||||
|
|
||||||
|
if len(color) == 6 and re.match(r"^[0-9a-fA-F]{6}$", color):
|
||||||
|
return f"#{color}"
|
||||||
|
|
||||||
|
if len(color) == 3 and color[0] == "g":
|
||||||
|
try:
|
||||||
|
val = int(color[1:])
|
||||||
|
if 0 <= val <= 99:
|
||||||
|
h = hex(int(val * 2.55))[2:].zfill(2)
|
||||||
|
return f"#{h}{h}{h}"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_ascii_art(text: str) -> bool:
|
||||||
|
"""Detect if text appears to be ASCII art.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): The text to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the text is likely ASCII art, False otherwise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not text or len(text) < 10:
|
||||||
|
return False
|
||||||
|
|
||||||
|
special_chars = set("│─┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬█▄▀▌▐■□▪▫▲▼◄►◆◇○●◎◢◣◥◤")
|
||||||
|
special_count = sum(1 for char in text if char in special_chars or (ord(char) > 127))
|
||||||
|
|
||||||
|
other_special = sum(1 for char in text if not char.isalnum() and char not in " \t")
|
||||||
|
|
||||||
|
total_chars = len(text.replace(" ", "").replace("\t", ""))
|
||||||
|
if total_chars == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
special_ratio = (special_count + other_special) / total_chars
|
||||||
|
return special_ratio > 0.3
|
||||||
|
|
||||||
|
def _parse_heading(self, line: str, state: dict) -> list[ft.Control]:
|
||||||
|
"""Parse heading lines (starting with '>') and return styled controls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line (str): The heading line.
|
||||||
|
state (dict): The current parsing state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[ft.Control]: Controls for the heading.
|
||||||
|
|
||||||
|
"""
|
||||||
|
heading_level = 0
|
||||||
|
for char in line:
|
||||||
|
if char == ">":
|
||||||
|
heading_level += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
state["depth"] = heading_level
|
||||||
|
|
||||||
|
heading_text = line[heading_level:].strip()
|
||||||
|
|
||||||
|
if heading_text:
|
||||||
|
style_key = f"heading{min(heading_level, 3)}"
|
||||||
|
style = self.SELECTED_STYLES.get(style_key, self.SELECTED_STYLES["plain"])
|
||||||
|
|
||||||
|
is_art = MicronParser._is_ascii_art(heading_text)
|
||||||
|
base_size = 20 - heading_level * 2
|
||||||
|
font_size = base_size * self.ascii_art_scale if is_art else base_size
|
||||||
|
|
||||||
|
indent_em = max(0, (state["depth"] - 1) * 1.2)
|
||||||
|
|
||||||
|
heading = ft.Text(
|
||||||
|
heading_text,
|
||||||
|
style=ft.TextStyle(
|
||||||
|
color=MicronParser._color_to_flet(style["fg"]),
|
||||||
|
weight=ft.FontWeight.BOLD if style["bold"] else ft.FontWeight.NORMAL,
|
||||||
|
size=font_size,
|
||||||
|
),
|
||||||
|
selectable=True,
|
||||||
|
enable_interactive_selection=True,
|
||||||
|
expand=True,
|
||||||
|
font_family="monospace",
|
||||||
|
)
|
||||||
|
|
||||||
|
bg_color = MicronParser._color_to_flet(style["bg"])
|
||||||
|
if bg_color:
|
||||||
|
heading = ft.Container(
|
||||||
|
content=ft.Container(
|
||||||
|
content=heading,
|
||||||
|
margin=ft.margin.only(left=indent_em * 16) if indent_em > 0 else None,
|
||||||
|
padding=ft.padding.symmetric(horizontal=4),
|
||||||
|
),
|
||||||
|
bgcolor=bg_color,
|
||||||
|
width=float("inf"),
|
||||||
|
)
|
||||||
|
elif indent_em > 0:
|
||||||
|
heading = ft.Container(
|
||||||
|
content=heading,
|
||||||
|
margin=ft.margin.only(left=indent_em * 16),
|
||||||
|
padding=ft.padding.symmetric(horizontal=4),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [heading]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def render_micron(content: str, ascii_art_scale: float = 0.75) -> ft.Control:
|
||||||
|
"""Render micron markup content to a Flet control.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: Micron markup content to render.
|
content: Micron markup content to render.
|
||||||
|
ascii_art_scale: Scale factor for ASCII art (0.0-1.0). Default 0.75.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ft.Control: Rendered content as a Flet control.
|
ft.Control: Rendered content as a Flet control.
|
||||||
|
|
||||||
|
This function parses the micron markup, merges adjacent text controls
|
||||||
|
with the same style, and returns a Flet ListView containing the result.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return ft.Text(
|
parser = MicronParser(ascii_art_scale=ascii_art_scale)
|
||||||
content,
|
controls = parser.convert_micron_to_controls(content)
|
||||||
selectable=True,
|
|
||||||
font_family="monospace",
|
return ft.Container(
|
||||||
|
content=ft.ListView(
|
||||||
|
controls=controls,
|
||||||
|
spacing=2,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
expand=True,
|
expand=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Provides fallback rendering for plaintext content and source viewing.
|
Provides fallback rendering for plaintext content and source viewing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Provides persistent storage for configuration, bookmarks, history,
|
Provides persistent storage for configuration, bookmarks, history,
|
||||||
and other application data across different platforms.
|
and other application data across different platforms.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -36,9 +37,11 @@ class StorageManager:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
|
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
|
||||||
# Android - use app's private files directory
|
# Android - use user-accessible external storage
|
||||||
storage_dir = pathlib.Path("/data/data/com.ren_browser/files")
|
storage_dir = pathlib.Path("/storage/emulated/0/Documents/ren_browser")
|
||||||
elif hasattr(os, "uname") and "iOS" in str(getattr(os, "uname", lambda: "")()).replace("iPhone", "iOS"):
|
elif hasattr(os, "uname") and "iOS" in str(
|
||||||
|
getattr(os, "uname", lambda: "")()
|
||||||
|
).replace("iPhone", "iOS"):
|
||||||
# iOS - use app's documents directory
|
# iOS - use app's documents directory
|
||||||
storage_dir = pathlib.Path.home() / "Documents" / "ren_browser"
|
storage_dir = pathlib.Path.home() / "Documents" / "ren_browser"
|
||||||
else:
|
else:
|
||||||
@@ -46,7 +49,9 @@ class StorageManager:
|
|||||||
if "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 = pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser"
|
storage_dir = (
|
||||||
|
pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
storage_dir = pathlib.Path.home() / ".ren_browser"
|
storage_dir = pathlib.Path.home() / ".ren_browser"
|
||||||
|
|
||||||
@@ -58,6 +63,7 @@ class StorageManager:
|
|||||||
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
except (OSError, PermissionError):
|
except (OSError, PermissionError):
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
self._storage_dir = pathlib.Path(tempfile.gettempdir()) / "ren_browser"
|
self._storage_dir = pathlib.Path(tempfile.gettempdir()) / "ren_browser"
|
||||||
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -65,17 +71,21 @@ class StorageManager:
|
|||||||
"""Get the path to the main configuration file."""
|
"""Get the path to the main configuration file."""
|
||||||
return self._storage_dir / "config"
|
return self._storage_dir / "config"
|
||||||
|
|
||||||
@staticmethod
|
def get_reticulum_config_path(self) -> pathlib.Path:
|
||||||
def get_reticulum_config_path() -> pathlib.Path:
|
|
||||||
"""Get the path to the Reticulum configuration directory."""
|
"""Get the path to the Reticulum configuration directory."""
|
||||||
# Check for global override from app
|
# Check for global override from app
|
||||||
try:
|
try:
|
||||||
from ren_browser.app import RNS_CONFIG_DIR
|
from ren_browser.app import RNS_CONFIG_DIR
|
||||||
|
|
||||||
if RNS_CONFIG_DIR:
|
if RNS_CONFIG_DIR:
|
||||||
return pathlib.Path(RNS_CONFIG_DIR)
|
return pathlib.Path(RNS_CONFIG_DIR)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# On Android, use app storage directory instead of ~/.reticulum
|
||||||
|
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
|
||||||
|
return self._storage_dir / "reticulum"
|
||||||
|
|
||||||
# Default to standard RNS config directory
|
# Default to standard RNS config directory
|
||||||
return pathlib.Path.home() / ".reticulum"
|
return pathlib.Path.home() / ".reticulum"
|
||||||
|
|
||||||
@@ -90,6 +100,7 @@ class StorageManager:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Always save to client storage first (most reliable on mobile)
|
||||||
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)
|
||||||
|
|
||||||
@@ -100,6 +111,7 @@ class StorageManager:
|
|||||||
|
|
||||||
# Also save to local config path as backup
|
# Also save to local config path as backup
|
||||||
config_path = self.get_config_path()
|
config_path = self.get_config_path()
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
config_path.write_text(config_content, encoding="utf-8")
|
config_path.write_text(config_content, encoding="utf-8")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -111,7 +123,9 @@ class StorageManager:
|
|||||||
try:
|
try:
|
||||||
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("ren_browser_config_error", f"File save failed: {error}")
|
self.page.client_storage.set(
|
||||||
|
"ren_browser_config_error", f"File save failed: {error}"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -122,6 +136,7 @@ class StorageManager:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
temp_path = pathlib.Path(tempfile.gettempdir()) / "ren_browser_config.txt"
|
temp_path = pathlib.Path(tempfile.gettempdir()) / "ren_browser_config.txt"
|
||||||
temp_path.write_text(config_content, encoding="utf-8")
|
temp_path.write_text(config_content, encoding="utf-8")
|
||||||
return True
|
return True
|
||||||
@@ -136,6 +151,13 @@ class StorageManager:
|
|||||||
Configuration text, or empty string if not found
|
Configuration text, or empty string if not found
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# On Android, prioritize client storage first as it's more reliable
|
||||||
|
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
|
||||||
|
if self.page and hasattr(self.page, "client_storage"):
|
||||||
|
stored_config = self.page.client_storage.get("ren_browser_config")
|
||||||
|
if stored_config:
|
||||||
|
return stored_config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reticulum_config_path = self.get_reticulum_config_path() / "config"
|
reticulum_config_path = self.get_reticulum_config_path() / "config"
|
||||||
if reticulum_config_path.exists():
|
if reticulum_config_path.exists():
|
||||||
@@ -145,13 +167,18 @@ class StorageManager:
|
|||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
return config_path.read_text(encoding="utf-8")
|
return config_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Fallback to client storage for non-Android or if files don't exist
|
||||||
if self.page and hasattr(self.page, "client_storage"):
|
if self.page and hasattr(self.page, "client_storage"):
|
||||||
stored_config = self.page.client_storage.get("ren_browser_config")
|
stored_config = self.page.client_storage.get("ren_browser_config")
|
||||||
if stored_config:
|
if stored_config:
|
||||||
return stored_config
|
return stored_config
|
||||||
|
|
||||||
except (OSError, PermissionError, UnicodeDecodeError):
|
except (OSError, PermissionError, UnicodeDecodeError):
|
||||||
pass
|
# If file access fails, try client storage as fallback
|
||||||
|
if self.page and hasattr(self.page, "client_storage"):
|
||||||
|
stored_config = self.page.client_storage.get("ren_browser_config")
|
||||||
|
if stored_config:
|
||||||
|
return stored_config
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -163,7 +190,9 @@ class StorageManager:
|
|||||||
json.dump(bookmarks, f, indent=2)
|
json.dump(bookmarks, f, indent=2)
|
||||||
|
|
||||||
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_bookmarks", json.dumps(bookmarks))
|
self.page.client_storage.set(
|
||||||
|
"ren_browser_bookmarks", json.dumps(bookmarks)
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -267,6 +296,7 @@ def get_rns_config_directory() -> str:
|
|||||||
"""Get the RNS config directory, checking for global override."""
|
"""Get the RNS config directory, checking for global override."""
|
||||||
try:
|
try:
|
||||||
from ren_browser.app import RNS_CONFIG_DIR
|
from ren_browser.app import RNS_CONFIG_DIR
|
||||||
|
|
||||||
if RNS_CONFIG_DIR:
|
if RNS_CONFIG_DIR:
|
||||||
return RNS_CONFIG_DIR
|
return RNS_CONFIG_DIR
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Provides tab creation, switching, and content management functionality
|
Provides tab creation, switching, and content management functionality
|
||||||
for the browser interface.
|
for the browser interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
@@ -25,15 +26,26 @@ class TabsManager:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import ren_browser.app as app_module
|
import ren_browser.app as app_module
|
||||||
|
|
||||||
self.page = page
|
self.page = page
|
||||||
self.manager = SimpleNamespace(tabs=[], index=0)
|
self.manager = SimpleNamespace(tabs=[], index=0)
|
||||||
self.tab_bar = ft.Row(spacing=4)
|
self.tab_bar = ft.Row(spacing=4)
|
||||||
self.content_container = ft.Container(expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5))
|
self.content_container = ft.Container(
|
||||||
|
expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5)
|
||||||
|
)
|
||||||
|
|
||||||
default_content = render_micron("Welcome to Ren Browser") if app_module.RENDERER == "micron" else render_plaintext("Welcome to Ren Browser")
|
default_content = (
|
||||||
|
render_micron("Welcome to Ren Browser")
|
||||||
|
if app_module.RENDERER == "micron"
|
||||||
|
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(ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click)
|
self.add_btn = ft.IconButton(
|
||||||
self.close_btn = ft.IconButton(ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click)
|
ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click
|
||||||
|
)
|
||||||
|
self.close_btn = ft.IconButton(
|
||||||
|
ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click
|
||||||
|
)
|
||||||
self.tab_bar.controls.extend([self.add_btn, self.close_btn])
|
self.tab_bar.controls.extend([self.add_btn, self.close_btn])
|
||||||
self.select_tab(0)
|
self.select_tab(0)
|
||||||
|
|
||||||
@@ -43,9 +55,13 @@ class TabsManager:
|
|||||||
value=title,
|
value=title,
|
||||||
expand=True,
|
expand=True,
|
||||||
text_style=ft.TextStyle(size=12),
|
text_style=ft.TextStyle(size=12),
|
||||||
content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8)
|
content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8),
|
||||||
|
)
|
||||||
|
go_btn = ft.IconButton(
|
||||||
|
ft.Icons.OPEN_IN_BROWSER,
|
||||||
|
tooltip="Load URL",
|
||||||
|
on_click=lambda e, i=idx: self._on_tab_go(e, i),
|
||||||
)
|
)
|
||||||
go_btn = ft.IconButton(ft.Icons.OPEN_IN_BROWSER, tooltip="Load URL", on_click=lambda e, i=idx: self._on_tab_go(e, i))
|
|
||||||
content_control = content
|
content_control = content
|
||||||
tab_content = ft.Column(
|
tab_content = ft.Column(
|
||||||
expand=True,
|
expand=True,
|
||||||
@@ -53,13 +69,15 @@ class TabsManager:
|
|||||||
content_control,
|
content_control,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.manager.tabs.append({
|
self.manager.tabs.append(
|
||||||
"title": title,
|
{
|
||||||
"url_field": url_field,
|
"title": title,
|
||||||
"go_btn": go_btn,
|
"url_field": url_field,
|
||||||
"content_control": content_control,
|
"go_btn": go_btn,
|
||||||
"content": tab_content,
|
"content_control": content_control,
|
||||||
})
|
"content": tab_content,
|
||||||
|
}
|
||||||
|
)
|
||||||
btn = ft.Container(
|
btn = ft.Container(
|
||||||
content=ft.Text(title),
|
content=ft.Text(title),
|
||||||
on_click=lambda e, i=idx: self.select_tab(i),
|
on_click=lambda e, i=idx: self.select_tab(i),
|
||||||
@@ -74,7 +92,12 @@ class TabsManager:
|
|||||||
title = f"Tab {len(self.manager.tabs) + 1}"
|
title = f"Tab {len(self.manager.tabs) + 1}"
|
||||||
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
|
||||||
content = render_micron(content_text) if app_module.RENDERER == "micron" else render_plaintext(content_text)
|
|
||||||
|
content = (
|
||||||
|
render_micron(content_text)
|
||||||
|
if app_module.RENDERER == "micron"
|
||||||
|
else render_plaintext(content_text)
|
||||||
|
)
|
||||||
self._add_tab_internal(title, content)
|
self._add_tab_internal(title, content)
|
||||||
self.select_tab(len(self.manager.tabs) - 1)
|
self.select_tab(len(self.manager.tabs) - 1)
|
||||||
self.page.update()
|
self.page.update()
|
||||||
@@ -114,7 +137,12 @@ class TabsManager:
|
|||||||
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 = render_micron(placeholder_text) if app_module.RENDERER == "micron" else render_plaintext(placeholder_text)
|
|
||||||
|
new_control = (
|
||||||
|
render_micron(placeholder_text)
|
||||||
|
if app_module.RENDERER == "micron"
|
||||||
|
else render_plaintext(placeholder_text)
|
||||||
|
)
|
||||||
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:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Provides configuration management, log viewing, and storage
|
Provides configuration management, log viewing, and storage
|
||||||
information display.
|
information display.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
from ren_browser.logs import ERROR_LOGS, RET_LOGS
|
from ren_browser.logs import ERROR_LOGS, RET_LOGS
|
||||||
@@ -35,13 +36,23 @@ def open_settings_tab(page: ft.Page, tab_manager):
|
|||||||
try:
|
try:
|
||||||
success = storage.save_config(config_field.value)
|
success = storage.save_config(config_field.value)
|
||||||
if success:
|
if success:
|
||||||
page.snack_bar = ft.SnackBar(ft.Text("Config saved successfully. Please restart the app."), open=True)
|
print("Config saved successfully. Please restart the app.")
|
||||||
|
page.snack_bar = ft.SnackBar(
|
||||||
|
ft.Text("Config saved successfully. Please restart the app."),
|
||||||
|
open=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
page.snack_bar = ft.SnackBar(ft.Text("Error saving config: Storage operation failed"), open=True)
|
print("Error saving config: Storage operation failed")
|
||||||
|
page.snack_bar = ft.SnackBar(
|
||||||
|
ft.Text("Error saving config: Storage operation failed"), open=True
|
||||||
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
page.snack_bar = ft.SnackBar(ft.Text(f"Error saving config: {ex}"), open=True)
|
print(f"Error saving config: {ex}")
|
||||||
page.update()
|
page.snack_bar = ft.SnackBar(
|
||||||
save_btn = ft.ElevatedButton("Save and Restart", on_click=on_save_config)
|
ft.Text(f"Error saving config: {ex}"), open=True
|
||||||
|
)
|
||||||
|
|
||||||
|
save_btn = ft.ElevatedButton("Save Config", on_click=on_save_config)
|
||||||
error_field = ft.TextField(
|
error_field = ft.TextField(
|
||||||
label="Error Logs",
|
label="Error Logs",
|
||||||
value="",
|
value="",
|
||||||
@@ -69,22 +80,29 @@ def open_settings_tab(page: ft.Page, tab_manager):
|
|||||||
)
|
)
|
||||||
|
|
||||||
content_placeholder = ft.Container(expand=True)
|
content_placeholder = ft.Container(expand=True)
|
||||||
|
|
||||||
def show_config(ev):
|
def show_config(ev):
|
||||||
content_placeholder.content = config_field
|
content_placeholder.content = config_field
|
||||||
page.update()
|
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
|
||||||
page.update()
|
page.update()
|
||||||
|
|
||||||
def show_ret_logs(ev):
|
def show_ret_logs(ev):
|
||||||
ret_field.value = "\n".join(RET_LOGS) or "No Reticulum logs."
|
ret_field.value = "\n".join(RET_LOGS) or "No Reticulum logs."
|
||||||
content_placeholder.content = ret_field
|
content_placeholder.content = ret_field
|
||||||
page.update()
|
page.update()
|
||||||
|
|
||||||
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([f"{key}: {value}" for key, value in storage_info.items()])
|
storage_field.value = "\n".join(
|
||||||
|
[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
|
# Refresh the currently displayed content
|
||||||
if content_placeholder.content == error_field:
|
if content_placeholder.content == error_field:
|
||||||
@@ -95,12 +113,15 @@ def open_settings_tab(page: ft.Page, tab_manager):
|
|||||||
show_storage_info(ev)
|
show_storage_info(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.ElevatedButton("Config", on_click=show_config)
|
||||||
btn_errors = ft.ElevatedButton("Errors", on_click=show_errors)
|
btn_errors = ft.ElevatedButton("Errors", on_click=show_errors)
|
||||||
btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs)
|
btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs)
|
||||||
btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info)
|
btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info)
|
||||||
btn_refresh = ft.ElevatedButton("Refresh", on_click=refresh_current_view)
|
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])
|
button_row = ft.Row(
|
||||||
|
controls=[btn_config, btn_errors, btn_ret, btn_storage, btn_refresh]
|
||||||
|
)
|
||||||
content_placeholder.content = config_field
|
content_placeholder.content = config_field
|
||||||
settings_content = ft.Column(
|
settings_content = ft.Column(
|
||||||
expand=True,
|
expand=True,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Builds the complete browser interface including tabs, navigation,
|
Builds the complete browser interface including tabs, navigation,
|
||||||
announce handling, and content rendering.
|
announce handling, and content rendering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
from flet import Page
|
from flet import Page
|
||||||
|
|
||||||
@@ -27,10 +28,12 @@ def build_ui(page: Page):
|
|||||||
|
|
||||||
page_fetcher = PageFetcher()
|
page_fetcher = PageFetcher()
|
||||||
announce_list = ft.ListView(expand=True, spacing=1)
|
announce_list = ft.ListView(expand=True, spacing=1)
|
||||||
|
|
||||||
def update_announces(ann_list):
|
def update_announces(ann_list):
|
||||||
announce_list.controls.clear()
|
announce_list.controls.clear()
|
||||||
for ann in ann_list:
|
for ann in ann_list:
|
||||||
label = ann.display_name or ann.destination_hash
|
label = ann.display_name or ann.destination_hash
|
||||||
|
|
||||||
def on_click_ann(e, dest=ann.destination_hash, disp=ann.display_name):
|
def on_click_ann(e, dest=ann.destination_hash, disp=ann.display_name):
|
||||||
title = disp or "Anonymous"
|
title = disp or "Anonymous"
|
||||||
full_url = f"{dest}:/page/index.mu"
|
full_url = f"{dest}:/page/index.mu"
|
||||||
@@ -41,12 +44,14 @@ def build_ui(page: Page):
|
|||||||
tab["url_field"].value = full_url
|
tab["url_field"].value = full_url
|
||||||
tab_manager.select_tab(idx)
|
tab_manager.select_tab(idx)
|
||||||
page.update()
|
page.update()
|
||||||
|
|
||||||
def fetch_and_update():
|
def fetch_and_update():
|
||||||
req = PageRequest(destination_hash=dest, page_path="/page/index.mu")
|
req = PageRequest(destination_hash=dest, page_path="/page/index.mu")
|
||||||
try:
|
try:
|
||||||
result = page_fetcher.fetch_page(req)
|
result = page_fetcher.fetch_page(req)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
import ren_browser.app as app_module
|
import ren_browser.app as app_module
|
||||||
|
|
||||||
app_module.log_error(str(ex))
|
app_module.log_error(str(ex))
|
||||||
result = f"Error: {ex}"
|
result = f"Error: {ex}"
|
||||||
try:
|
try:
|
||||||
@@ -62,13 +67,21 @@ def build_ui(page: Page):
|
|||||||
if tab_manager.manager.index == idx:
|
if tab_manager.manager.index == idx:
|
||||||
tab_manager.content_container.content = tab["content"]
|
tab_manager.content_container.content = tab["content"]
|
||||||
page.update()
|
page.update()
|
||||||
|
|
||||||
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_list.controls.append(ft.TextButton(label, on_click=on_click_ann))
|
||||||
page.update()
|
page.update()
|
||||||
|
|
||||||
AnnounceService(update_callback=update_announces)
|
AnnounceService(update_callback=update_announces)
|
||||||
page.drawer = ft.NavigationDrawer(
|
page.drawer = ft.NavigationDrawer(
|
||||||
controls=[
|
controls=[
|
||||||
ft.Text("Announcements", weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER, expand=True),
|
ft.Text(
|
||||||
|
"Announcements",
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
ft.Divider(),
|
ft.Divider(),
|
||||||
announce_list,
|
announce_list,
|
||||||
],
|
],
|
||||||
@@ -76,12 +89,22 @@ def build_ui(page: Page):
|
|||||||
page.appbar.leading = ft.IconButton(
|
page.appbar.leading = ft.IconButton(
|
||||||
ft.Icons.MENU,
|
ft.Icons.MENU,
|
||||||
tooltip="Toggle sidebar",
|
tooltip="Toggle sidebar",
|
||||||
on_click=lambda e: (setattr(page.drawer, "open", not page.drawer.open), page.update()),
|
on_click=lambda e: (
|
||||||
|
setattr(page.drawer, "open", not page.drawer.open),
|
||||||
|
page.update(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
tab_manager = TabsManager(page)
|
tab_manager = TabsManager(page)
|
||||||
from ren_browser.ui.settings import open_settings_tab
|
from ren_browser.ui.settings import open_settings_tab
|
||||||
page.appbar.actions = [ft.IconButton(ft.Icons.SETTINGS, tooltip="Settings", on_click=lambda e: open_settings_tab(page, tab_manager))]
|
|
||||||
|
page.appbar.actions = [
|
||||||
|
ft.IconButton(
|
||||||
|
ft.Icons.SETTINGS,
|
||||||
|
tooltip="Settings",
|
||||||
|
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.Row(
|
||||||
controls=[
|
controls=[
|
||||||
@@ -91,15 +114,19 @@ def build_ui(page: Page):
|
|||||||
)
|
)
|
||||||
page.appbar.title = url_bar
|
page.appbar.title = url_bar
|
||||||
orig_select_tab = tab_manager.select_tab
|
orig_select_tab = tab_manager.select_tab
|
||||||
|
|
||||||
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.controls.clear()
|
||||||
url_bar.controls.extend([tab["url_field"], tab["go_btn"]])
|
url_bar.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
|
||||||
|
|
||||||
def _update_content_width(e=None):
|
def _update_content_width(e=None):
|
||||||
tab_manager.content_container.width = page.width
|
tab_manager.content_container.width = page.width
|
||||||
|
|
||||||
_update_content_width()
|
_update_content_width()
|
||||||
page.on_resized = lambda e: (_update_content_width(), page.update())
|
page.on_resized = lambda e: (_update_content_width(), page.update())
|
||||||
main_area = ft.Column(
|
main_area = ft.Column(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def mock_rns():
|
|||||||
|
|
||||||
# Mock at the module level for all imports
|
# Mock at the module level for all imports
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.modules["RNS"] = mock_rns
|
sys.modules["RNS"] = mock_rns
|
||||||
|
|
||||||
yield mock_rns
|
yield mock_rns
|
||||||
@@ -51,7 +52,7 @@ def sample_announce_data():
|
|||||||
return {
|
return {
|
||||||
"destination_hash": "1234567890abcdef",
|
"destination_hash": "1234567890abcdef",
|
||||||
"display_name": "Test Node",
|
"display_name": "Test Node",
|
||||||
"timestamp": 1234567890
|
"timestamp": 1234567890,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,10 +60,9 @@ def sample_announce_data():
|
|||||||
def sample_page_request():
|
def sample_page_request():
|
||||||
"""Sample page request for testing."""
|
"""Sample page request for testing."""
|
||||||
from ren_browser.pages.page_request import PageRequest
|
from ren_browser.pages.page_request import PageRequest
|
||||||
|
|
||||||
return PageRequest(
|
return PageRequest(
|
||||||
destination_hash="1234567890abcdef",
|
destination_hash="1234567890abcdef", page_path="/page/index.mu", field_data=None,
|
||||||
page_path="/page/index.mu",
|
|
||||||
field_data=None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,11 +75,11 @@ def mock_storage_manager():
|
|||||||
mock_storage.get_config_path.return_value = Mock()
|
mock_storage.get_config_path.return_value = Mock()
|
||||||
mock_storage.get_reticulum_config_path.return_value = Mock()
|
mock_storage.get_reticulum_config_path.return_value = Mock()
|
||||||
mock_storage.get_storage_info.return_value = {
|
mock_storage.get_storage_info.return_value = {
|
||||||
'storage_dir': '/mock/storage',
|
"storage_dir": "/mock/storage",
|
||||||
'config_path': '/mock/storage/config.txt',
|
"config_path": "/mock/storage/config.txt",
|
||||||
'reticulum_config_path': '/mock/storage/reticulum',
|
"reticulum_config_path": "/mock/storage/reticulum",
|
||||||
'storage_dir_exists': True,
|
"storage_dir_exists": True,
|
||||||
'storage_dir_writable': True,
|
"storage_dir_writable": True,
|
||||||
'has_client_storage': True,
|
"has_client_storage": True,
|
||||||
}
|
}
|
||||||
return mock_storage
|
return mock_storage
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ class TestAppIntegration:
|
|||||||
def test_entry_points_exist(self):
|
def test_entry_points_exist(self):
|
||||||
"""Test that all expected entry points exist and are callable."""
|
"""Test that all expected entry points exist and are callable."""
|
||||||
entry_points = [
|
entry_points = [
|
||||||
"run", "web", "android", "ios",
|
"run",
|
||||||
"run_dev", "web_dev", "android_dev", "ios_dev"
|
"web",
|
||||||
|
"android",
|
||||||
|
"ios",
|
||||||
|
"run_dev",
|
||||||
|
"web_dev",
|
||||||
|
"android_dev",
|
||||||
|
"ios_dev",
|
||||||
]
|
]
|
||||||
|
|
||||||
for entry_point in entry_points:
|
for entry_point in entry_points:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from ren_browser.announces.announces import Announce
|
from ren_browser.announces.announces import Announce
|
||||||
|
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ class TestAnnounce:
|
|||||||
announce = Announce(
|
announce = Announce(
|
||||||
destination_hash="1234567890abcdef",
|
destination_hash="1234567890abcdef",
|
||||||
display_name="Test Node",
|
display_name="Test Node",
|
||||||
timestamp=1234567890
|
timestamp=1234567890,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert announce.destination_hash == "1234567890abcdef"
|
assert announce.destination_hash == "1234567890abcdef"
|
||||||
@@ -20,18 +19,17 @@ 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",
|
destination_hash="1234567890abcdef", display_name=None, timestamp=1234567890,
|
||||||
display_name=None,
|
|
||||||
timestamp=1234567890
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert announce.destination_hash == "1234567890abcdef"
|
assert announce.destination_hash == "1234567890abcdef"
|
||||||
assert announce.display_name is None
|
assert announce.display_name is None
|
||||||
assert announce.timestamp == 1234567890
|
assert announce.timestamp == 1234567890
|
||||||
|
|
||||||
|
|
||||||
class TestAnnounceService:
|
class TestAnnounceService:
|
||||||
"""Test cases for the AnnounceService class.
|
"""Test cases for the AnnounceService class.
|
||||||
|
|
||||||
Note: These tests are simplified due to complex RNS integration.
|
Note: These tests are simplified due to complex RNS integration.
|
||||||
Full integration tests will be added in the future.
|
Full integration tests will be added in the future.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ class TestApp:
|
|||||||
|
|
||||||
def test_run_with_default_args(self, mock_rns):
|
def test_run_with_default_args(self, mock_rns):
|
||||||
"""Test run function with default arguments."""
|
"""Test run function with default arguments."""
|
||||||
with patch("sys.argv", ["ren-browser"]), \
|
with patch("sys.argv", ["ren-browser"]), patch("flet.app") as mock_ft_app:
|
||||||
patch("flet.app") as mock_ft_app:
|
|
||||||
|
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
mock_ft_app.assert_called_once()
|
mock_ft_app.assert_called_once()
|
||||||
@@ -46,9 +44,10 @@ class TestApp:
|
|||||||
|
|
||||||
def test_run_with_web_flag(self, mock_rns):
|
def test_run_with_web_flag(self, mock_rns):
|
||||||
"""Test run function with web flag."""
|
"""Test run function with web flag."""
|
||||||
with patch("sys.argv", ["ren-browser", "--web"]), \
|
with (
|
||||||
patch("flet.app") as mock_ft_app:
|
patch("sys.argv", ["ren-browser", "--web"]),
|
||||||
|
patch("flet.app") as mock_ft_app,
|
||||||
|
):
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
mock_ft_app.assert_called_once()
|
mock_ft_app.assert_called_once()
|
||||||
@@ -58,9 +57,10 @@ class TestApp:
|
|||||||
|
|
||||||
def test_run_with_web_and_port(self, mock_rns):
|
def test_run_with_web_and_port(self, mock_rns):
|
||||||
"""Test run function with web flag and custom port."""
|
"""Test run function with web flag and custom port."""
|
||||||
with patch("sys.argv", ["ren-browser", "--web", "--port", "8080"]), \
|
with (
|
||||||
patch("flet.app") as mock_ft_app:
|
patch("sys.argv", ["ren-browser", "--web", "--port", "8080"]),
|
||||||
|
patch("flet.app") as mock_ft_app,
|
||||||
|
):
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
mock_ft_app.assert_called_once()
|
mock_ft_app.assert_called_once()
|
||||||
@@ -71,9 +71,10 @@ class TestApp:
|
|||||||
|
|
||||||
def test_run_with_renderer_flag(self, mock_rns):
|
def test_run_with_renderer_flag(self, mock_rns):
|
||||||
"""Test run function with renderer selection."""
|
"""Test run function with renderer selection."""
|
||||||
with patch("sys.argv", ["ren-browser", "--renderer", "micron"]), \
|
with (
|
||||||
patch("flet.app"):
|
patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
|
||||||
|
patch("flet.app"),
|
||||||
|
):
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
assert app.RENDERER == "micron"
|
assert app.RENDERER == "micron"
|
||||||
@@ -131,8 +132,10 @@ class TestApp:
|
|||||||
"""Test that RENDERER global is properly updated."""
|
"""Test that RENDERER global is properly updated."""
|
||||||
original_renderer = app.RENDERER
|
original_renderer = app.RENDERER
|
||||||
|
|
||||||
with patch("sys.argv", ["ren-browser", "--renderer", "micron"]), \
|
with (
|
||||||
patch("flet.app"):
|
patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
|
||||||
|
patch("flet.app"),
|
||||||
|
):
|
||||||
app.run()
|
app.run()
|
||||||
assert app.RENDERER == "micron"
|
assert app.RENDERER == "micron"
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ 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("Test RNS message", "arg1", kwarg1="value1")
|
logs._original_rns_log.assert_called_once_with(
|
||||||
|
"Test RNS message", "arg1", kwarg1="value1",
|
||||||
|
)
|
||||||
assert result == "original_result"
|
assert result == "original_result"
|
||||||
|
|
||||||
def test_multiple_log_calls(self):
|
def test_multiple_log_calls(self):
|
||||||
|
|||||||
@@ -7,8 +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",
|
destination_hash="1234567890abcdef", page_path="/page/index.mu",
|
||||||
page_path="/page/index.mu"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert request.destination_hash == "1234567890abcdef"
|
assert request.destination_hash == "1234567890abcdef"
|
||||||
@@ -21,7 +20,7 @@ class TestPageRequest:
|
|||||||
request = PageRequest(
|
request = PageRequest(
|
||||||
destination_hash="1234567890abcdef",
|
destination_hash="1234567890abcdef",
|
||||||
page_path="/page/form.mu",
|
page_path="/page/form.mu",
|
||||||
field_data=field_data
|
field_data=field_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert request.destination_hash == "1234567890abcdef"
|
assert request.destination_hash == "1234567890abcdef"
|
||||||
@@ -48,7 +47,7 @@ class TestPageRequest:
|
|||||||
# These will be implemented when the networking layer is more stable.
|
# These will be implemented when the networking layer is more stable.
|
||||||
class TestPageFetcher:
|
class TestPageFetcher:
|
||||||
"""Test cases for the PageFetcher class.
|
"""Test cases for the PageFetcher class.
|
||||||
|
|
||||||
Note: These tests are simplified due to complex RNS networking integration.
|
Note: These tests are simplified due to complex RNS networking integration.
|
||||||
Full integration tests will be added when the networking layer is stable.
|
Full integration tests will be added when the networking layer is stable.
|
||||||
"""
|
"""
|
||||||
@@ -59,7 +58,7 @@ class TestPageFetcher:
|
|||||||
requests = [
|
requests = [
|
||||||
PageRequest("hash1", "/index.mu"),
|
PageRequest("hash1", "/index.mu"),
|
||||||
PageRequest("hash2", "/about.mu", {"form": "data"}),
|
PageRequest("hash2", "/about.mu", {"form": "data"}),
|
||||||
PageRequest("hash3", "/contact.mu")
|
PageRequest("hash3", "/contact.mu"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Test that requests have the expected structure
|
# Test that requests have the expected structure
|
||||||
|
|||||||
@@ -57,54 +57,304 @@ class TestPlaintextRenderer:
|
|||||||
|
|
||||||
class TestMicronRenderer:
|
class TestMicronRenderer:
|
||||||
"""Test cases for the micron renderer.
|
"""Test cases for the micron renderer.
|
||||||
|
|
||||||
Note: The micron renderer is currently a placeholder implementation
|
The micron renderer parses Micron markup format and returns a ListView
|
||||||
that displays raw content without markup processing.
|
containing styled controls with proper formatting, colors, and layout.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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)
|
# Should return a Container with ListView
|
||||||
assert result.value == "# Heading\n\nSome content"
|
assert isinstance(result, ft.Container)
|
||||||
assert result.selectable is True
|
assert isinstance(result.content, ft.ListView)
|
||||||
assert result.font_family == "monospace"
|
assert result.content.spacing == 2
|
||||||
assert result.expand is True
|
|
||||||
|
# Should contain controls
|
||||||
|
assert len(result.content.controls) > 0
|
||||||
|
for control in result.content.controls:
|
||||||
|
assert control.selectable is True
|
||||||
|
assert control.font_family == "monospace"
|
||||||
|
|
||||||
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)
|
# Should return a Container
|
||||||
assert result.value == ""
|
assert isinstance(result, ft.Container)
|
||||||
assert result.selectable is True
|
|
||||||
|
# May contain empty controls
|
||||||
|
|
||||||
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)
|
# Should return a Container
|
||||||
assert result.value == content
|
assert isinstance(result, ft.Container)
|
||||||
assert result.selectable is True
|
|
||||||
|
# Should contain controls with the content
|
||||||
|
assert len(result.content.controls) > 0
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
# Extract text from the control
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
elif isinstance(control, ft.Container) and hasattr(control, "content"):
|
||||||
|
# Handle indented text controls
|
||||||
|
if isinstance(control.content, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control.content, "value") and control.content.value:
|
||||||
|
text_content = control.content.value
|
||||||
|
elif hasattr(control.content, "spans") and control.content.spans:
|
||||||
|
text_content = "".join(span.text for span in control.content.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
|
||||||
|
# Remove trailing newline and should preserve the content
|
||||||
|
all_text = all_text.rstrip("\n")
|
||||||
|
assert content in all_text
|
||||||
|
|
||||||
|
def test_render_micron_headings(self):
|
||||||
|
"""Test micron rendering with different heading levels."""
|
||||||
|
content = "> Level 1\n>> Level 2\n>>> Level 3"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
assert len(result.content.controls) == 3
|
||||||
|
|
||||||
|
# Check that headings are wrapped in containers with backgrounds
|
||||||
|
for control in result.content.controls:
|
||||||
|
assert isinstance(control, ft.Container)
|
||||||
|
assert control.bgcolor is not None # Should have background color
|
||||||
|
assert control.width == float("inf") # Should be full width
|
||||||
|
|
||||||
|
def test_render_micron_formatting(self):
|
||||||
|
"""Test micron rendering with text formatting."""
|
||||||
|
content = "`!Bold text!` and `_underline_` and `*italic*`"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
assert len(result.content.controls) >= 1
|
||||||
|
|
||||||
|
# Should produce some text content
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
|
||||||
|
assert len(all_text.strip()) > 0 # Should have some processed content
|
||||||
|
|
||||||
|
def test_render_micron_colors(self):
|
||||||
|
"""Test micron rendering with color codes."""
|
||||||
|
content = "`FffRed text` and `B00Blue background`"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
assert len(result.content.controls) >= 1
|
||||||
|
|
||||||
|
# Should produce some text content (color codes may consume characters)
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
|
||||||
|
assert len(all_text.strip()) > 0 # Should have some processed content
|
||||||
|
|
||||||
|
def test_render_micron_alignment(self):
|
||||||
|
"""Test micron rendering with alignment."""
|
||||||
|
content = "`cCentered text`"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
assert len(result.content.controls) >= 1
|
||||||
|
|
||||||
|
# Should have some text content
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
|
||||||
|
assert len(all_text.strip()) > 0
|
||||||
|
|
||||||
|
def test_render_micron_comments(self):
|
||||||
|
"""Test that comments are ignored."""
|
||||||
|
content = "# This is a comment\nVisible text"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
# Should only contain the visible text, not the comment
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
|
||||||
|
all_text = all_text.strip()
|
||||||
|
assert "Visible text" in all_text
|
||||||
|
assert "This is a comment" not in all_text
|
||||||
|
|
||||||
|
def test_render_micron_section_depth(self):
|
||||||
|
"""Test micron rendering with section depth/indentation."""
|
||||||
|
content = "> Main section\n>> Subsection\n>>> Sub-subsection"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
assert len(result.content.controls) == 3
|
||||||
|
|
||||||
|
# Check indentation increases with depth
|
||||||
|
for i, control in enumerate(result.content.controls):
|
||||||
|
assert isinstance(control, ft.Container)
|
||||||
|
# The inner container should have margin for indentation
|
||||||
|
inner_container = control.content
|
||||||
|
if hasattr(inner_container, "margin") and inner_container.margin:
|
||||||
|
# Should have left margin based on depth: (depth-1) * 1.2 * 16
|
||||||
|
# depth = i + 1, so margin = i * 1.2 * 16
|
||||||
|
expected_margin = i * 1.2 * 16 # 19.2px per depth level above 1
|
||||||
|
assert inner_container.margin.left == expected_margin
|
||||||
|
|
||||||
|
def test_render_micron_ascii_art(self):
|
||||||
|
"""Test micron rendering with ASCII art scaling."""
|
||||||
|
# Create content with ASCII art characters
|
||||||
|
ascii_art = "┌───┐\n│Box│\n└───┘"
|
||||||
|
content = f"Normal text\n{ascii_art}\nMore text"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
# Each line is kept as separate control
|
||||||
|
assert len(result.content.controls) >= 3
|
||||||
|
# Should contain the ASCII art content
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
all_text = all_text.strip()
|
||||||
|
assert "┌───┐" in all_text
|
||||||
|
assert "Normal text" in all_text
|
||||||
|
|
||||||
|
def test_render_micron_literal_mode(self):
|
||||||
|
"""Test micron literal mode."""
|
||||||
|
content = "`=Literal mode`\n# This should be visible\n`=Back to normal`"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
# Should contain the processed content (literal mode may not be fully implemented)
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
all_text += text_content + "\n"
|
||||||
|
|
||||||
|
# At minimum, should contain some text content
|
||||||
|
assert len(all_text.strip()) > 0
|
||||||
|
|
||||||
|
def test_render_micron_dividers(self):
|
||||||
|
"""Test micron rendering with dividers."""
|
||||||
|
content = "Text above\n-\nText below"
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
# Should contain controls for text
|
||||||
|
assert len(result.content.controls) >= 2
|
||||||
|
|
||||||
|
def test_render_micron_complex_formatting(self):
|
||||||
|
"""Test complex combination of micron formatting."""
|
||||||
|
content = """# Comment (ignored)
|
||||||
|
> Heading
|
||||||
|
Regular text.
|
||||||
|
|
||||||
|
>> Subsection
|
||||||
|
Centered text
|
||||||
|
Final paragraph."""
|
||||||
|
|
||||||
|
result = render_micron(content)
|
||||||
|
|
||||||
|
assert isinstance(result, ft.Container)
|
||||||
|
assert len(result.content.controls) >= 3 # Should have multiple elements
|
||||||
|
|
||||||
|
# Check for heading containers
|
||||||
|
heading_containers = [c for c in result.content.controls if isinstance(c, ft.Container)]
|
||||||
|
assert len(heading_containers) >= 2 # At least 2 headings
|
||||||
|
|
||||||
|
# Check that we have some text content
|
||||||
|
def extract_all_text(control):
|
||||||
|
"""Recursively extract text from control and its children."""
|
||||||
|
text = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text += control.value
|
||||||
|
elif hasattr(control, "_Control__attrs") and "value" in control._Control__attrs:
|
||||||
|
text += control._Control__attrs["value"][0]
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text += "".join(span.text for span in control.spans)
|
||||||
|
elif hasattr(control, "content"):
|
||||||
|
text += extract_all_text(control.content)
|
||||||
|
return text
|
||||||
|
|
||||||
|
all_text = ""
|
||||||
|
for control in result.content.controls:
|
||||||
|
all_text += extract_all_text(control)
|
||||||
|
|
||||||
|
assert "Heading" in all_text
|
||||||
|
assert "Subsection" in all_text
|
||||||
|
assert "Regular text" in all_text
|
||||||
|
|
||||||
|
|
||||||
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_correct_types(self):
|
||||||
"""Test that both renderers return the same control type."""
|
"""Test that both renderers return the expected control types."""
|
||||||
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)
|
# Plaintext returns Text, Micron returns Container
|
||||||
assert isinstance(plaintext_result, ft.Text)
|
assert isinstance(plaintext_result, ft.Text)
|
||||||
assert isinstance(micron_result, ft.Text)
|
assert isinstance(micron_result, ft.Container)
|
||||||
|
|
||||||
def test_renderers_preserve_content(self):
|
def test_renderers_preserve_content(self):
|
||||||
"""Test that both renderers preserve the original content."""
|
"""Test that both renderers preserve the original content."""
|
||||||
@@ -114,7 +364,33 @@ class TestRendererComparison:
|
|||||||
micron_result = render_micron(content)
|
micron_result = render_micron(content)
|
||||||
|
|
||||||
assert plaintext_result.value == content
|
assert plaintext_result.value == content
|
||||||
assert micron_result.value == content
|
|
||||||
|
# For micron result (Container), extract text from controls
|
||||||
|
micron_text = ""
|
||||||
|
for control in micron_result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
# Extract text from the control
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control, "value") and control.value:
|
||||||
|
text_content = control.value
|
||||||
|
elif hasattr(control, "spans") and control.spans:
|
||||||
|
text_content = "".join(span.text for span in control.spans)
|
||||||
|
if text_content:
|
||||||
|
micron_text += text_content + "\n"
|
||||||
|
elif isinstance(control, ft.Container) and hasattr(control, "content"):
|
||||||
|
# Handle indented text controls
|
||||||
|
if isinstance(control.content, ft.Text):
|
||||||
|
text_content = ""
|
||||||
|
if hasattr(control.content, "value") and control.content.value:
|
||||||
|
text_content = control.content.value
|
||||||
|
elif hasattr(control.content, "spans") and control.content.spans:
|
||||||
|
text_content = "".join(span.text for span in control.content.spans)
|
||||||
|
if text_content:
|
||||||
|
micron_text += text_content + "\n"
|
||||||
|
|
||||||
|
# Remove trailing newline and compare
|
||||||
|
micron_text = micron_text.rstrip("\n")
|
||||||
|
assert micron_text == 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 set the same basic properties."""
|
||||||
@@ -123,6 +399,17 @@ class TestRendererComparison:
|
|||||||
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
|
# Check basic properties
|
||||||
assert plaintext_result.font_family == micron_result.font_family
|
assert plaintext_result.selectable is True
|
||||||
assert plaintext_result.expand == micron_result.expand
|
assert plaintext_result.font_family == "monospace"
|
||||||
|
assert plaintext_result.expand is True
|
||||||
|
|
||||||
|
# For micron result (Container), check properties
|
||||||
|
assert isinstance(micron_result.content, ft.ListView)
|
||||||
|
assert micron_result.content.spacing == 2
|
||||||
|
|
||||||
|
# Check that all Text controls in the ListView have the expected properties
|
||||||
|
for control in micron_result.content.controls:
|
||||||
|
if isinstance(control, ft.Text):
|
||||||
|
assert control.selectable is True
|
||||||
|
assert control.font_family == "monospace"
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class TestShortcuts:
|
|||||||
url_field2 = Mock()
|
url_field2 = Mock()
|
||||||
mock_tab_manager.manager.tabs = [
|
mock_tab_manager.manager.tabs = [
|
||||||
{"url_field": url_field1},
|
{"url_field": url_field1},
|
||||||
{"url_field": url_field2}
|
{"url_field": url_field2},
|
||||||
]
|
]
|
||||||
mock_tab_manager.manager.index = 1 # Second tab
|
mock_tab_manager.manager.index = 1 # Second tab
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ren_browser.storage.storage import StorageManager, get_storage_manager, initialize_storage
|
from ren_browser.storage.storage import (
|
||||||
|
StorageManager,
|
||||||
|
get_storage_manager,
|
||||||
|
initialize_storage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestStorageManager:
|
class TestStorageManager:
|
||||||
@@ -13,13 +17,15 @@ 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('ren_browser.storage.storage.StorageManager._get_storage_directory') as mock_get_dir:
|
with patch(
|
||||||
mock_dir = Path('/mock/storage')
|
"ren_browser.storage.storage.StorageManager._get_storage_directory",
|
||||||
|
) as mock_get_dir:
|
||||||
|
mock_dir = Path("/mock/storage")
|
||||||
mock_get_dir.return_value = mock_dir
|
mock_get_dir.return_value = mock_dir
|
||||||
|
|
||||||
with patch('pathlib.Path.mkdir') as mock_mkdir:
|
with patch("pathlib.Path.mkdir") as mock_mkdir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
|
|
||||||
assert storage.page is None
|
assert storage.page is None
|
||||||
assert storage._storage_dir == mock_dir
|
assert storage._storage_dir == mock_dir
|
||||||
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
||||||
@@ -27,27 +33,34 @@ class TestStorageManager:
|
|||||||
def test_storage_manager_init_with_page(self):
|
def test_storage_manager_init_with_page(self):
|
||||||
"""Test StorageManager initialization with a page."""
|
"""Test StorageManager initialization with a page."""
|
||||||
mock_page = Mock()
|
mock_page = Mock()
|
||||||
|
|
||||||
with patch('ren_browser.storage.storage.StorageManager._get_storage_directory') as mock_get_dir:
|
with patch(
|
||||||
mock_dir = Path('/mock/storage')
|
"ren_browser.storage.storage.StorageManager._get_storage_directory",
|
||||||
|
) as mock_get_dir:
|
||||||
|
mock_dir = Path("/mock/storage")
|
||||||
mock_get_dir.return_value = mock_dir
|
mock_get_dir.return_value = mock_dir
|
||||||
|
|
||||||
with patch('pathlib.Path.mkdir'):
|
with patch("pathlib.Path.mkdir"):
|
||||||
storage = StorageManager(mock_page)
|
storage = StorageManager(mock_page)
|
||||||
|
|
||||||
assert storage.page == mock_page
|
assert storage.page == mock_page
|
||||||
assert storage._storage_dir == mock_dir
|
assert storage._storage_dir == mock_dir
|
||||||
|
|
||||||
def test_get_storage_directory_desktop(self):
|
def test_get_storage_directory_desktop(self):
|
||||||
"""Test storage directory detection for desktop platforms."""
|
"""Test storage directory detection for desktop platforms."""
|
||||||
with patch('os.name', 'posix'), \
|
with (
|
||||||
patch.dict('os.environ', {'XDG_CONFIG_HOME': '/home/user/.config'}, clear=True), \
|
patch("os.name", "posix"),
|
||||||
patch('pathlib.Path.mkdir'):
|
patch.dict(
|
||||||
|
"os.environ", {"XDG_CONFIG_HOME": "/home/user/.config"}, clear=True,
|
||||||
with 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()
|
||||||
expected_dir = Path('/home/user/.config') / 'ren_browser'
|
expected_dir = Path("/home/user/.config") / "ren_browser"
|
||||||
assert storage._storage_dir == expected_dir
|
assert storage._storage_dir == expected_dir
|
||||||
|
|
||||||
def test_get_storage_directory_windows(self):
|
def test_get_storage_directory_windows(self):
|
||||||
@@ -57,14 +70,17 @@ class TestStorageManager:
|
|||||||
|
|
||||||
def test_get_storage_directory_android(self):
|
def test_get_storage_directory_android(self):
|
||||||
"""Test storage directory detection for Android."""
|
"""Test storage directory detection for Android."""
|
||||||
with patch('os.name', 'posix'), \
|
with (
|
||||||
patch.dict('os.environ', {'ANDROID_ROOT': '/system'}, clear=True), \
|
patch("os.name", "posix"),
|
||||||
patch('pathlib.Path.mkdir'):
|
patch.dict("os.environ", {"ANDROID_ROOT": "/system"}, clear=True),
|
||||||
|
patch("pathlib.Path.mkdir"),
|
||||||
with 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()
|
||||||
expected_dir = Path('/data/data/com.ren_browser/files')
|
expected_dir = Path("/storage/emulated/0/Documents/ren_browser")
|
||||||
assert storage._storage_dir == expected_dir
|
assert storage._storage_dir == expected_dir
|
||||||
|
|
||||||
def test_get_config_path(self):
|
def test_get_config_path(self):
|
||||||
@@ -72,17 +88,17 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
config_path = storage.get_config_path()
|
config_path = storage.get_config_path()
|
||||||
expected_path = Path(temp_dir) / 'config'
|
expected_path = Path(temp_dir) / "config"
|
||||||
assert config_path == expected_path
|
assert config_path == expected_path
|
||||||
|
|
||||||
def test_get_reticulum_config_path(self):
|
def test_get_reticulum_config_path(self):
|
||||||
"""Test getting Reticulum config directory path."""
|
"""Test getting Reticulum config directory path."""
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
|
|
||||||
config_path = storage.get_reticulum_config_path()
|
config_path = storage.get_reticulum_config_path()
|
||||||
expected_path = Path.home() / '.reticulum'
|
expected_path = Path.home() / ".reticulum"
|
||||||
assert config_path == expected_path
|
assert config_path == expected_path
|
||||||
|
|
||||||
def test_save_config_success(self):
|
def test_save_config_success(self):
|
||||||
@@ -90,52 +106,71 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Mock the reticulum config path to use temp dir
|
# Mock the reticulum config path to use temp dir
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
config_content = "test config content"
|
config_content = "test config content"
|
||||||
result = storage.save_config(config_content)
|
result = storage.save_config(config_content)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
config_path = storage.get_config_path()
|
config_path = storage.get_config_path()
|
||||||
assert config_path.exists()
|
assert config_path.exists()
|
||||||
assert config_path.read_text(encoding='utf-8') == config_content
|
assert config_path.read_text(encoding="utf-8") == config_content
|
||||||
|
|
||||||
def test_save_config_with_client_storage(self):
|
def test_save_config_with_client_storage(self):
|
||||||
"""Test config saving with client storage."""
|
"""Test config saving with client storage."""
|
||||||
mock_page = Mock()
|
mock_page = Mock()
|
||||||
mock_page.client_storage.set = Mock()
|
mock_page.client_storage.set = Mock()
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager(mock_page)
|
storage = StorageManager(mock_page)
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Mock the reticulum config path to use temp dir
|
# Mock the reticulum config path to use temp dir
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
config_content = "test config content"
|
config_content = "test config content"
|
||||||
result = storage.save_config(config_content)
|
result = storage.save_config(config_content)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_page.client_storage.set.assert_called_with('ren_browser_config', config_content)
|
mock_page.client_storage.set.assert_called_with(
|
||||||
|
"ren_browser_config", config_content,
|
||||||
|
)
|
||||||
|
|
||||||
def test_save_config_fallback(self):
|
def test_save_config_fallback(self):
|
||||||
"""Test config saving fallback when file system fails."""
|
"""Test config saving fallback when file system fails."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
mock_page = Mock()
|
mock_page = Mock()
|
||||||
mock_page.client_storage.set = Mock()
|
mock_page.client_storage.set = Mock()
|
||||||
|
|
||||||
storage = StorageManager(mock_page)
|
storage = StorageManager(mock_page)
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Mock the reticulum config path to use temp dir and cause failure
|
# Mock the reticulum config path to use temp dir and cause failure
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
with patch('pathlib.Path.write_text', side_effect=PermissionError("Access denied")):
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"pathlib.Path.write_text",
|
||||||
|
side_effect=PermissionError("Access denied"),
|
||||||
|
):
|
||||||
config_content = "test config content"
|
config_content = "test config content"
|
||||||
result = storage.save_config(config_content)
|
result = storage.save_config(config_content)
|
||||||
|
|
||||||
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('ren_browser_config', config_content)
|
mock_page.client_storage.set.assert_any_call(
|
||||||
|
"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
|
||||||
|
|
||||||
@@ -144,13 +179,17 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Mock the reticulum config path to use temp dir
|
# Mock the reticulum config path to use temp dir
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
config_content = "test config content"
|
config_content = "test config content"
|
||||||
config_path = storage.get_config_path()
|
config_path = storage.get_config_path()
|
||||||
config_path.write_text(config_content, encoding='utf-8')
|
config_path.write_text(config_content, encoding="utf-8")
|
||||||
|
|
||||||
loaded_config = storage.load_config()
|
loaded_config = storage.load_config()
|
||||||
assert loaded_config == config_content
|
assert loaded_config == config_content
|
||||||
|
|
||||||
@@ -158,25 +197,33 @@ class TestStorageManager:
|
|||||||
"""Test loading config from client storage when file doesn't exist."""
|
"""Test loading config from client storage when file doesn't exist."""
|
||||||
mock_page = Mock()
|
mock_page = Mock()
|
||||||
mock_page.client_storage.get = Mock(return_value="client storage config")
|
mock_page.client_storage.get = Mock(return_value="client storage config")
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager(mock_page)
|
storage = StorageManager(mock_page)
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Mock the reticulum config path to also be in temp dir
|
# Mock the reticulum config path to also be in temp dir
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
loaded_config = storage.load_config()
|
loaded_config = storage.load_config()
|
||||||
assert loaded_config == "client storage config"
|
assert loaded_config == "client storage config"
|
||||||
mock_page.client_storage.get.assert_called_with('ren_browser_config')
|
mock_page.client_storage.get.assert_called_with("ren_browser_config")
|
||||||
|
|
||||||
def test_load_config_default(self):
|
def test_load_config_default(self):
|
||||||
"""Test loading default config when no config exists."""
|
"""Test loading default config when no config exists."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Mock the reticulum config path to also be in temp dir
|
# Mock the reticulum config path to also be in temp dir
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
loaded_config = storage.load_config()
|
loaded_config = storage.load_config()
|
||||||
assert loaded_config == ""
|
assert loaded_config == ""
|
||||||
|
|
||||||
@@ -185,15 +232,15 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
bookmarks = [{"name": "Test", "url": "test://example"}]
|
bookmarks = [{"name": "Test", "url": "test://example"}]
|
||||||
result = storage.save_bookmarks(bookmarks)
|
result = storage.save_bookmarks(bookmarks)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
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
|
||||||
|
|
||||||
@@ -202,13 +249,13 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
bookmarks = [{"name": "Test", "url": "test://example"}]
|
bookmarks = [{"name": "Test", "url": "test://example"}]
|
||||||
bookmarks_path = storage._storage_dir / 'bookmarks.json'
|
bookmarks_path = storage._storage_dir / "bookmarks.json"
|
||||||
|
|
||||||
with open(bookmarks_path, 'w', encoding='utf-8') as f:
|
with open(bookmarks_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(bookmarks, f)
|
json.dump(bookmarks, f)
|
||||||
|
|
||||||
loaded_bookmarks = storage.load_bookmarks()
|
loaded_bookmarks = storage.load_bookmarks()
|
||||||
assert loaded_bookmarks == bookmarks
|
assert loaded_bookmarks == bookmarks
|
||||||
|
|
||||||
@@ -217,7 +264,7 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
loaded_bookmarks = storage.load_bookmarks()
|
loaded_bookmarks = storage.load_bookmarks()
|
||||||
assert loaded_bookmarks == []
|
assert loaded_bookmarks == []
|
||||||
|
|
||||||
@@ -226,15 +273,15 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
history = [{"url": "test://example", "timestamp": 1234567890}]
|
history = [{"url": "test://example", "timestamp": 1234567890}]
|
||||||
result = storage.save_history(history)
|
result = storage.save_history(history)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
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
|
||||||
|
|
||||||
@@ -243,13 +290,13 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
history = [{"url": "test://example", "timestamp": 1234567890}]
|
history = [{"url": "test://example", "timestamp": 1234567890}]
|
||||||
history_path = storage._storage_dir / 'history.json'
|
history_path = storage._storage_dir / "history.json"
|
||||||
|
|
||||||
with open(history_path, 'w', encoding='utf-8') as f:
|
with open(history_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(history, f)
|
json.dump(history, f)
|
||||||
|
|
||||||
loaded_history = storage.load_history()
|
loaded_history = storage.load_history()
|
||||||
assert loaded_history == history
|
assert loaded_history == history
|
||||||
|
|
||||||
@@ -258,33 +305,36 @@ class TestStorageManager:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
mock_page = Mock()
|
mock_page = Mock()
|
||||||
mock_page.client_storage = Mock()
|
mock_page.client_storage = Mock()
|
||||||
|
|
||||||
storage = StorageManager(mock_page)
|
storage = StorageManager(mock_page)
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
info = storage.get_storage_info()
|
info = storage.get_storage_info()
|
||||||
|
|
||||||
assert 'storage_dir' in info
|
assert "storage_dir" in info
|
||||||
assert 'config_path' in info
|
assert "config_path" in info
|
||||||
assert 'reticulum_config_path' in info
|
assert "reticulum_config_path" in info
|
||||||
assert 'storage_dir_exists' in info
|
assert "storage_dir_exists" in info
|
||||||
assert 'storage_dir_writable' in info
|
assert "storage_dir_writable" in info
|
||||||
assert 'has_client_storage' in info
|
assert "has_client_storage" in info
|
||||||
|
|
||||||
assert info['storage_dir'] == str(Path(temp_dir))
|
assert info["storage_dir"] == str(Path(temp_dir))
|
||||||
assert info['storage_dir_exists'] is True
|
assert info["storage_dir_exists"] is True
|
||||||
assert info['has_client_storage'] is True
|
assert info["has_client_storage"] is True
|
||||||
|
|
||||||
def test_storage_directory_fallback(self):
|
def test_storage_directory_fallback(self):
|
||||||
"""Test fallback to temp directory when storage creation fails."""
|
"""Test fallback to temp directory when storage creation fails."""
|
||||||
with patch.object(StorageManager, '_get_storage_directory') as mock_get_dir:
|
with patch.object(StorageManager, "_get_storage_directory") as mock_get_dir:
|
||||||
mock_get_dir.return_value = Path('/nonexistent/path')
|
mock_get_dir.return_value = Path("/nonexistent/path")
|
||||||
|
|
||||||
with patch('pathlib.Path.mkdir', side_effect=[PermissionError("Access denied"), None]):
|
with patch(
|
||||||
with patch('tempfile.gettempdir', return_value='/tmp'):
|
"pathlib.Path.mkdir",
|
||||||
|
side_effect=[PermissionError("Access denied"), None],
|
||||||
|
):
|
||||||
|
with patch("tempfile.gettempdir", return_value="/tmp"):
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
|
|
||||||
expected_fallback = Path('/tmp') / 'ren_browser'
|
expected_fallback = Path("/tmp") / "ren_browser"
|
||||||
assert storage._storage_dir == expected_fallback
|
assert storage._storage_dir == expected_fallback
|
||||||
|
|
||||||
|
|
||||||
@@ -293,28 +343,28 @@ class TestStorageGlobalFunctions:
|
|||||||
|
|
||||||
def test_get_storage_manager_singleton(self):
|
def test_get_storage_manager_singleton(self):
|
||||||
"""Test that get_storage_manager returns the same instance."""
|
"""Test that get_storage_manager returns the same instance."""
|
||||||
with patch('ren_browser.storage.storage._storage_manager', None):
|
with patch("ren_browser.storage.storage._storage_manager", None):
|
||||||
storage1 = get_storage_manager()
|
storage1 = get_storage_manager()
|
||||||
storage2 = get_storage_manager()
|
storage2 = get_storage_manager()
|
||||||
|
|
||||||
assert storage1 is storage2
|
assert storage1 is storage2
|
||||||
|
|
||||||
def test_get_storage_manager_with_page(self):
|
def test_get_storage_manager_with_page(self):
|
||||||
"""Test get_storage_manager with page parameter."""
|
"""Test get_storage_manager with page parameter."""
|
||||||
mock_page = Mock()
|
mock_page = Mock()
|
||||||
|
|
||||||
with patch('ren_browser.storage.storage._storage_manager', None):
|
with patch("ren_browser.storage.storage._storage_manager", None):
|
||||||
storage = get_storage_manager(mock_page)
|
storage = get_storage_manager(mock_page)
|
||||||
|
|
||||||
assert storage.page == mock_page
|
assert storage.page == mock_page
|
||||||
|
|
||||||
def test_initialize_storage(self):
|
def test_initialize_storage(self):
|
||||||
"""Test initialize_storage function."""
|
"""Test initialize_storage function."""
|
||||||
mock_page = Mock()
|
mock_page = Mock()
|
||||||
|
|
||||||
with patch('ren_browser.storage.storage._storage_manager', None):
|
with patch("ren_browser.storage.storage._storage_manager", None):
|
||||||
storage = initialize_storage(mock_page)
|
storage = initialize_storage(mock_page)
|
||||||
|
|
||||||
assert storage.page == mock_page
|
assert storage.page == mock_page
|
||||||
assert get_storage_manager() is storage
|
assert get_storage_manager() is storage
|
||||||
|
|
||||||
@@ -327,11 +377,18 @@ class TestStorageManagerEdgeCases:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Mock the reticulum config path to use temp dir
|
# Mock the reticulum config path to use temp dir
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
# Test with content that might cause encoding issues
|
# Test with content that might cause encoding issues
|
||||||
with patch('pathlib.Path.write_text', side_effect=UnicodeEncodeError('utf-8', '', 0, 1, 'error')):
|
with patch(
|
||||||
|
"pathlib.Path.write_text",
|
||||||
|
side_effect=UnicodeEncodeError("utf-8", "", 0, 1, "error"),
|
||||||
|
):
|
||||||
result = storage.save_config("test content")
|
result = storage.save_config("test content")
|
||||||
# Should still succeed due to fallback
|
# Should still succeed due to fallback
|
||||||
assert result is False
|
assert result is False
|
||||||
@@ -341,13 +398,17 @@ class TestStorageManagerEdgeCases:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
storage._storage_dir = Path(temp_dir)
|
storage._storage_dir = Path(temp_dir)
|
||||||
|
|
||||||
# Create a config file with invalid encoding
|
# Create a config file with invalid encoding
|
||||||
config_path = storage.get_config_path()
|
config_path = storage.get_config_path()
|
||||||
config_path.write_bytes(b'\xff\xfe invalid utf-8')
|
config_path.write_bytes(b"\xff\xfe invalid utf-8")
|
||||||
|
|
||||||
# Mock the reticulum config path to also be in temp dir
|
# Mock the reticulum config path to also be in temp dir
|
||||||
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"):
|
with patch.object(
|
||||||
|
storage,
|
||||||
|
"get_reticulum_config_path",
|
||||||
|
return_value=Path(temp_dir) / "reticulum",
|
||||||
|
):
|
||||||
# Should return empty string when encoding fails
|
# Should return empty string when encoding fails
|
||||||
config = storage.load_config()
|
config = storage.load_config()
|
||||||
assert config == ""
|
assert config == ""
|
||||||
@@ -355,9 +416,11 @@ class TestStorageManagerEdgeCases:
|
|||||||
def test_is_writable_permission_denied(self):
|
def test_is_writable_permission_denied(self):
|
||||||
"""Test _is_writable when permission is denied."""
|
"""Test _is_writable when permission is denied."""
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
|
|
||||||
with patch('pathlib.Path.write_text', side_effect=PermissionError("Access denied")):
|
with patch(
|
||||||
test_path = Path('/mock/path')
|
"pathlib.Path.write_text", side_effect=PermissionError("Access denied"),
|
||||||
|
):
|
||||||
|
test_path = Path("/mock/path")
|
||||||
result = storage._is_writable(test_path)
|
result = storage._is_writable(test_path)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -366,6 +429,6 @@ class TestStorageManagerEdgeCases:
|
|||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
storage = StorageManager()
|
storage = StorageManager()
|
||||||
test_path = Path(temp_dir)
|
test_path = Path(temp_dir)
|
||||||
|
|
||||||
result = storage._is_writable(test_path)
|
result = storage._is_writable(test_path)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|||||||
@@ -13,17 +13,19 @@ class TestTabsManager:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tabs_manager(self, mock_page):
|
def tabs_manager(self, mock_page):
|
||||||
"""Create a TabsManager instance for testing."""
|
"""Create a TabsManager instance for testing."""
|
||||||
with patch("ren_browser.app.RENDERER", "plaintext"), \
|
with (
|
||||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render:
|
patch("ren_browser.app.RENDERER", "plaintext"),
|
||||||
|
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
|
||||||
|
):
|
||||||
mock_render.return_value = Mock(spec=ft.Text)
|
mock_render.return_value = Mock(spec=ft.Text)
|
||||||
return TabsManager(mock_page)
|
return TabsManager(mock_page)
|
||||||
|
|
||||||
def test_tabs_manager_init(self, mock_page):
|
def test_tabs_manager_init(self, mock_page):
|
||||||
"""Test TabsManager initialization."""
|
"""Test TabsManager initialization."""
|
||||||
with patch("ren_browser.app.RENDERER", "plaintext"), \
|
with (
|
||||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render:
|
patch("ren_browser.app.RENDERER", "plaintext"),
|
||||||
|
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
|
||||||
|
):
|
||||||
mock_render.return_value = Mock(spec=ft.Text)
|
mock_render.return_value = Mock(spec=ft.Text)
|
||||||
manager = TabsManager(mock_page)
|
manager = TabsManager(mock_page)
|
||||||
|
|
||||||
@@ -55,9 +57,10 @@ class TestTabsManager:
|
|||||||
|
|
||||||
def test_on_add_click(self, tabs_manager):
|
def test_on_add_click(self, tabs_manager):
|
||||||
"""Test adding a new tab via button click."""
|
"""Test adding a new tab via button click."""
|
||||||
with patch("ren_browser.app.RENDERER", "plaintext"), \
|
with (
|
||||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render:
|
patch("ren_browser.app.RENDERER", "plaintext"),
|
||||||
|
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
|
||||||
|
):
|
||||||
mock_render.return_value = Mock(spec=ft.Text)
|
mock_render.return_value = Mock(spec=ft.Text)
|
||||||
initial_count = len(tabs_manager.manager.tabs)
|
initial_count = len(tabs_manager.manager.tabs)
|
||||||
|
|
||||||
@@ -198,7 +201,7 @@ class TestTabsManager:
|
|||||||
"""Test management of multiple tabs."""
|
"""Test management of multiple tabs."""
|
||||||
# Add several tabs
|
# Add several tabs
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
tabs_manager._add_tab_internal(f"Tab {i+2}", Mock())
|
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
|
||||||
|
|
||||||
assert len(tabs_manager.manager.tabs) == 4
|
assert len(tabs_manager.manager.tabs) == 4
|
||||||
|
|
||||||
@@ -220,7 +223,13 @@ class TestTabsManager:
|
|||||||
tabs_manager._add_tab_internal("Tab 3", content2)
|
tabs_manager._add_tab_internal("Tab 3", content2)
|
||||||
|
|
||||||
tabs_manager.select_tab(1)
|
tabs_manager.select_tab(1)
|
||||||
assert tabs_manager.content_container.content == tabs_manager.manager.tabs[1]["content"]
|
assert (
|
||||||
|
tabs_manager.content_container.content
|
||||||
|
== tabs_manager.manager.tabs[1]["content"]
|
||||||
|
)
|
||||||
|
|
||||||
tabs_manager.select_tab(2)
|
tabs_manager.select_tab(2)
|
||||||
assert tabs_manager.content_container.content == tabs_manager.manager.tabs[2]["content"]
|
assert (
|
||||||
|
tabs_manager.content_container.content
|
||||||
|
== tabs_manager.manager.tabs[2]["content"]
|
||||||
|
)
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class TestBuildUI:
|
|||||||
@patch("ren_browser.pages.page_request.PageFetcher")
|
@patch("ren_browser.pages.page_request.PageFetcher")
|
||||||
@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(self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page):
|
def test_build_ui_appbar_setup(
|
||||||
|
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()
|
||||||
mock_tabs.return_value = mock_tab_manager
|
mock_tabs.return_value = mock_tab_manager
|
||||||
@@ -48,7 +50,9 @@ class TestBuildUI:
|
|||||||
@patch("ren_browser.pages.page_request.PageFetcher")
|
@patch("ren_browser.pages.page_request.PageFetcher")
|
||||||
@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(self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page):
|
def test_build_ui_drawer_setup(
|
||||||
|
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()
|
||||||
mock_tabs.return_value = mock_tab_manager
|
mock_tabs.return_value = mock_tab_manager
|
||||||
@@ -116,9 +120,10 @@ class TestOpenSettingsTab:
|
|||||||
mock_tab_manager._add_tab_internal = Mock()
|
mock_tab_manager._add_tab_internal = Mock()
|
||||||
mock_tab_manager.select_tab = Mock()
|
mock_tab_manager.select_tab = Mock()
|
||||||
|
|
||||||
with patch("pathlib.Path.read_text", return_value="config"), \
|
with (
|
||||||
patch("pathlib.Path.write_text"):
|
patch("pathlib.Path.read_text", return_value="config"),
|
||||||
|
patch("pathlib.Path.write_text"),
|
||||||
|
):
|
||||||
open_settings_tab(mock_page, mock_tab_manager)
|
open_settings_tab(mock_page, mock_tab_manager)
|
||||||
|
|
||||||
# Get the settings content that was added
|
# Get the settings content that was added
|
||||||
@@ -129,7 +134,10 @@ class TestOpenSettingsTab:
|
|||||||
for control in settings_content.controls:
|
for control in settings_content.controls:
|
||||||
if hasattr(control, "controls"):
|
if hasattr(control, "controls"):
|
||||||
for sub_control in control.controls:
|
for sub_control in control.controls:
|
||||||
if hasattr(sub_control, "text") and sub_control.text == "Save and Restart":
|
if (
|
||||||
|
hasattr(sub_control, "text")
|
||||||
|
and sub_control.text == "Save Config"
|
||||||
|
):
|
||||||
save_btn = sub_control
|
save_btn = sub_control
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -142,7 +150,10 @@ class TestOpenSettingsTab:
|
|||||||
mock_tab_manager._add_tab_internal = Mock()
|
mock_tab_manager._add_tab_internal = Mock()
|
||||||
mock_tab_manager.select_tab = Mock()
|
mock_tab_manager.select_tab = Mock()
|
||||||
|
|
||||||
with patch('ren_browser.ui.settings.get_storage_manager', return_value=mock_storage_manager):
|
with patch(
|
||||||
|
"ren_browser.ui.settings.get_storage_manager",
|
||||||
|
return_value=mock_storage_manager,
|
||||||
|
):
|
||||||
open_settings_tab(mock_page, mock_tab_manager)
|
open_settings_tab(mock_page, mock_tab_manager)
|
||||||
|
|
||||||
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
|
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
|
||||||
@@ -155,10 +166,14 @@ class TestOpenSettingsTab:
|
|||||||
mock_tab_manager._add_tab_internal = Mock()
|
mock_tab_manager._add_tab_internal = Mock()
|
||||||
mock_tab_manager.select_tab = Mock()
|
mock_tab_manager.select_tab = Mock()
|
||||||
|
|
||||||
with patch('ren_browser.ui.settings.get_storage_manager', return_value=mock_storage_manager), \
|
with (
|
||||||
patch("ren_browser.logs.ERROR_LOGS", ["Error 1", "Error 2"]), \
|
patch(
|
||||||
patch("ren_browser.logs.RET_LOGS", ["RNS log 1", "RNS log 2"]):
|
"ren_browser.ui.settings.get_storage_manager",
|
||||||
|
return_value=mock_storage_manager,
|
||||||
|
),
|
||||||
|
patch("ren_browser.logs.ERROR_LOGS", ["Error 1", "Error 2"]),
|
||||||
|
patch("ren_browser.logs.RET_LOGS", ["RNS log 1", "RNS log 2"]),
|
||||||
|
):
|
||||||
open_settings_tab(mock_page, mock_tab_manager)
|
open_settings_tab(mock_page, mock_tab_manager)
|
||||||
|
|
||||||
mock_tab_manager._add_tab_internal.assert_called_once()
|
mock_tab_manager._add_tab_internal.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user