mirror of
https://github.com/markqvist/NomadNet.git
synced 2026-10-26 10:51:40 -04:00
Compare commits
54 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f852c9e0 | ||
|
|
84620f8471 | ||
|
|
725c5a1b42 | ||
|
|
e5d7de4537 | ||
|
|
9d24271090 | ||
|
|
cfe23af68d | ||
|
|
64b750c28b | ||
|
|
2561f73d4c | ||
|
|
5fbd2914af | ||
|
|
9f57b972c1 | ||
|
|
f286853b2b | ||
|
|
700104d3de | ||
|
|
abdfc62b17 | ||
|
|
e1e8ab8380 | ||
|
|
24c61665e9 | ||
|
|
7606b32418 | ||
|
|
652a6256ef | ||
|
|
73380d2c46 | ||
|
|
7fbd5390ec | ||
|
|
379bf9318e | ||
|
|
abb1d3b923 | ||
|
|
5d6419610f | ||
|
|
0b3f96e06a | ||
|
|
8d4daed978 | ||
|
|
240e391a99 | ||
|
|
c0bcc1c2eb | ||
|
|
d2de625e5f | ||
|
|
1d99e0dca6 | ||
|
|
23de5ca936 | ||
|
|
7a7cd8fe23 | ||
|
|
774c08905f | ||
|
|
3a5fd73ee0 | ||
|
|
9caa24b5e8 | ||
|
|
10664102e8 | ||
|
|
f62603da1d | ||
|
|
4d4e707338 | ||
|
|
b0d42c9c05 | ||
|
|
0db7d4bdaa | ||
|
|
6451d7fa7f | ||
|
|
05b968ecf6 | ||
|
|
37fff0bc76 | ||
|
|
3c732874f6 | ||
|
|
4544335324 | ||
|
|
38165804ae | ||
|
|
0cd8de0c2e | ||
|
|
235658c375 | ||
|
|
815ff99e12 | ||
|
|
2250f2a71c | ||
|
|
ff73e32502 | ||
|
|
7bd3a3a888 | ||
|
|
3028301fb1 | ||
|
|
4fecb706d8 | ||
|
|
c0091dbf31 | ||
|
|
71904f1e40 |
30 changed files with 3246 additions and 216 deletions
90
README.md
90
README.md
|
|
@ -1,10 +1,19 @@
|
|||
# Nomad Network - Communicate Freely
|
||||
|
||||
> [!WARNING]
|
||||
> Several hastily launched, incorrect, and in most cases entirely LLM-generated fakes of Nomad Network and associated tools are currently being circulated and marketed. Most of these "projects" violate the open source GPLv3 license that Nomad Network was published under, but claim independent ownership and license grants. Such claims or grants are **not** legally valid, and **not** recognized by the Nomad Network authors and copyright holders.
|
||||
>
|
||||
> Any claimed assertion of copyright or grant of license that such projects are making are [void grants](https://reticulum.network/manual/brandolinis.html#void-grants-legal-foundations-of-machine-generated-code). Both the violators themselves, and **all** downstream projects using the infringing derivatives, are **directly** and **personally** liable for the legal consequences.
|
||||
>
|
||||
> For more detailed information, see the relevant sections of the [Brandolini's Reference](https://reticulum.network/manual/brandolinis.html) chapter of the Reticulum Manual. For software and projects recognized by the Reticulum community, see the [Programs Using Reticulum](https://reticulum.network/manual/software.html) chapter.
|
||||
>
|
||||
> Do **not** use these "projects", they are a hazard to the entire ecosystem we have been carefully building over the last ten years.
|
||||
|
||||
*This repository is [a public mirror](./MIRROR.md). All development is happening elsewhere.*
|
||||
|
||||
Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy.
|
||||
|
||||

|
||||

|
||||
|
||||
Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers.
|
||||
|
||||
|
|
@ -12,15 +21,23 @@ Nomad Network is build on [LXMF](https://github.com/markqvist/LXMF) and [Reticul
|
|||
|
||||
Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over *extremely* low-bandwidth medium, and has been succesfully used over 300bps radio links.
|
||||
|
||||
If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android and macOS.
|
||||
If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at [Sideband](https://github.com/markqvist/sideband), which is available for Linux, Android, Windows and macOS.
|
||||
|
||||
Other Nomad Network clients include:
|
||||
|
||||
- [MeshChatX](https://meshchatx.com/)
|
||||
- [Columba (mobile)](https://github.com/torlando-tech/columba)
|
||||
- [Ren Browser](https://github.com/Quad4-Software/Ren-Browser)
|
||||
|
||||
## Notable Features
|
||||
- Encrypted messaging over packet-radio, LoRa, WiFi or anything else [Reticulum](https://github.com/markqvist/Reticulum) supports.
|
||||
- Zero-configuration, minimal-infrastructure mesh communication
|
||||
- Distributed and encrypted message store holds messages for offline users
|
||||
- Connectable nodes that can host pages and files
|
||||
- Image rendering support in terminals that support it
|
||||
- Node-side generated pages with PHP, Python, bash or others
|
||||
- Built-in text-based browser for interacting with contents on nodes
|
||||
- Built-in RRC client for live, many-to-many chat
|
||||
- An easy to use and bandwidth efficient markup language for writing pages
|
||||
- Page caching in browser
|
||||
|
||||
|
|
@ -76,10 +93,10 @@ The first time the program is running, you will be presented with the **Guide se
|
|||
|
||||
To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the [Reticulum documentation](https://markqvist.github.io/Reticulum/manual/interfaces.html) for info. For a general introduction on how to set up such a system, take a look at [this post](https://unsigned.io/private-messaging-over-lora/).
|
||||
|
||||
If you want to try Nomad Network without building your own physical network, you can connect to the [Unsigned.io RNS Testnet](https://github.com/markqvist/Reticulum#public-testnet) over the Internet, where there is already some Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here:
|
||||
If you want to try Nomad Network without building your own physical network, you can connect to the [distributed backbone](https://reticulum.network/manual/gettingstartedfast.html#bootstrapping-connectivity) over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here:
|
||||
|
||||
- `abb3ebcd03cb2388a838e70c001291f9` Dublin Hub Testnet Node
|
||||
- `ea6a715f814bdc37e56f80c34da6ad51` Frankfurt Hub Testnet Node
|
||||
- `9ce92808be498e9e05590ff27cbfdfe4` The rns.recipes forum
|
||||
- `a4a5e861626ce97c9aa544d9ecdf6d22` rmap.world
|
||||
|
||||
To browse pages on a node that is not currently known, open the URL dialog in the `Network` section of the program by pressing `Ctrl+U`, paste or enter the address and select `Go` or press enter. Nomadnet will attempt to discover and connect to the requested node.
|
||||
|
||||
|
|
@ -88,42 +105,12 @@ You can install Nomad Network on Android using Termux, but there's a few more co
|
|||
|
||||
For a native Android application with a graphical user interface, have a look at [Sideband](https://github.com/markqvist/Sideband).
|
||||
|
||||
### Docker Images
|
||||
|
||||
Nomad Network is automatically published as a docker image on Github Packages. Image tags are one of either `master` (for the very latest commit) or the version number (eg `0.2.0`) for a specific release.
|
||||
|
||||
```sh
|
||||
$ docker pull ghcr.io/markqvist/nomadnet:master
|
||||
|
||||
# Run nomadnet interactively in a container
|
||||
$ docker run -it ghcr.io/markqvist/nomadnet:master --textui
|
||||
|
||||
# Run nomadnet as a daemon, using config stored on the host machine in specified
|
||||
# directories, and connect the containers network to the host network (which will
|
||||
# allow the default AutoInterface to automatically peer with other discovered
|
||||
# Reticulum instances).
|
||||
$ docker run -d \
|
||||
-v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \
|
||||
-v /local/path/reticulumconfigdir/:/root/.reticulum/ \
|
||||
--network host
|
||||
ghcr.io/markqvist/nomadnet:master
|
||||
|
||||
# You can also keep the network of the container isolated from the host, but you
|
||||
# will need to manually configure one or more Reticulum interfaces to reach other
|
||||
# nodes in a network, by editing the Reticulum configuration file.
|
||||
$ docker run -d \
|
||||
-v /local/path/nomadnetconfigdir/:/root/.nomadnetwork/ \
|
||||
-v /local/path/reticulumconfigdir/:/root/.reticulum/ \
|
||||
ghcr.io/markqvist/nomadnet:master
|
||||
|
||||
# Send daemon log output to console instead of file
|
||||
$ docker run -i ghcr.io/markqvist/nomadnet:master --daemon --console
|
||||
```
|
||||
|
||||
## Tools & Extensions
|
||||
|
||||
Nomad Network is a very flexible and extensible platform, and a variety of community-provided tools, utilities and node-side extensions exist:
|
||||
|
||||
- [Retipedia](https://github.com/RFnexus/Retipedia)
|
||||
- [NomadForum](https://codeberg.org/AutumnSpark1226/nomadForum) ([GitHub mirror](https://github.com/AutumnSpark1226/nomadForum))
|
||||
- [NomadForecast](https://github.com/faragher/NomadForecast)
|
||||
- [micron-blog](https://github.com/randogoth/micron-blog)
|
||||
|
|
@ -137,9 +124,6 @@ Nomad Network is a very flexible and extensible platform, and a variety of commu
|
|||
- [POPR](https://github.com/faragher/POPR)
|
||||
- [LXMF Tools](https://github.com/SebastianObi/LXMF-Tools)
|
||||
|
||||
## Help & Discussion
|
||||
|
||||
For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the [Nomad Network discussions pages](https://github.com/markqvist/Reticulum/discussions/categories/nomad-network).
|
||||
|
||||
## Support Nomad Network
|
||||
You can help support the continued development of open, free and private communications systems by donating via one of the following channels:
|
||||
|
|
@ -161,32 +145,12 @@ You can help support the continued development of open, free and private communi
|
|||
- Ko-Fi: https://ko-fi.com/markqvist
|
||||
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
- New major features
|
||||
- Network-wide propagated bulletins and discussion threads
|
||||
- Collaborative maps and geospatial information sharing
|
||||
- Minor improvements and fixes
|
||||
- Link status (RSSI and SNR) in conversation or conv list
|
||||
- Ctrl-M shorcut for jumping to menu
|
||||
- Share node with other users / send node info to user
|
||||
- Fix internal editor failing on some OSes with no "editor" alias
|
||||
- Possibly add a required-width header
|
||||
- Improve browser handling of remote link close
|
||||
- Better navigation handling when requests fail (also because of closed links)
|
||||
- Retry failed messages mechanism
|
||||
- Re-arrange buttons to be more consistent
|
||||
- Term compatibility notice in readme
|
||||
- Selected icon in conversation list
|
||||
- Possibly a Search Local Nodes function
|
||||
- Possibly add via entry in node info box, next to distance
|
||||
|
||||
## Caveat Emptor
|
||||
Nomad Network is beta software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it _has not_ been externally security audited, and there could very well be privacy-breaking bugs. If you want to help out, or help sponsor an audit, please do get in touch.
|
||||
Nomad Network is beta software, and should be considered as such. Use at your own risk and responsibility.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
|
|
@ -194,4 +158,6 @@ Nomad Network is beta software, and should be considered as such. While it has b
|
|||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ If you'd rather want to use an LXMF client with a graphical user interface, you
|
|||
• Zero-configuration, minimal-infrastructure mesh communication
|
||||
• Distributed and encrypted message store holds messages for offline users
|
||||
• Connectable nodes that can host pages and files
|
||||
• Image rendering support in terminals that support it
|
||||
• Node-side generated pages with PHP, Python, bash or others
|
||||
• Built-in text-based browser for interacting with contents on nodes
|
||||
• Built-in RRC client for live, many-to-many chat
|
||||
|
|
@ -119,4 +120,4 @@ For this to be possible, I need your help. Please support the continued developm
|
|||
|
||||
>> Caveat Emptor
|
||||
|
||||
Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it `!has not`! been externally security audited, and there could very well be privacy-breaking bugs. Use at your own risk and responsibility.
|
||||
Nomad Network is experimental software, and should be considered as such. Use at your own risk and responsibility.
|
||||
|
|
|
|||
BIN
docs/screenshots/1a.png
Normal file
BIN
docs/screenshots/1a.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
|
@ -6,7 +6,7 @@ import nomadnet
|
|||
import threading
|
||||
import RNS.vendor.umsgpack as msgpack
|
||||
|
||||
from LXMF import pn_announce_data_is_valid
|
||||
from LXMF import pn_announce_data_is_valid, PN_META_IMPL_NAME, PN_META_VERSION
|
||||
from nomadnet.util import strip_modifiers
|
||||
from nomadnet.util import sanitize_name
|
||||
|
||||
|
|
@ -21,7 +21,10 @@ class PNAnnounceHandler:
|
|||
data = msgpack.unpackb(app_data)
|
||||
|
||||
if data[2] == True:
|
||||
RNS.log("Received active propagation node announce from "+RNS.prettyhexrep(destination_hash), RNS.LOG_DEBUG)
|
||||
metadata = data[6]
|
||||
if not PN_META_IMPL_NAME in metadata or not PN_META_VERSION in metadata: impl_str = ""
|
||||
else: impl_str = f" running {metadata[PN_META_IMPL_NAME]} {metadata[PN_META_VERSION]}"
|
||||
RNS.log(f"Received active propagation node announce from {RNS.prettyhexrep(destination_hash)}{impl_str}", RNS.LOG_DEBUG)
|
||||
|
||||
associated_peer = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", announced_identity)
|
||||
associated_node = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", announced_identity)
|
||||
|
|
|
|||
137
nomadnet/Node.py
137
nomadnet/Node.py
|
|
@ -4,9 +4,13 @@ import sys
|
|||
import RNS
|
||||
import time
|
||||
import threading
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import RNS.vendor.umsgpack as msgpack
|
||||
|
||||
from RNS.Utilities.rngit.media import convert_file_to_webp
|
||||
|
||||
class Node:
|
||||
JOB_INTERVAL = 5
|
||||
SETTINGS_FLUSH_INTERVAL = 60
|
||||
|
|
@ -26,12 +30,14 @@ class Node:
|
|||
self.job_interval = Node.JOB_INTERVAL
|
||||
self.should_run_jobs = True
|
||||
self.settings_dirty = False
|
||||
self.conversion_cache_lock = threading.Lock()
|
||||
self.last_settings_flush = time.time()
|
||||
self.app_data = None
|
||||
self.name = self.app.node_name
|
||||
|
||||
self.register_pages()
|
||||
self.register_files()
|
||||
self.register_media()
|
||||
|
||||
self.destination.set_link_established_callback(self.peer_connected)
|
||||
|
||||
|
|
@ -73,6 +79,10 @@ class Node:
|
|||
response_generator = self.serve_page,
|
||||
allow = RNS.Destination.ALLOW_ALL)
|
||||
|
||||
def register_media(self):
|
||||
self.destination.register_request_handler("/media", response_generator = self.serve_media,
|
||||
allow = RNS.Destination.ALLOW_ALL)
|
||||
|
||||
def register_files(self):
|
||||
# TODO: Deregister previously registered files
|
||||
# that no longer exist.
|
||||
|
|
@ -153,10 +163,130 @@ class Node:
|
|||
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
return None
|
||||
|
||||
NATIVE_MEDIA_EXTS = [".webp"]
|
||||
MEDIA_EXTS = [".webp", ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]
|
||||
CONVERSION_QUALITY = 85
|
||||
CONVERSION_MAX_DIMENSION = 1200
|
||||
CONVERSION_CACHE_MAX_FILES = 256
|
||||
CONVERSION_CACHE_MAX_BYTES = 192*1024*1024
|
||||
def serve_media(self, path, data, request_id, link_id, remote_identity, requested_at):
|
||||
if not type(data) == dict: return None
|
||||
if not "path" in data: return None
|
||||
if not "key" in data: return None
|
||||
|
||||
jail = os.path.normpath(self.app.pagespath)
|
||||
media_path = os.path.join(self.app.pagespath, data["path"].replace("/media/", "").lstrip("/"))
|
||||
media_path = os.path.normpath(media_path)
|
||||
base_name = os.path.basename(media_path)
|
||||
base_ext = os.path.splitext(base_name)[-1]
|
||||
|
||||
if not base_ext.lower() in self.MEDIA_EXTS:
|
||||
RNS.log(f"Invalid media request type: {media_path}, must be in {self.MEDIA_EXTS}", RNS.LOG_DEBUG)
|
||||
return False
|
||||
|
||||
if not media_path.startswith(jail+os.sep):
|
||||
RNS.log(f"Invalid media request path: {media_path}", RNS.LOG_DEBUG)
|
||||
return False
|
||||
|
||||
if len(media_path) > 512:
|
||||
RNS.log(f"Invalid media request path length: {len(media_path)}", RNS.LOG_DEBUG)
|
||||
return False
|
||||
|
||||
RNS.log(f"Media request {RNS.prettyhexrep(request_id)} for: {media_path}", RNS.LOG_VERBOSE)
|
||||
try:
|
||||
self.app.peer_settings["served_media_requests"] += 1
|
||||
self.settings_dirty = True
|
||||
except Exception as e: RNS.log("Could not increase served page request count", RNS.LOG_ERROR)
|
||||
|
||||
request_allowed = self.request_allowed(media_path, remote_identity)
|
||||
try:
|
||||
if request_allowed:
|
||||
if not base_ext.lower() in self.NATIVE_MEDIA_EXTS:
|
||||
converted_path = self.convert_media_to_webp(media_path)
|
||||
if converted_path is False:
|
||||
RNS.log(f"Could not convert {media_path} to WebP", RNS.LOG_DEBUG)
|
||||
return False
|
||||
file_path = converted_path
|
||||
file_name = os.path.splitext(base_name)[0]+".webp"
|
||||
else:
|
||||
file_path = media_path
|
||||
file_name = base_name
|
||||
|
||||
RNS.log(f"Serving media: {file_path}", RNS.LOG_VERBOSE)
|
||||
return [open(file_path, "rb"), {"name": file_name.encode("utf-8")}]
|
||||
|
||||
else:
|
||||
RNS.log("Request denied", RNS.LOG_VERBOSE)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Error occurred while handling request "+RNS.prettyhexrep(request_id)+" for: "+str(media_path), RNS.LOG_ERROR)
|
||||
RNS.log("The contained exception was: "+str(e), RNS.LOG_ERROR)
|
||||
return False
|
||||
|
||||
def convert_media_to_webp(self, media_path, quality = CONVERSION_QUALITY, max_dimension = CONVERSION_MAX_DIMENSION):
|
||||
try:
|
||||
hasher = hashlib.sha256()
|
||||
with open(media_path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""): hasher.update(chunk)
|
||||
source_hash = hasher.hexdigest()
|
||||
except Exception as e:
|
||||
RNS.log(f"Could not read media source for conversion: {media_path}", RNS.LOG_WARNING)
|
||||
return False
|
||||
|
||||
if quality == None: quality = Node.CONVERSION_QUALITY
|
||||
if max_dimension == None: max_dimension = Node.CONVERSION_MAX_DIMENSION
|
||||
|
||||
cache_name = f"{source_hash}.q{quality}.d{max_dimension if max_dimension is not None else 0}.webp"
|
||||
cache_path = os.path.join(self.app.convcachepath, cache_name)
|
||||
return self.__cached_conversion(media_path, cache_path, quality, max_dimension)
|
||||
|
||||
def __cached_conversion(self, media_path, cache_path, quality, max_dimension):
|
||||
with self.conversion_cache_lock:
|
||||
if os.path.isfile(cache_path):
|
||||
RNS.log(f"Using cached media conversion for: {media_path}", RNS.LOG_VERBOSE)
|
||||
try: os.utime(cache_path, None)
|
||||
except Exception: pass
|
||||
return cache_path
|
||||
|
||||
try:
|
||||
os.makedirs(self.app.convcachepath, exist_ok=True)
|
||||
converted_path = convert_file_to_webp(media_path, quality=quality, max_dimension=max_dimension)
|
||||
if converted_path is False: return False
|
||||
|
||||
shutil.move(converted_path, cache_path)
|
||||
self._prune_conversion_cache()
|
||||
RNS.log(f"Converted media: {media_path} -> {cache_path}", RNS.LOG_VERBOSE)
|
||||
return cache_path
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Error during media conversion of {media_path}: {e}", RNS.LOG_WARNING)
|
||||
return False
|
||||
|
||||
def _prune_conversion_cache(self):
|
||||
try:
|
||||
entries = []
|
||||
for name in os.listdir(self.app.convcachepath):
|
||||
path = os.path.join(self.app.convcachepath, name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
if st.st_size > 0:
|
||||
entries.append((st.st_mtime, st.st_size, path))
|
||||
except OSError: pass
|
||||
|
||||
total = sum(e[1] for e in entries)
|
||||
entries.sort()
|
||||
while len(entries) > Node.CONVERSION_CACHE_MAX_FILES or total > Node.CONVERSION_CACHE_MAX_BYTES:
|
||||
_, size, path = entries.pop(0)
|
||||
total -= size
|
||||
try: os.unlink(path)
|
||||
except OSError: pass
|
||||
except OSError: pass
|
||||
|
||||
def request_allowed(self, file_path, remote_identity):
|
||||
if file_path.lower().endswith(".allowed"): return False
|
||||
allowed_path = file_path+".allowed"
|
||||
if not os.path.isfile(allowed_path):
|
||||
return True
|
||||
if not os.path.isfile(allowed_path): return True
|
||||
|
||||
allowed_list = []
|
||||
try:
|
||||
|
|
@ -183,7 +313,6 @@ class Node:
|
|||
RNS.log("Denying request, remote identity was not in list of allowed identities", RNS.LOG_VERBOSE)
|
||||
return False
|
||||
|
||||
# TODO: Improve file handling, this will be slow for large files
|
||||
def serve_file(self, path, data, request_id, remote_identity, requested_at):
|
||||
RNS.log("File request "+RNS.prettyhexrep(request_id)+" for: "+str(path), RNS.LOG_VERBOSE)
|
||||
try:
|
||||
|
|
@ -194,7 +323,7 @@ class Node:
|
|||
RNS.log("Could not increase served file request count", RNS.LOG_ERROR)
|
||||
|
||||
file_path = path.replace("/file", self.app.filespath, 1)
|
||||
file_name = path.replace("/file/", "", 1)
|
||||
file_name = os.path.basename(file_path)
|
||||
if not self.request_allowed(file_path, remote_identity):
|
||||
RNS.log("Request denied", RNS.LOG_VERBOSE)
|
||||
return DEFAULT_NOTALLOWED.encode("utf-8")
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ class NomadNetworkApp:
|
|||
self.storagepath = self.configdir+"/storage"
|
||||
self.identitypath = self.configdir+"/storage/identity"
|
||||
self.cachepath = self.configdir+"/storage/cache"
|
||||
self.imagecachepath = self.configdir+"/storage/cache/images/browser"
|
||||
self.convcachepath = self.configdir+"/storage/cache/images/converted_node"
|
||||
self.dispcachepath = self.configdir+"/storage/cache/images/converted_disp"
|
||||
self.resourcepath = self.configdir+"/storage/resources"
|
||||
self.conversationpath = self.configdir+"/storage/conversations"
|
||||
self.directorypath = self.configdir+"/storage/directory"
|
||||
|
|
@ -121,7 +124,6 @@ class NomadNetworkApp:
|
|||
|
||||
self.pagespath = self.configdir+"/storage/pages"
|
||||
self.filespath = self.configdir+"/storage/files"
|
||||
self.cachepath = self.configdir+"/storage/cache"
|
||||
self.examplespath = self.configdir+"/examples"
|
||||
|
||||
self.downloads_path = os.path.expanduser("~/Downloads")
|
||||
|
|
@ -176,6 +178,15 @@ class NomadNetworkApp:
|
|||
if not os.path.isdir(self.cachepath):
|
||||
os.makedirs(self.cachepath)
|
||||
|
||||
if not os.path.isdir(self.imagecachepath):
|
||||
os.makedirs(self.imagecachepath)
|
||||
|
||||
if not os.path.isdir(self.convcachepath):
|
||||
os.makedirs(self.convcachepath)
|
||||
|
||||
if not os.path.isdir(self.dispcachepath):
|
||||
os.makedirs(self.dispcachepath)
|
||||
|
||||
if not os.path.isdir(self.resourcepath):
|
||||
os.makedirs(self.resourcepath)
|
||||
|
||||
|
|
@ -274,9 +285,15 @@ class NomadNetworkApp:
|
|||
if not "served_page_requests" in self.peer_settings:
|
||||
self.peer_settings["served_page_requests"] = 0
|
||||
|
||||
if not "served_media_requests" in self.peer_settings:
|
||||
self.peer_settings["served_media_requests"] = 0
|
||||
|
||||
if not "served_file_requests" in self.peer_settings:
|
||||
self.peer_settings["served_file_requests"] = 0
|
||||
|
||||
if not "quit_confirm" in self.peer_settings:
|
||||
self.peer_settings["quit_confirm"] = True
|
||||
|
||||
self.peer_settings["announce_interval"] = self.announce_interval
|
||||
if path != self.peersettingspath:
|
||||
self.peer_settings_notice = "Peer settings were restored from the last backup.\nCheck your display name and propagation node."
|
||||
|
|
@ -299,6 +316,7 @@ class NomadNetworkApp:
|
|||
"last_announce": None,
|
||||
"node_last_announce": None,
|
||||
"propagation_node": None,
|
||||
"quit_confirm": True,
|
||||
"last_lxmf_sync": 0,
|
||||
"node_connects": 0,
|
||||
"served_page_requests": 0,
|
||||
|
|
@ -985,6 +1003,20 @@ class NomadNetworkApp:
|
|||
else:
|
||||
raise ValueError("The selected Text UI color mode is invalid")
|
||||
|
||||
if not "image_loading" in self.config["textui"]:
|
||||
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO
|
||||
else:
|
||||
if self.config["textui"]["image_loading"].lower() == "never":
|
||||
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_NEVER
|
||||
elif self.config["textui"]["image_loading"].lower() == "manual":
|
||||
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_MANUAL
|
||||
elif self.config["textui"]["image_loading"].lower() == "auto":
|
||||
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_AUTO
|
||||
elif self.config["textui"]["image_loading"].lower() == "always":
|
||||
self.config["textui"]["image_loading"] = nomadnet.ui.TextUI.IMGLOAD_ALWAYS
|
||||
else:
|
||||
raise ValueError("The selected image loading mode is invalid")
|
||||
|
||||
if not "theme" in self.config["textui"]:
|
||||
self.config["textui"]["theme"] = nomadnet.ui.TextUI.THEME_DARK
|
||||
else:
|
||||
|
|
@ -1391,6 +1423,15 @@ colormode = 24bit
|
|||
# glyphs = unicode
|
||||
glyphs = nerdfont
|
||||
|
||||
# If your terminal supports image output,
|
||||
# the page browser can display images and
|
||||
# graphics on pages. You can specify how,
|
||||
# or if at all, images are loaded. Valid
|
||||
# options are "auto", "manual", "never"
|
||||
# and "always".
|
||||
|
||||
image_loading = auto
|
||||
|
||||
# You can specify whether mouse events
|
||||
# should be considered as input to the
|
||||
# application. On by default.
|
||||
|
|
|
|||
147
nomadnet/RRC.py
147
nomadnet/RRC.py
|
|
@ -118,7 +118,7 @@ def _msg_id():
|
|||
# greedy .+ intentionally captures nicks containing parens like "user (alt) (deadbeefcafe)"
|
||||
_WHO_ENTRY_RE = re.compile(
|
||||
r"(?:^|,\s)"
|
||||
r"(?:(?P<bh>[0-9a-fA-F]{32})|(?P<nick>.+?)\s\((?P<np>[0-9a-fA-F]{12})\))"
|
||||
r"(?:(?P<bh>[0-9a-fA-F]{64})|(?P<nick>.+?)\s\((?P<np>[0-9a-fA-F]{12})\))"
|
||||
r"(?=,\s|$)"
|
||||
)
|
||||
|
||||
|
|
@ -170,6 +170,16 @@ def _parse_room_list_notice(text):
|
|||
return rooms
|
||||
|
||||
|
||||
# On low-MTU links the hub may deliver a /list reply as a sequence of
|
||||
# chunked NOTICE packets (one line per packet) instead of a single notice
|
||||
# or a resource transfer. The chunks carry no continuation marker, but each
|
||||
# line has a recognisable shape. Lines matching it, arriving within a short
|
||||
# window after a /list header, are treated as continuation fragments and
|
||||
# merged into the room list instead of being recorded as chat messages.
|
||||
_LIST_FRAGMENT_RE = re.compile(r"^ (?P<name>\S+?)(?: - (?P<topic>.*))?$")
|
||||
LIST_FRAGMENT_WINDOW = 60.0
|
||||
|
||||
|
||||
def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None):
|
||||
env = {
|
||||
K_V: RRC_VERSION,
|
||||
|
|
@ -235,6 +245,11 @@ class RRCHub:
|
|||
self.mention_rooms = set()
|
||||
self.members = {}
|
||||
self.nicks = {}
|
||||
# Display-only member entries derived from 12-hex identity prefixes
|
||||
# in /who replies. Populated when the full identity hash of a nicked
|
||||
# member is not yet known, and promoted to regular members as soon
|
||||
# as the full hash is learned from room traffic or join fanouts.
|
||||
self.prefix_members = {}
|
||||
|
||||
self.auto_reconnect = False
|
||||
self.auto_list = False
|
||||
|
|
@ -259,6 +274,7 @@ class RRCHub:
|
|||
self.available_rooms = {}
|
||||
self._silent_list_pending = 0
|
||||
self._silent_who_rooms = set()
|
||||
self._list_fragment_until = 0.0
|
||||
|
||||
self.nick_override = None
|
||||
self._pending_joins = set()
|
||||
|
|
@ -290,6 +306,7 @@ class RRCHub:
|
|||
self.unread_rooms.discard(r)
|
||||
self.mention_rooms.discard(r)
|
||||
self.members.pop(r, None)
|
||||
self.prefix_members.pop(r, None)
|
||||
self._delete_history(r)
|
||||
self.manager.save()
|
||||
self.manager._notify_change(self)
|
||||
|
|
@ -306,7 +323,23 @@ class RRCHub:
|
|||
|
||||
def get_members(self, room):
|
||||
with self._lock:
|
||||
return list(self.members.get(room, set()))
|
||||
return list(self.members.get(room, set()) | self.prefix_members.get(room, set()))
|
||||
|
||||
def _upgrade_prefix_member(self, room, full_hash):
|
||||
# Once a full identity hash is learned from room traffic or join
|
||||
# fanouts, promote any display-only prefix member that matches it,
|
||||
# so the entry becomes a full, addressable member. The prefix
|
||||
# entry's nickname is carried over unless the promoting source
|
||||
# provides one.
|
||||
pset = self.prefix_members.get(room)
|
||||
if pset:
|
||||
for p in list(pset):
|
||||
if full_hash.startswith(p):
|
||||
pset.discard(p)
|
||||
nick = self.nicks.pop(p, None)
|
||||
if nick and full_hash not in self.nicks:
|
||||
self.nicks[full_hash] = nick
|
||||
break
|
||||
|
||||
def display_name_for(self, peer):
|
||||
if not isinstance(peer, (bytes, bytearray)):
|
||||
|
|
@ -467,6 +500,7 @@ class RRCHub:
|
|||
self.welcomed = False
|
||||
self.motd = None
|
||||
self.members.clear()
|
||||
self.prefix_members.clear()
|
||||
self._resource_expectations.clear()
|
||||
self._pending_joins.clear()
|
||||
self._pending_parts.clear()
|
||||
|
|
@ -844,23 +878,50 @@ class RRCHub:
|
|||
def _process_notice_text(self, text):
|
||||
# Parse hub service notices (/list and /who replies) regardless of
|
||||
# whether they arrived as a packet or a resource transfer. Returns
|
||||
# True when the notice was consumed silently (auto /list or /who)
|
||||
# and should not be recorded to the message log.
|
||||
# True when the notice was consumed silently (auto /list or /who,
|
||||
# or a /list continuation fragment) and should not be recorded to
|
||||
# the message log.
|
||||
parsed = _parse_room_list_notice(text)
|
||||
if parsed is not None:
|
||||
with self._lock:
|
||||
self.available_rooms = {strip_modifiers(name): strip_modifiers(topic) for name, topic in parsed.items()}
|
||||
# The first line of a /list reply opens a short window in
|
||||
# which continuation fragments (chunked NOTICE packets on
|
||||
# low-MTU links) are absorbed into the room list.
|
||||
self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW
|
||||
silent = self._silent_list_pending > 0
|
||||
if silent:
|
||||
self._silent_list_pending -= 1
|
||||
self.manager._notify_change(self)
|
||||
if silent:
|
||||
if silent: return True
|
||||
with self._lock:
|
||||
list_window_active = time.monotonic() < self._list_fragment_until
|
||||
if list_window_active:
|
||||
fm = _LIST_FRAGMENT_RE.match(text)
|
||||
if fm is not None:
|
||||
with self._lock:
|
||||
frag_name = fm.group("name").strip().lower()
|
||||
frag_topic = fm.group("topic")
|
||||
if frag_topic:
|
||||
frag_topic = strip_modifiers(frag_topic.strip()) or None
|
||||
else:
|
||||
frag_topic = None
|
||||
self.available_rooms[frag_name] = frag_topic
|
||||
self._list_fragment_until = time.monotonic()+LIST_FRAGMENT_WINDOW
|
||||
self.manager._notify_change(self)
|
||||
return True
|
||||
parsed_who = _parse_who_notice(text)
|
||||
if parsed_who is not None:
|
||||
who_room, who_entries = parsed_who
|
||||
with self._lock:
|
||||
members = self.members.setdefault(who_room, set())
|
||||
# Treat /who replies as fresh roster snapshots: replace any
|
||||
# display-only prefix members from earlier replies before
|
||||
# applying this one.
|
||||
prefix_members = self.prefix_members.setdefault(who_room, set())
|
||||
for p in list(prefix_members):
|
||||
self.nicks.pop(p, None)
|
||||
prefix_members.clear()
|
||||
for nick, hash_hex in who_entries:
|
||||
try:
|
||||
hash_bytes = bytes.fromhex(hash_hex)
|
||||
|
|
@ -868,11 +929,23 @@ class RRCHub:
|
|||
continue
|
||||
if nick is None:
|
||||
members.add(hash_bytes)
|
||||
self._upgrade_prefix_member(who_room, hash_bytes)
|
||||
continue
|
||||
known = None
|
||||
for ph in members:
|
||||
if ph.startswith(hash_bytes):
|
||||
self.nicks[ph] = nick
|
||||
known = ph
|
||||
break
|
||||
if known is not None:
|
||||
self.nicks[known] = nick
|
||||
else:
|
||||
# A /who line only carries a 12-hex identity prefix
|
||||
# for nicked members. If the full hash is not yet
|
||||
# known, keep the prefix as a display-only member.
|
||||
# It is promoted to a full member as soon as the
|
||||
# hash is learned from room traffic or fanouts.
|
||||
prefix_members.add(hash_bytes)
|
||||
self.nicks[hash_bytes] = nick
|
||||
silent_who = who_room in self._silent_who_rooms
|
||||
if silent_who:
|
||||
self._silent_who_rooms.discard(who_room)
|
||||
|
|
@ -987,8 +1060,10 @@ class RRCHub:
|
|||
members = self.members.setdefault(r, set())
|
||||
for h in body_hashes:
|
||||
members.add(h)
|
||||
self._upgrade_prefix_member(r, h)
|
||||
if own_hash is not None:
|
||||
members.add(own_hash)
|
||||
self._upgrade_prefix_member(r, own_hash)
|
||||
|
||||
# rrcd 0.3.2 attaches advisory K_NICK to fanout JOINED; learn it
|
||||
# so display_name_for() can render the nick instead of a hash prefix.
|
||||
|
|
@ -1043,12 +1118,19 @@ class RRCHub:
|
|||
self.nicks[ph] = parter_nick
|
||||
|
||||
members = self.members.get(r)
|
||||
pset = self.prefix_members.get(r)
|
||||
if members is not None:
|
||||
for h in body_hashes:
|
||||
members.discard(h)
|
||||
if pset:
|
||||
for p in list(pset):
|
||||
if h.startswith(p):
|
||||
pset.discard(p)
|
||||
self.nicks.pop(p, None)
|
||||
if self_part:
|
||||
self.rooms.discard(r)
|
||||
self.members.pop(r, None)
|
||||
self.prefix_members.pop(r, None)
|
||||
|
||||
if self_part:
|
||||
self.manager.save()
|
||||
|
|
@ -1075,7 +1157,9 @@ class RRCHub:
|
|||
with self._lock:
|
||||
self.nicks[bytes(src)] = nick
|
||||
if isinstance(room, str) and room:
|
||||
self.members.setdefault(room.strip().lower(), set()).add(bytes(src))
|
||||
rn = room.strip().lower()
|
||||
self.members.setdefault(rn, set()).add(bytes(src))
|
||||
self._upgrade_prefix_member(rn, bytes(src))
|
||||
if isinstance(body, str):
|
||||
msg = RRCMessage(
|
||||
"msg",
|
||||
|
|
@ -1108,7 +1192,9 @@ class RRCHub:
|
|||
with self._lock:
|
||||
self.nicks[bytes(src)] = nick
|
||||
if isinstance(room, str) and room:
|
||||
self.members.setdefault(room.strip().lower(), set()).add(bytes(src))
|
||||
rn = room.strip().lower()
|
||||
self.members.setdefault(rn, set()).add(bytes(src))
|
||||
self._upgrade_prefix_member(rn, bytes(src))
|
||||
if isinstance(body, str):
|
||||
msg = RRCMessage(
|
||||
"action",
|
||||
|
|
@ -1132,12 +1218,47 @@ class RRCHub:
|
|||
room = env.get(K_ROOM)
|
||||
src = env.get(K_SRC)
|
||||
if isinstance(body, str):
|
||||
if self._process_notice_text(body):
|
||||
return
|
||||
#################### HACK ZONE #####################
|
||||
# This is the kind of crazy mess that happens when
|
||||
# an LLM designs a chat protocol, and the client
|
||||
# maintainer is too open-minded, decides to go with
|
||||
# it, and now is responsible for the fundamental
|
||||
# architectural chaos.
|
||||
#
|
||||
# Yes, I love RRC, but trying to figure out an
|
||||
# elegant way to solve the underlying issues that
|
||||
# make this kind of mess necessary revealed the
|
||||
# following answer: There is none.
|
||||
#
|
||||
# There are so many bugs, weird cases, architectural
|
||||
# mismatches, impossible situations and general
|
||||
# weirdness here, that I am just not going to go and
|
||||
# attempt to sort it all out. Sorry, I don't have the
|
||||
# time, so a hack like this is what it will be. This
|
||||
# makes things actually work semi-expectedly, even
|
||||
# over low-bandwidth links and such, so hacky fix it
|
||||
# is. Until we have something better, this will do
|
||||
# just fine. On a related note, I was considering
|
||||
# taking up maintenance of rrcd, now that it seems to
|
||||
# not really be worked on or updated anymore, but
|
||||
# looking at the entire system, I think it's better
|
||||
# left as it; it works fine, albeit with a few quirks
|
||||
# for it's purpose. My future work in chat-related
|
||||
# protocols on Reticulum will be focused on a better
|
||||
# architecture, incorporating everything we learned
|
||||
# from RRC, and making it Reticulum-native from the
|
||||
# start, instead of mirroring IRC too closely.
|
||||
# - Mark
|
||||
was_parsed = self._process_notice_text(body)
|
||||
room_n = room.strip().lower() if isinstance(room, str) else None
|
||||
if room_n is None and isinstance(body, str) and body.strip():
|
||||
with self._lock:
|
||||
self.motd = strip_modifiers(body)
|
||||
probably_motd = True
|
||||
if was_parsed: probably_motd = False
|
||||
if body.lstrip().lower().startswith("registered public rooms:"): probably_motd = False; was_parsed = True
|
||||
if body.lstrip().lower().startswith("members in "): probably_motd = False; was_parsed = True
|
||||
if probably_motd:
|
||||
with self._lock: self.motd = strip_modifiers(body)
|
||||
was_parsed = False
|
||||
self.manager._notify_change(self)
|
||||
msg = RRCMessage(
|
||||
"notice",
|
||||
|
|
@ -1147,7 +1268,7 @@ class RRCHub:
|
|||
body,
|
||||
_now_ms(),
|
||||
)
|
||||
self._record_notice(msg)
|
||||
if not was_parsed: self._record_notice(msg)
|
||||
return
|
||||
|
||||
if t == T_ERROR:
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__version__ = "1.3.1"
|
||||
__version__ = "1.4.4"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import platform
|
|||
import nomadnet
|
||||
from nomadnet.ui import THEME_DARK, THEME_LIGHT
|
||||
from nomadnet.ui.textui import *
|
||||
from nomadnet.ui.textui.images import ImageScreen
|
||||
from nomadnet import NomadNetworkApp
|
||||
|
||||
COLORMODE_MONO = 1
|
||||
|
|
@ -15,6 +16,11 @@ COLORMODE_88 = 88
|
|||
COLORMODE_256 = 256
|
||||
COLORMODE_TRUE = 2**24
|
||||
|
||||
IMGLOAD_NEVER = 0x00
|
||||
IMGLOAD_MANUAL = 0x01
|
||||
IMGLOAD_AUTO = 0x02
|
||||
IMGLOAD_ALWAYS = 0x03
|
||||
|
||||
THEMES = {
|
||||
THEME_DARK: {
|
||||
"urwid_theme": [
|
||||
|
|
@ -245,7 +251,7 @@ class TextUI:
|
|||
for glyph in GLYPHS:
|
||||
self.glyphs[glyph[0]] = glyph[GLYPHSETS[glyphset]]
|
||||
|
||||
self.screen = urwid.raw_display.Screen()
|
||||
self.screen = ImageScreen()
|
||||
self.screen.register_palette(self.palette)
|
||||
|
||||
self.main_display = Main.MainDisplay(self, self.app)
|
||||
|
|
@ -291,10 +297,8 @@ class TextUI:
|
|||
self.restore_palette = True
|
||||
|
||||
def unhandled_input(self, key):
|
||||
if key == "ctrl q":
|
||||
self.main_display.show_quit_dialog()
|
||||
elif key == "ctrl e":
|
||||
pass
|
||||
if key == "ctrl q": self.main_display.quit()
|
||||
elif key == "ctrl e": pass
|
||||
|
||||
def display_main(self, loop, user_data):
|
||||
self.loop.widget = self.main_display.widget
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import LXMF
|
|||
import io
|
||||
import os
|
||||
import time
|
||||
import bisect
|
||||
import urwid
|
||||
import shutil
|
||||
import nomadnet
|
||||
|
|
@ -15,9 +16,15 @@ from nomadnet.Conversation import ConversationMessage
|
|||
from nomadnet.vendor.Scrollable import *
|
||||
from nomadnet.util import strip_modifiers
|
||||
from nomadnet.util import sanitize_name
|
||||
from nomadnet.ui.textui.images import _termlib
|
||||
from .Helpers import ClickableIcon, osc52_copy
|
||||
from .ReadlineEdit import ReadlineMixin, ReadlineEdit
|
||||
|
||||
IMGLOAD_NEVER = 0x00
|
||||
IMGLOAD_MANUAL = 0x01
|
||||
IMGLOAD_AUTO = 0x02
|
||||
IMGLOAD_ALWAYS = 0x03
|
||||
|
||||
class BrowserFrame(urwid.Frame):
|
||||
def keypress(self, size, key):
|
||||
if key == "ctrl w":
|
||||
|
|
@ -30,6 +37,10 @@ class BrowserFrame(urwid.Frame):
|
|||
self.delegate.reload()
|
||||
elif key == "ctrl u":
|
||||
self.delegate.url_dialog()
|
||||
elif key == "ctrl l":
|
||||
self.delegate.load_images()
|
||||
elif key == "ctrl x":
|
||||
self.delegate.load_images(force_reload=True)
|
||||
elif key == "ctrl s":
|
||||
self.delegate.save_node_dialog()
|
||||
elif key == "ctrl b":
|
||||
|
|
@ -42,11 +53,11 @@ class BrowserFrame(urwid.Frame):
|
|||
elif self.focus_position == "body":
|
||||
if key == "down" or key == "up":
|
||||
try:
|
||||
if hasattr(self.delegate, "page_pile") and self.delegate.page_pile:
|
||||
if hasattr(self.delegate, "page_list") and self.delegate.page_list:
|
||||
def df(loop, user_data):
|
||||
st = None
|
||||
if self.delegate.page_pile:
|
||||
nf = self.delegate.page_pile.focus
|
||||
if self.delegate.page_list:
|
||||
nf = self.delegate.page_list.focus
|
||||
if hasattr(nf, "key_timeout"):
|
||||
st = nf
|
||||
elif hasattr(nf, "original_widget"):
|
||||
|
|
@ -70,6 +81,168 @@ class BrowserFrame(urwid.Frame):
|
|||
else:
|
||||
return super(BrowserFrame, self).keypress(size, key)
|
||||
|
||||
class PageList(urwid.ListBox):
|
||||
def __init__(self, body):
|
||||
super().__init__(body)
|
||||
self._page_size = (0, 0)
|
||||
self._page_focus = None
|
||||
self._page_layouts = {}
|
||||
|
||||
def invalidate_layout(self):
|
||||
self._page_layouts = {}
|
||||
self._invalidate()
|
||||
|
||||
def _layout(self, maxcol):
|
||||
offsets = self._page_layouts.get(maxcol)
|
||||
if offsets is None or len(offsets) != len(self.body) + 1:
|
||||
layout_started = time.time()
|
||||
offsets = [0]
|
||||
for widget in self.body:
|
||||
offsets.append(offsets[-1] + widget.rows((maxcol,), True))
|
||||
self._page_layouts[maxcol] = offsets
|
||||
# RNS.log(f" rebuilt {len(self.body)} widgets, {offsets[-1]} rows, width {maxcol}, {(time.time()-layout_started)*1000:.1f}ms", RNS.LOG_DEBUG)
|
||||
return offsets
|
||||
|
||||
def calculate_visible(self, size, focus=False):
|
||||
visible = super().calculate_visible(size, focus)
|
||||
if visible is not None and self.body:
|
||||
self._sync_layout(size[0], visible)
|
||||
return visible
|
||||
|
||||
def _sync_layout(self, maxcol, visible):
|
||||
# Validate the cached layout against the live geometry of the
|
||||
# currently visible widgets, and delta-update the cached offsets when a
|
||||
# visible widget has changed height since the layout was built without
|
||||
# rescanning the entire document on every render.
|
||||
|
||||
if not self.body or not self._page_layouts: return
|
||||
|
||||
offsets = self._page_layouts.get(maxcol)
|
||||
if offsets is None or len(offsets) != len(self.body) + 1: return
|
||||
|
||||
def sync_position(pos, live_rows):
|
||||
if pos < 0 or pos >= len(self.body) or live_rows < 0: return
|
||||
cached_rows = offsets[pos + 1] - offsets[pos]
|
||||
if cached_rows == live_rows: return
|
||||
|
||||
delta = live_rows - cached_rows
|
||||
for i in range(pos + 1, len(offsets)): offsets[i] += delta
|
||||
|
||||
# Other cached widths may also be affected by this change. Sync
|
||||
# them using the changed widget's live rows at their widths.
|
||||
for width, other in list(self._page_layouts.items()):
|
||||
if width == maxcol or len(other) != len(offsets): continue
|
||||
other_cached = other[pos + 1] - other[pos]
|
||||
other_live = self.body[pos].rows((width,), True)
|
||||
if other_cached == other_live: continue
|
||||
other_delta = other_live - other_cached
|
||||
for i in range(pos + 1, len(other)): other[i] += other_delta
|
||||
|
||||
middle = visible.middle
|
||||
sync_position(middle.focus_pos, middle.focus_rows)
|
||||
for item in visible.top.fill: sync_position(item.position, item.rows)
|
||||
for item in visible.bottom.fill: sync_position(item.position, item.rows)
|
||||
|
||||
def render(self, size, focus=False):
|
||||
self._page_size = size
|
||||
if self.body and self.focus is not self._page_focus:
|
||||
self._page_focus = self.focus
|
||||
widget = self.focus
|
||||
while widget is not None:
|
||||
widget._invalidate()
|
||||
widget = getattr(widget, "original_widget", None)
|
||||
return super().render(size, focus)
|
||||
|
||||
def rows_max(self, size=None, focus=False):
|
||||
size = size or self._page_size
|
||||
return self._layout(size[0])[-1]
|
||||
|
||||
def get_scrollpos(self, size=None, focus=False):
|
||||
size = size or self._page_size
|
||||
if not self.body or not size[1]: return 0
|
||||
|
||||
middle, top, bottom = self.calculate_visible(size, focus)
|
||||
if middle is None: return 0
|
||||
return self._layout(size[0])[self.top_position(size)] + top.trim
|
||||
|
||||
def set_scrollpos(self, position):
|
||||
size = self._page_size
|
||||
maxcol, maxrow = size
|
||||
if not self.body or not maxrow: return
|
||||
|
||||
offsets = self._layout(maxcol)
|
||||
position = max(0, min(int(position), max(0, offsets[-1] - maxrow)))
|
||||
pos = bisect.bisect_left(offsets, position, 0, len(self.body))
|
||||
if pos == len(self.body) or offsets[pos] - position >= maxrow: pos -= 1
|
||||
|
||||
# Guard against the cached layout being inconsistent
|
||||
# with the live widget geometry. Without it, change_focus
|
||||
# can be called with an offset that the live target widget
|
||||
# cannot satisfy, raising ListBoxError.
|
||||
pos = max(0, min(pos, len(self.body) - 1))
|
||||
offset_inset = offsets[pos] - position
|
||||
if offset_inset > maxrow - 1: offset_inset = maxrow - 1
|
||||
elif offset_inset < 0:
|
||||
tgt_rows = max(1, self.body[pos].rows((maxcol,), True))
|
||||
if offset_inset < 1 - tgt_rows: offset_inset = 1 - tgt_rows
|
||||
|
||||
self.change_focus(size, pos, offset_inset)
|
||||
|
||||
def top_position(self, size=None):
|
||||
size = size or self._page_size
|
||||
if not self.body or not size[1]: return 0
|
||||
middle, top, bottom = self.calculate_visible(size, True)
|
||||
if middle is None: return 0
|
||||
return top.fill[-1].position if top.fill else middle.focus_pos
|
||||
|
||||
def scroll(self, rows):
|
||||
self.set_scrollpos(self.get_scrollpos() + rows)
|
||||
|
||||
def focused_widget_name(self):
|
||||
widget = self.focus
|
||||
while getattr(widget, "original_widget", None) is not None: widget = widget.original_widget
|
||||
return type(widget).__name__
|
||||
|
||||
def keypress(self, size, key):
|
||||
self._page_size = size
|
||||
key_started = time.time()
|
||||
focus_before = self.focus_position if self.body else None
|
||||
scroll_before = self.get_scrollpos(size, True)
|
||||
unhandled_key = self.handle_key(size, key)
|
||||
|
||||
# RNS.log(f" {key}: {unhandled_key} focus {focus_before} -> {self.focus_position if self.body else None} ({self.focused_widget_name()}) scrollpos {scroll_before}->{self.get_scrollpos(size, True)}, {(time.time()-key_started) * 1000:.1f}ms", RNS.LOG_DEBUG)
|
||||
return unhandled_key
|
||||
|
||||
def handle_key(self, size, key):
|
||||
try:
|
||||
if key in ("page up", "page down", "home", "end"):
|
||||
focus_widget = self.focus
|
||||
if focus_widget is not None and focus_widget.selectable():
|
||||
key = focus_widget.keypress((size[0],), key)
|
||||
if key is None:
|
||||
self.make_cursor_visible(size)
|
||||
return None
|
||||
|
||||
if key == "page down": self.scroll(size[1] - 1)
|
||||
elif key == "page up": self.scroll(1 - size[1])
|
||||
elif key == "home": self.set_scrollpos(0)
|
||||
elif key == "end": self.set_scrollpos(self.rows_max(size))
|
||||
return None
|
||||
|
||||
unhandled_key = super().keypress(size, key)
|
||||
if key in ("up", "down"): return None
|
||||
return unhandled_key
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while handling browser keypress: {e}", RNS.LOG_ERROR)
|
||||
RNS.trace_exception(e)
|
||||
return None
|
||||
|
||||
def mouse_event(self, size, event, button, col, row, focus):
|
||||
self._page_size = size
|
||||
if button in (4, 5): return False
|
||||
return super().mouse_event(size, event, button, col, row, focus)
|
||||
|
||||
class Browser:
|
||||
DEFAULT_PATH = "/page/index.mu"
|
||||
DEFAULT_TIMEOUT = 10
|
||||
|
|
@ -123,10 +296,17 @@ class Browser:
|
|||
self.link_target = None
|
||||
self.frame = None
|
||||
self.attr_maps = []
|
||||
self.page_pile = None
|
||||
self.page_list = None
|
||||
self.page_rows = []
|
||||
self.page_partials = {}
|
||||
self.page_images = {}
|
||||
self.updater_running = False
|
||||
self.image_updater_running = False
|
||||
self.partial_updater_lock = Lock()
|
||||
self.image_updater_lock = Lock()
|
||||
self.image_cache_lock = Lock()
|
||||
self.image_mode = self.app.config["textui"]["image_loading"]
|
||||
self.image_rendering_supported = _termlib.is_kitty_supported() and self.image_mode != IMGLOAD_NEVER
|
||||
self.build_display()
|
||||
|
||||
self.history = []
|
||||
|
|
@ -144,28 +324,26 @@ class Browser:
|
|||
|
||||
self.clean_cache()
|
||||
|
||||
if self.image_rendering_supported: RNS.log(f"In-browser image rendering supported", RNS.LOG_DEBUG)
|
||||
else: RNS.log(f"In-browser image rendering is not supported", RNS.LOG_DEBUG)
|
||||
|
||||
def current_url(self):
|
||||
if self.destination_hash == None:
|
||||
return ""
|
||||
if self.destination_hash == None: return ""
|
||||
path = "" if self.path == None else self.path
|
||||
url = RNS.hexrep(self.destination_hash, delimit=False)+":"+path
|
||||
|
||||
if isinstance(self.request_data, dict) and self.request_data:
|
||||
parts = []
|
||||
for k, v in self.request_data.items():
|
||||
if not isinstance(k, str):
|
||||
continue
|
||||
if not k.startswith("var_"):
|
||||
continue
|
||||
if not isinstance(k, str): continue
|
||||
if not k.startswith("var_"): continue
|
||||
parts.append(k[4:]+"="+str(v))
|
||||
if parts:
|
||||
url += "`" + "|".join(parts)
|
||||
if parts: url += "`" + "|".join(parts)
|
||||
|
||||
return url
|
||||
|
||||
def url_hash(self, url):
|
||||
if url == None:
|
||||
return None
|
||||
if url == None: return None
|
||||
else:
|
||||
url = url.encode("utf-8")
|
||||
return RNS.hexrep(RNS.Identity.full_hash(url), delimit=False)
|
||||
|
|
@ -267,7 +445,7 @@ class Browser:
|
|||
pass # do nothing if checkbox is not check
|
||||
|
||||
recurse_down(self.attr_maps)
|
||||
RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG)
|
||||
RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME)
|
||||
|
||||
# In-document anchor link (#name or empty #)
|
||||
if link_target.startswith("#"):
|
||||
|
|
@ -326,8 +504,6 @@ class Browser:
|
|||
anchors = getattr(self.attr_maps, "anchors", None) or {}
|
||||
header_rows = getattr(self.attr_maps, "header_rows", None) or []
|
||||
|
||||
cols = self._content_cols()
|
||||
|
||||
target_idx = None
|
||||
if name:
|
||||
target_idx = anchors.get(name)
|
||||
|
|
@ -338,11 +514,11 @@ class Browser:
|
|||
else:
|
||||
current = 0
|
||||
try:
|
||||
current = self.browser_body.original_widget.original_widget.get_scrollpos()
|
||||
current = self.page_rows[self.page_list.top_position()]
|
||||
except Exception:
|
||||
current = 0
|
||||
for hr in header_rows:
|
||||
if self._rows_above(hr, cols) > current:
|
||||
if hr > current:
|
||||
target_idx = hr
|
||||
break
|
||||
if target_idx is None:
|
||||
|
|
@ -350,12 +526,9 @@ class Browser:
|
|||
|
||||
self.reveal_index(int(target_idx))
|
||||
|
||||
row_offset = self._rows_above(int(target_idx), cols)
|
||||
|
||||
try:
|
||||
scrollable = self.browser_body.original_widget.original_widget
|
||||
scrollable.anchor_cursor_update = True
|
||||
scrollable.set_scrollpos(row_offset)
|
||||
self.page_list.set_focus(self.page_rows.index(int(target_idx)))
|
||||
self.page_list.set_focus_valign("top")
|
||||
|
||||
except Exception as e: RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
|
|
@ -373,18 +546,6 @@ class Browser:
|
|||
|
||||
return max(40, cols)
|
||||
|
||||
def _rows_above(self, index, cols):
|
||||
if index <= 0 or not self.attr_maps: return 0
|
||||
|
||||
hidden = self.hidden_indices()
|
||||
total = 0
|
||||
for i in range(min(index, len(self.attr_maps))):
|
||||
if i in hidden: continue
|
||||
try: total += self.attr_maps[i].rows((cols,))
|
||||
except Exception: total += 1
|
||||
|
||||
return total
|
||||
|
||||
def handle_lxmf_link(self, link_target):
|
||||
try:
|
||||
def san(name):
|
||||
|
|
@ -479,8 +640,10 @@ class Browser:
|
|||
self.browser_header = urwid.Text("")
|
||||
self.browser_footer = urwid.Text("")
|
||||
|
||||
self.page_pile = None
|
||||
self.page_list = None
|
||||
self.page_rows = []
|
||||
self.page_partials = {}
|
||||
self.page_images = {}
|
||||
self.browser_body = urwid.Filler(
|
||||
urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER),
|
||||
urwid.MIDDLE,
|
||||
|
|
@ -554,8 +717,10 @@ class Browser:
|
|||
def update_display(self):
|
||||
if self.status == Browser.DISCONECTED:
|
||||
self.display_widget.set_attr_map({None: "browser_inactive"})
|
||||
self.page_pile = None
|
||||
self.page_list = None
|
||||
self.page_rows = []
|
||||
self.page_partials = {}
|
||||
self.page_images = {}
|
||||
self.browser_body = urwid.Filler(
|
||||
urwid.Text("Disconnected\n"+self.g["arrow_l"]+" "+self.g["arrow_r"], align=urwid.CENTER),
|
||||
urwid.MIDDLE,
|
||||
|
|
@ -626,15 +791,319 @@ class Browser:
|
|||
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
|
||||
|
||||
def update_page_display(self):
|
||||
pile = urwid.Pile(self.attr_maps)
|
||||
pile.automove_cursor_on_scroll = True
|
||||
self.page_pile = pile
|
||||
self._purge_page_images()
|
||||
self.page_rows = list(range(len(self.attr_maps)))
|
||||
self.page_list = PageList(urwid.SimpleFocusListWalker(list(self.attr_maps)))
|
||||
self.page_partials = {}
|
||||
self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar")
|
||||
self.page_images = {}
|
||||
self.browser_body = urwid.AttrMap(ScrollBar(self.page_list, thumb_char="\u2503", trough_char=" "), "scrollbar")
|
||||
self.detect_partials()
|
||||
if self.should_load_images(): self.detect_images()
|
||||
self.init_folds()
|
||||
|
||||
# render an arbitrary markup buffer (used by the page editor preview) and
|
||||
def refresh_page_layout(self):
|
||||
if self.page_list is not None: self.page_list.invalidate_layout()
|
||||
|
||||
def image_cache_path(self, url):
|
||||
destination_hash, path = self.parse_url(url)
|
||||
url = f"{RNS.hexrep(destination_hash, delimit=False)}:{path}"
|
||||
url_hash = self.url_hash(url)
|
||||
if not url_hash: return None
|
||||
else:
|
||||
path = f"{self.app.imagecachepath}/{url_hash}"
|
||||
return path
|
||||
|
||||
def resolve_image(self, url):
|
||||
cachepath = self.image_cache_path(url)
|
||||
if not cachepath: return None
|
||||
else:
|
||||
if os.path.exists(cachepath): return cachepath
|
||||
else: return None
|
||||
|
||||
def _purge_page_images(self):
|
||||
if not self.image_rendering_supported: return
|
||||
try:
|
||||
screen = getattr(getattr(self.app.ui, "loop", None), "screen", None)
|
||||
if hasattr(screen, "purge_images"): screen.purge_images()
|
||||
except Exception: pass
|
||||
|
||||
def should_load_images(self):
|
||||
if self.image_mode == IMGLOAD_NEVER: return False
|
||||
elif self.image_mode == IMGLOAD_MANUAL: return False
|
||||
elif self.image_mode == IMGLOAD_ALWAYS: return True
|
||||
elif self.image_mode == IMGLOAD_AUTO:
|
||||
if self.destination_hash == self.loopback: return True
|
||||
rtt_limit = 1.5; edr_limit = 10000
|
||||
if self.link:
|
||||
rtt = self.link.rtt
|
||||
edr = self.link.get_expected_rate() or self.last_response_speed()
|
||||
rtt_ok = rtt and rtt < rtt_limit
|
||||
edr_ok = edr and edr > edr_limit
|
||||
RNS.log(f"Checking image load conditions, RTT is {RNS.prettyshorttime(rtt) if rtt else 'unknown'}, EDR is {RNS.prettyspeed(edr) if edr else 'unknown'}", RNS.LOG_DEBUG)
|
||||
if rtt_ok and edr_ok: return True
|
||||
else: return False
|
||||
|
||||
return False
|
||||
|
||||
def load_images(self, force_reload=False):
|
||||
if not self.image_rendering_supported: return
|
||||
elif self.image_mode == IMGLOAD_NEVER: return
|
||||
else: self.detect_images(force_reload=force_reload)
|
||||
|
||||
def detect_images(self, force_reload=False):
|
||||
if not self.image_rendering_supported: return
|
||||
for w in self.attr_maps:
|
||||
o = w._original_widget
|
||||
if hasattr(o, "_contained_image"): o = o._contained_image
|
||||
if hasattr(o, "image_url"):
|
||||
resolved_image = self.resolve_image(o.image_url)
|
||||
if resolved_image and force_reload:
|
||||
RNS.log(f"Clearing image cache for {o.image_url} ({resolved_image})", RNS.LOG_DEBUG)
|
||||
try: os.unlink(resolved_image)
|
||||
except Exception as e: RNS.log(f"Could not clear image cache for {o.image_url}: {e}", RNS.LOG_ERROR)
|
||||
resolved_image = None
|
||||
|
||||
if force_reload: continue
|
||||
elif resolved_image and self.image_rendering_supported: o.load(resolved_image, remote_source=True)
|
||||
else:
|
||||
image = { "widget": o, "id": o.image_id, "url": o.image_url, "refresh": None, "updated": None,
|
||||
"update_requested": None, "request_id": None, "link": None, "failed": False, "pr_throttle": 0,
|
||||
"progress_updated": None, "previous_progress": 0 }
|
||||
|
||||
self.page_images[o.image_id] = image
|
||||
|
||||
self.refresh_page_layout()
|
||||
if force_reload: self.reload()
|
||||
elif len(self.page_images) > 0: self.start_image_updater()
|
||||
|
||||
def start_image_updater(self):
|
||||
if not self.image_rendering_supported: return
|
||||
if not self.image_updater_running: self.update_images()
|
||||
|
||||
def update_images(self, loop=None, user_data=None):
|
||||
if not self.image_rendering_supported: return
|
||||
def job():
|
||||
if self.image_updater_lock.locked(): return
|
||||
with self.image_updater_lock:
|
||||
self.image_updater_running = True
|
||||
|
||||
for iid in self.page_images:
|
||||
try:
|
||||
image = self.page_images[iid]
|
||||
if image["failed"]: continue
|
||||
if not image["updated"] or (image["refresh"] != None and time.time() > image["updated"]+image["refresh"]):
|
||||
image["update_requested"] = time.time()
|
||||
self.__load_image(image)
|
||||
while not image["updated"] and not image["failed"] and len(self.page_images) > 0: time.sleep(0.2)
|
||||
|
||||
except Exception as e: RNS.log(f"Error updating page image: {e}", RNS.LOG_ERROR)
|
||||
|
||||
if len(self.page_images) > 0: self.app.ui.loop.set_alarm_in(1, self.update_images)
|
||||
else: self.image_updater_running = False
|
||||
|
||||
threading.Thread(target=job, daemon=True).start()
|
||||
|
||||
def __load_image(self, image):
|
||||
if image["failed"] == True: return
|
||||
w = image["widget"]
|
||||
url = image["url"]
|
||||
try: image_destination_hash, path = self.parse_url(image["url"])
|
||||
except Exception as e:
|
||||
RNS.log(f"Could not parse image URL: {e}", RNS.LOG_ERROR)
|
||||
image["failed"] = True
|
||||
w.notice(f"Could not load image {url}: {e}")
|
||||
return
|
||||
|
||||
if image_destination_hash == self.loopback:
|
||||
local_image = path.replace("/media/", "")
|
||||
local_path = f"{self.app.pagespath}/{local_image}"
|
||||
|
||||
if self.app.node:
|
||||
image_ext = os.path.splitext(local_path)[-1]
|
||||
if not image_ext in self.app.node.NATIVE_MEDIA_EXTS:
|
||||
RNS.log(f"Non-native media type for {local_path}, attempting auto-conversion", RNS.LOG_DEBUG)
|
||||
local_path = self.app.node.convert_media_to_webp(local_path)
|
||||
|
||||
if self.image_rendering_supported:
|
||||
w.load(local_path)
|
||||
self.refresh_page_layout()
|
||||
image["updated"] = time.time()
|
||||
return
|
||||
|
||||
if not RNS.Transport.has_path(image_destination_hash):
|
||||
if time.time() <= image["pr_throttle"]: return
|
||||
else:
|
||||
image["pr_throttle"] = time.time()+15
|
||||
RNS.log(f"Requesting path for image: {path}", RNS.LOG_DEBUG)
|
||||
RNS.Transport.request_path(image_destination_hash)
|
||||
pr_time = time.time()+self.path_timeout(image_destination_hash)
|
||||
while not RNS.Transport.has_path(image_destination_hash):
|
||||
if time.time() > pr_time: return
|
||||
time.sleep(0.25)
|
||||
|
||||
if not image["link"]:
|
||||
if self.link and self.link.destination.hash == image_destination_hash and self.link.status == RNS.Link.ACTIVE:
|
||||
image["link"] = self.link
|
||||
RNS.log(f"Re-using existing page link for image {url}", RNS.LOG_EXTREME)
|
||||
|
||||
if not image["link"]:
|
||||
for pid in self.page_images:
|
||||
other_image = self.page_images[pid]
|
||||
if other_image["link"]:
|
||||
existing_link = other_image["link"]
|
||||
if existing_link.destination.hash == image_destination_hash and existing_link.status == RNS.Link.ACTIVE:
|
||||
RNS.log(f"Re-using existing link for image {url}", RNS.LOG_EXTREME)
|
||||
image["link"] = existing_link
|
||||
break
|
||||
|
||||
if not image["link"] or (image["link"] and image["link"].status) == RNS.Link.CLOSED:
|
||||
RNS.log(f"Establishing link for image: {image_destination_hash} / {path}", RNS.LOG_DEBUG)
|
||||
identity = RNS.Identity.recall(image_destination_hash)
|
||||
destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self.app_name, self.aspects)
|
||||
w.notice(f"Establishing link...")
|
||||
|
||||
def f_established(wd):
|
||||
def established(link):
|
||||
RNS.log(f"Link established for image: {path}", RNS.LOG_DEBUG)
|
||||
wd.notice(f"Link established")
|
||||
return established
|
||||
|
||||
def f_closed(wd):
|
||||
def closed(link):
|
||||
RNS.log(f"Link closed for image: {path}", RNS.LOG_DEBUG)
|
||||
image["link"] = None
|
||||
image["failed"] = True
|
||||
wd.notice(f"Link closed during transfer")
|
||||
return closed
|
||||
|
||||
image["link"] = RNS.Link(destination, established_callback = f_established(w), closed_callback = f_closed(w))
|
||||
timeout = time.time()+max(self.timeout, image["link"].establishment_timeout)
|
||||
while image["link"].status != RNS.Link.ACTIVE and time.time() < timeout: time.sleep(0.1)
|
||||
|
||||
if image["link"] and image["link"].status == RNS.Link.ACTIVE and image["request_id"] == None:
|
||||
RNS.log(f"Sending request for image: {path}", RNS.LOG_DEBUG)
|
||||
receipt = image["link"].request("/media", data=self.__get_image_request_data(image), response_callback = self.image_received,
|
||||
failed_callback = self.image_failed, progress_callback = self.image_progressed)
|
||||
w.notice(f"Request sent")
|
||||
|
||||
if receipt: image["request_id"] = receipt.request_id
|
||||
else:
|
||||
RNS.log(f"Image request failed", RNS.LOG_ERROR)
|
||||
image["failed"] = True
|
||||
w.notice(f"Request failed")
|
||||
|
||||
def __get_image_request_data(self, image):
|
||||
destination_hash, path = self.parse_url(image["url"])
|
||||
request_data = {"path": path, "key": None}
|
||||
return request_data
|
||||
|
||||
def image_failed(self, request_receipt):
|
||||
RNS.log("Loading page image failed", RNS.LOG_ERROR)
|
||||
for iid in self.page_images:
|
||||
image = self.page_images[iid]
|
||||
if image["request_id"] == request_receipt.request_id:
|
||||
try:
|
||||
image["updated"] = time.time()
|
||||
image["request_id"] = None
|
||||
image["failed"] = True
|
||||
url = image["url"]
|
||||
w = image["widget"]
|
||||
w.notice(f"Could not load image {url}")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error in image failed callback: {e}", RNS.LOG_ERROR)
|
||||
RNS.trace_exception(e)
|
||||
|
||||
def image_progressed(self, request_receipt):
|
||||
for iid in self.page_images:
|
||||
i = self.page_images[iid]
|
||||
if i["request_id"] == request_receipt.request_id:
|
||||
try:
|
||||
response_progress = request_receipt.progress
|
||||
response_time = request_receipt.get_response_time()
|
||||
response_size = request_receipt.response_size
|
||||
response_transfer_size = request_receipt.response_transfer_size
|
||||
|
||||
w = i["widget"]
|
||||
now = time.time()
|
||||
if i["progress_updated"] == None: i["progress_updated"] = now
|
||||
if now > i["progress_updated"]+0.5:
|
||||
td = now - i["progress_updated"]
|
||||
pd = response_progress - i["previous_progress"]
|
||||
bd = pd*response_transfer_size
|
||||
response_speed = (bd/td)*8
|
||||
i["previous_progress"] = response_progress
|
||||
i["progress_updated"] = now
|
||||
stats = f"{round(response_progress*100, 2)}% ({RNS.prettysize(response_progress*response_size)} of {RNS.prettysize(response_size)}) - {RNS.prettyspeed(response_speed)}"
|
||||
w.notice(stats)
|
||||
|
||||
except Exception as e: RNS.trace_exception(e)
|
||||
|
||||
def image_received(self, request_receipt):
|
||||
if not request_receipt.response: self.image_failed(request_receipt)
|
||||
for iid in self.page_images:
|
||||
image = self.page_images[iid]
|
||||
if image["request_id"] == request_receipt.request_id:
|
||||
try:
|
||||
image["updated"] = image["update_requested"]
|
||||
image["request_id"] = None
|
||||
w = image["widget"]
|
||||
w.notice(f"Image loaded")
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Error while loading received image: {e}", RNS.LOG_ERROR)
|
||||
RNS.trace_exception(e)
|
||||
image["failed"] = True
|
||||
|
||||
try:
|
||||
url = image["url"]
|
||||
cachepath = self.image_cache_path(url)
|
||||
if isinstance(request_receipt.response, bytes):
|
||||
with open(cachepath, "wb") as f: f.write(request_receipt.response)
|
||||
|
||||
elif type(request_receipt.response) == io.BufferedReader:
|
||||
if request_receipt.metadata != None:
|
||||
file_handle = request_receipt.response
|
||||
file_destination = cachepath
|
||||
shutil.move(file_handle.name, file_destination)
|
||||
threading.Thread(target=self.clean_image_cache, daemon=True).start()
|
||||
|
||||
resolved_path = self.resolve_image(url)
|
||||
if resolved_path and self.image_rendering_supported:
|
||||
w.load(resolved_path, remote_source=True)
|
||||
self.refresh_page_layout()
|
||||
|
||||
except Exception as e:
|
||||
RNS.log("Error while handling image response: "+str(e), RNS.LOG_ERROR)
|
||||
image["failed"] = True
|
||||
|
||||
IMAGE_CACHE_MAX_FILES = 256
|
||||
IMAGE_CACHE_MAX_BYTES = 192*1024*1024
|
||||
def clean_image_cache(self):
|
||||
if self.image_cache_lock.locked(): return
|
||||
with self.image_cache_lock:
|
||||
try:
|
||||
entries = []
|
||||
for name in os.listdir(self.app.imagecachepath):
|
||||
path = os.path.join(self.app.imagecachepath, name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
if st.st_size > 0: entries.append((st.st_mtime, st.st_size, path))
|
||||
except OSError as exc: RNS.log(f"Could not get cached file information for {path}: {e}", RNS.LOG_DEBUG)
|
||||
|
||||
cleaned_b = 0; cleaned_c = 0
|
||||
total = sum(e[1] for e in entries)
|
||||
entries.sort()
|
||||
while len(entries) > self.IMAGE_CACHE_MAX_FILES or total > self.IMAGE_CACHE_MAX_BYTES:
|
||||
_, size, path = entries.pop(0)
|
||||
total -= size
|
||||
cleaned_b += size
|
||||
cleaned_c += 1
|
||||
try: os.unlink(path)
|
||||
except OSError as exc: RNS.log(f"Could not remove cached file {path}: {exc}")
|
||||
if cleaned_b or cleaned_c: RNS.log(f"Cleaned {RNS.prettysize(cleaned_b)} / {cleaned_c} image{'s' if cleaned_c != 1 else ''} from cache", RNS.LOG_DEBUG)
|
||||
except Exception as exc: RNS.log(f"Error while cleaning browser image cache: {exc}", RNS.LOG_ERROR)
|
||||
|
||||
# Render an arbitrary markup buffer (used by the page editor preview) and
|
||||
# return the content widget, with partials/folds/fields wired as usual
|
||||
def render_markup_buffer(self, markup):
|
||||
self.status = Browser.DONE
|
||||
|
|
@ -699,15 +1168,14 @@ class Browser:
|
|||
return hidden
|
||||
|
||||
def rebuild_visible(self):
|
||||
pile = getattr(self, "page_pile", None)
|
||||
if pile is None: return
|
||||
page = getattr(self, "page_list", None)
|
||||
if page is None: return
|
||||
hidden = self.hidden_indices()
|
||||
opts = pile.options()
|
||||
pile.contents = [(w, opts) for i, w in enumerate(self.attr_maps) if i not in hidden]
|
||||
try:
|
||||
if pile.focus_position >= len(pile.contents):
|
||||
pile.focus_position = max(0, len(pile.contents)-1)
|
||||
except Exception: pass
|
||||
self.page_rows = [i for i in range(len(self.attr_maps)) if i not in hidden]
|
||||
focus = page.focus_position if page.body else 0
|
||||
page.body[:] = [self.attr_maps[i] for i in self.page_rows]
|
||||
page.invalidate_layout()
|
||||
if page.body: page.set_focus(min(focus, len(page.body)-1))
|
||||
|
||||
def fold_changed(self, heading_widget):
|
||||
if getattr(heading_widget, "collapsed", False):
|
||||
|
|
@ -716,9 +1184,9 @@ class Browser:
|
|||
self.collapsed_headings.discard(heading_widget)
|
||||
self.rebuild_visible()
|
||||
try:
|
||||
for pos, (w, _) in enumerate(self.page_pile.contents):
|
||||
for pos, w in enumerate(self.page_list.body):
|
||||
if getattr(w, "_original_widget", None) is heading_widget:
|
||||
self.page_pile.focus_position = pos
|
||||
self.page_list.set_focus(pos)
|
||||
break
|
||||
except Exception: pass
|
||||
|
||||
|
|
@ -787,6 +1255,7 @@ class Browser:
|
|||
url = partial["url"]
|
||||
pile = partial["pile"]
|
||||
pile.contents = [(urwid.Text(f"Could not load partial {url}: The resource transfer failed"), pile.options())]
|
||||
self.refresh_page_layout()
|
||||
except Exception as e:
|
||||
RNS.log(f"Error in partial failed callback: {e}", RNS.LOG_ERROR)
|
||||
RNS.trace_exception(e)
|
||||
|
|
@ -805,6 +1274,7 @@ class Browser:
|
|||
partial["attr_maps"] = markup_to_attrmaps(strip_modifiers(partial["content"]), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color)
|
||||
pile = partial["pile"]
|
||||
pile.contents = [(e, pile.options()) for e in partial["attr_maps"]]
|
||||
self.refresh_page_layout()
|
||||
|
||||
except Exception as e:
|
||||
RNS.trace_exception(e)
|
||||
|
|
@ -818,6 +1288,7 @@ class Browser:
|
|||
pile = partial["pile"]
|
||||
url = partial["url"]
|
||||
pile.contents = [(urwid.Text(f"Could not load partial {url}: {e}"), pile.options())]
|
||||
self.refresh_page_layout()
|
||||
return
|
||||
|
||||
if partial_destination_hash != self.loopback and not RNS.Transport.has_path(partial_destination_hash):
|
||||
|
|
@ -917,7 +1388,7 @@ class Browser:
|
|||
pass # do nothing if checkbox is not check
|
||||
|
||||
recurse_down(self.attr_maps)
|
||||
RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG)
|
||||
RNS.log("Including request data: "+str(request_data), RNS.LOG_EXTREME)
|
||||
|
||||
return request_data
|
||||
|
||||
|
|
@ -1805,10 +2276,8 @@ class Browser:
|
|||
|
||||
self.update_display()
|
||||
if self.link != None:
|
||||
try:
|
||||
self.link.teardown()
|
||||
except Exception as e:
|
||||
pass
|
||||
try: self.link.teardown()
|
||||
except Exception as e: pass
|
||||
else:
|
||||
self.status = Browser.REQUEST_FAILED
|
||||
self.response_progress = 0
|
||||
|
|
@ -1861,6 +2330,10 @@ class Browser:
|
|||
|
||||
self.update_display()
|
||||
|
||||
def last_response_speed(self):
|
||||
if self.response_transfer_size and self.response_time:
|
||||
return (self.response_transfer_size/max(self.response_time, 0.0001))*8
|
||||
else: return None
|
||||
|
||||
def status_text(self):
|
||||
if self.status == Browser.DONE and self.response_transfer_size != None:
|
||||
|
|
|
|||
|
|
@ -239,6 +239,14 @@ CONFIG_SECTIONS = [
|
|||
"default": "24bit",
|
||||
"help": "Colour capability of your terminal",
|
||||
},
|
||||
{
|
||||
"config_key": "image_loading",
|
||||
"label": "Image Loading: ",
|
||||
"type": "dropdown",
|
||||
"options": ["never", "manual", "auto", "always"],
|
||||
"default": "auto",
|
||||
"help": "How, or if at all, to load page images",
|
||||
},
|
||||
{
|
||||
"config_key": "glyphs",
|
||||
"label": "Glyphs: ",
|
||||
|
|
|
|||
|
|
@ -445,6 +445,8 @@ class PageEditorDisplay():
|
|||
self.register_styles()
|
||||
|
||||
self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None)
|
||||
self.preview_browser.loopback = self.app.node.destination.hash
|
||||
self.preview_browser.destination_hash = self.app.node.destination.hash
|
||||
self.preview_browser.handle_link = self._preview_handle_link
|
||||
self.preview_browser.marked_link = lambda *a, **k: None
|
||||
self.preview_browser._content_cols = self._preview_cols
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ class GuideColumns(urwid.Columns):
|
|||
class GuideLinkDelegate:
|
||||
def __init__(self, app, reader=None):
|
||||
self.app = app
|
||||
self.g = app.ui.glyphs
|
||||
self.reader = reader
|
||||
self.last_keypress = 0
|
||||
|
||||
|
|
@ -100,6 +101,16 @@ class GuideLinkDelegate:
|
|||
def micron_released_focus(self):
|
||||
self.reader.focus_topics()
|
||||
|
||||
def resolve_image(self, url):
|
||||
if url.endswith("demo.webp"):
|
||||
import os
|
||||
import nomadnet.ui.textui.images as images_module
|
||||
path = f"{os.path.dirname(images_module.__file__)}/demo.webp"
|
||||
RNS.log(path)
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def handle_link(self, target, fields=None):
|
||||
if not target:
|
||||
return
|
||||
|
|
@ -172,7 +183,7 @@ class TopicList(urwid.WidgetWrap):
|
|||
GuideEntry(self.app, self, guide_display, "Hosting a Node"),
|
||||
GuideEntry(self.app, self, guide_display, "Configuration Options"),
|
||||
GuideEntry(self.app, self, guide_display, "Keyboard Shortcuts"),
|
||||
GuideEntry(self.app, self, guide_display, "Markup"),
|
||||
GuideEntry(self.app, self, guide_display, "Micron Markup"),
|
||||
self.first_run_entry,
|
||||
GuideEntry(self.app, self, guide_display, "Network Configuration"),
|
||||
GuideEntry(self.app, self, guide_display, "Display Test"),
|
||||
|
|
@ -1171,6 +1182,17 @@ Tells the program what color palette is supported by the terminal. Most terminal
|
|||
`!24bit`! Most new terminals support this full-color mode
|
||||
<
|
||||
|
||||
>>>
|
||||
`!image_loading = auto`!
|
||||
>>>>
|
||||
Specifies how, if at all, images on pages should be loaded. Available options:
|
||||
>>>>>
|
||||
`!never`! Never load any images
|
||||
`!manual`! Only load images when manually requseted with Ctrl-L
|
||||
`!auto`! Load images automatically if link conditions are sufficient
|
||||
`!always`! Always load images, regardless of link RTT and EDR
|
||||
<
|
||||
|
||||
>>>
|
||||
`!theme = dark`!
|
||||
>>>>
|
||||
|
|
@ -1381,6 +1403,15 @@ Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0
|
|||
|
||||
`cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions.
|
||||
``
|
||||
|
||||
>>Image Rendering Test
|
||||
|
||||
`(The RNS logo`w=n`a=c`:/media/demo.webp)
|
||||
|
||||
If you see the RNS logo above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others.
|
||||
|
||||
It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either.
|
||||
|
||||
'''
|
||||
|
||||
|
||||
|
|
@ -1450,6 +1481,7 @@ With micron you can easily create structured documents and pages with formatting
|
|||
`F79d`_`[Links`#links]`_`f
|
||||
`F79d`_`[Anchors`#anchors]`_`f
|
||||
`F79d`_`[Tables`#tables]`_`f
|
||||
`F79d`_`[Images`#images]`_`f
|
||||
`F79d`_`[Fields & Requests`#fields-requests]`_`f
|
||||
`F79d`_`[Comments`#comments]`_`f
|
||||
`F79d`_`[Partials`#partials]`_`f
|
||||
|
|
@ -1776,6 +1808,32 @@ The above markup produces the following table:
|
|||
| Orange | Ask, nicely | 3 |
|
||||
`t
|
||||
|
||||
>Images
|
||||
|
||||
You can include images in pages by using the image tag. All images must specify at least an alt-text and a URL, and can optionally specify rendering options such as width, height and alignment. The alt-text is required so a meaningful placeholder can be displayed for clients that do not support image rendering.
|
||||
|
||||
Width (`Faaaw=`f) and height (`Faaah=`f) can be specified either in terminal columns/rows, in percentages, or using the `Faaan`f specifier, which will render the image as close to its native resolution as possible
|
||||
|
||||
Alignment (`Faaaa=`f) can be either `Faaaleft`f (`Faaal`f), `Faaaright`f (`Faaar`f) or `Faaacenter`f (`Faaac`f).
|
||||
|
||||
`Faaa
|
||||
`=
|
||||
`(The RNS logo`w=n`a=c`:/media/demo.webp)
|
||||
`=
|
||||
``
|
||||
|
||||
Images are block-level elements, and must be placed independently on their own line.
|
||||
|
||||
If your terminal supports image rendering, the image will be displayed as specified. If not, a text placeholder is shown instead:
|
||||
|
||||
`(The RNS logo`w=n`a=c`:/media/demo.webp)
|
||||
|
||||
If you see the image above, your terminal supports image rendering! If not, you can use a terminal that supports the Kitty Terminal Graphics Protocol, for example Kitty, Konsole, Wezterm, iTerm2 and others.
|
||||
|
||||
It is also important to note, that for image rendering to work, your operating system will need `*some sort`* of mechanism to actually decode the WebP format. Nomadnet will attempt to detect a suitable decoding backend on your system, and should be able to do so on 99% of variants across Linux, BSD, macOS and Windows, as long as `Faaalibwebp`f or one of the fallback decoders are available. If all else fails, you can install PIL (`Faaapip install pillow`f) or download the open-source `Faaadwebp`f decoder utility and place it on your path (for example at `Faaa~/.local/bin/dwebp`f); nomadnet will auto-detect either.
|
||||
|
||||
`!Important!`! For the sake of bandwidth efficiency, images sent over the network `!must`! be in WebP format. Nomadnet will reject and ignore all other image formats. You can easily convert images to WebP with command-line utilities such as `Faaaconvert`f and similar. The WebP format supports both efficient compression and lossless alpha masks, so it is suitable for anything you can achieve with PNG, JPEG or other formats, but much lighter on bandwidth.
|
||||
|
||||
>Fields & Requests
|
||||
|
||||
Nomad Network lets you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables.
|
||||
|
|
@ -1990,7 +2048,7 @@ TOPICS = {
|
|||
"Hosting a Node": TOPIC_HOSTING,
|
||||
"Configuration Options": TOPIC_CONFIG,
|
||||
"Keyboard Shortcuts": TOPIC_SHORTCUTS,
|
||||
"Markup": TOPIC_MARKUP,
|
||||
"Micron Markup": TOPIC_MARKUP,
|
||||
"Display Test": TOPIC_DISPLAYTEST,
|
||||
"Network Configuration": TOPIC_NETWORKS,
|
||||
"Credits & Licenses": TOPIC_LICENSES,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class LogTerminal(urwid.WidgetWrap):
|
|||
|
||||
def keypress(self, size, key):
|
||||
if key == "ctrl q":
|
||||
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.show_quit_dialog()
|
||||
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.quit()
|
||||
return None
|
||||
|
||||
if key == "up":
|
||||
|
|
|
|||
|
|
@ -194,11 +194,11 @@ class MainDisplay():
|
|||
self.menu_display.start()
|
||||
|
||||
def quit(self, sender=None):
|
||||
self.show_quit_dialog()
|
||||
if self.app.peer_settings["quit_confirm"]: self.show_quit_dialog()
|
||||
else: self.do_quit()
|
||||
|
||||
def show_quit_dialog(self, sender=None):
|
||||
if getattr(self, "_quit_dialog_open", False):
|
||||
return
|
||||
if getattr(self, "_quit_dialog_open", False): return
|
||||
|
||||
active = self.sub_displays.active_display
|
||||
if active is self.sub_displays.log_display:
|
||||
|
|
@ -208,7 +208,14 @@ class MainDisplay():
|
|||
self.do_quit()
|
||||
return
|
||||
|
||||
dont_ask_again = urwid.CheckBox("Don't ask again")
|
||||
|
||||
def confirm(button=None):
|
||||
if dont_ask_again.get_state():
|
||||
try:
|
||||
self.app.peer_settings["quit_confirm"] = False
|
||||
self.app.save_peer_settings()
|
||||
except Exception: pass
|
||||
self.do_quit()
|
||||
|
||||
def cancel(button=None):
|
||||
|
|
@ -220,19 +227,15 @@ class MainDisplay():
|
|||
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
||||
(urwid.WEIGHT, 0.45, urwid.Button("No", on_press=cancel)),
|
||||
])
|
||||
try:
|
||||
|
||||
|
||||
buttons.focus_position = 2 # default to "No"
|
||||
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
try: buttons.focus_position = 2 # default to "No"
|
||||
except Exception: pass
|
||||
|
||||
pile = urwid.Pile([
|
||||
urwid.Text("Are you sure you want to quit?", align="center"),
|
||||
urwid.Divider(),
|
||||
buttons,
|
||||
urwid.Divider(),
|
||||
dont_ask_again,
|
||||
])
|
||||
|
||||
dialog = QuitDialogLineBox(
|
||||
|
|
@ -246,7 +249,7 @@ class MainDisplay():
|
|||
self.app.ui.loop.widget = urwid.Overlay(
|
||||
dialog, self.frame,
|
||||
align="center", width=44,
|
||||
valign="middle", height=8, min_width=20,
|
||||
valign="middle", height=7, min_width=20,
|
||||
)
|
||||
|
||||
def show_notice(self, text):
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from urwid.text_layout import calc_coords
|
|||
from .ReadlineEdit import ReadlineEdit
|
||||
from RNS.Utilities.rngit.util import MarkdownToMicron
|
||||
from nomadnet.util import STRIP_CONTROL_RE
|
||||
try: from .images import ImageWidget
|
||||
except Exception: ImageWidget = None
|
||||
|
||||
DEFAULT_FG_DARK = "ddd"
|
||||
DEFAULT_FG_LIGHT = "222"
|
||||
|
|
@ -216,6 +218,76 @@ def parse_partial(line):
|
|||
|
||||
except Exception as e: return None
|
||||
|
||||
def parse_image(line, state, url_delegate):
|
||||
try:
|
||||
endpos = line.rfind(")")
|
||||
if endpos <= 0: return None
|
||||
image_data = line[:endpos]
|
||||
fields = image_data.split("`")
|
||||
if len(fields) < 2: return None
|
||||
|
||||
alt_text = fields[0].strip()
|
||||
image_url = fields[-1].strip()
|
||||
properties = fields[1:-1]
|
||||
|
||||
width = None
|
||||
height = None
|
||||
align = None
|
||||
for prop in properties:
|
||||
if "=" not in prop: continue
|
||||
key, _, value = prop.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key == "w": width = value
|
||||
elif key == "h": height = value
|
||||
elif key == "a": align = value
|
||||
|
||||
if align == "c": align = "center"
|
||||
if align == "l": align = "left"
|
||||
if align == "r": align = "right"
|
||||
|
||||
widget = None
|
||||
if image_url:
|
||||
try: path = url_delegate.resolve_image(image_url)
|
||||
except Exception as e:
|
||||
RNS.trace_exception(e) # TODO: Remove
|
||||
path = None
|
||||
|
||||
if ImageWidget is not None:
|
||||
try:
|
||||
if not ImageWidget.placeholder_glyph: ImageWidget.placeholder_glyph = f"{url_delegate.g['image']} "
|
||||
|
||||
widget = ImageWidget(path, name=alt_text)
|
||||
for prop_name, prop_value in ( ("width", width), ("height", height), ("align", align) ):
|
||||
if prop_value is not None:
|
||||
try:
|
||||
if isinstance(prop_value, str) and prop_value.isdigit(): prop_value = int(prop_value)
|
||||
setattr(widget, prop_name, prop_value)
|
||||
except Exception as e:
|
||||
RNS.trace_exception(e) # TODO: Remove
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
RNS.trace_exception(e) # TODO: Remove
|
||||
widget = None
|
||||
|
||||
if widget is None:
|
||||
placeholder = alt_text if alt_text else (image_url or "image")
|
||||
widget = urwid.Text(f"[{url_delegate.g['warning']} Error loading image: {placeholder}]")
|
||||
|
||||
widget.image_url = image_url
|
||||
widget.image_alt = alt_text
|
||||
if state["depth"] == 0: return [widget]
|
||||
else:
|
||||
padded = urwid.Padding(widget, left=left_indent(state), right=right_indent(state))
|
||||
padded._contained_image = widget
|
||||
return [padded]
|
||||
|
||||
except Exception as e:
|
||||
# TODO: Remove
|
||||
RNS.trace_exception(e)
|
||||
return None
|
||||
|
||||
def render_table(lines, state, url_delegate):
|
||||
if len(lines) < 2: return None
|
||||
if state["table_maxwidth"]: max_width = state["table_maxwidth"]
|
||||
|
|
@ -240,9 +312,7 @@ def render_table(lines, state, url_delegate):
|
|||
return widgets if widgets else None
|
||||
|
||||
class FormColumns(urwid.Columns):
|
||||
|
||||
MIN_FIELD_WIDTH = 4
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _natural_width(widget, maxcol, focused):
|
||||
|
|
@ -286,14 +356,8 @@ class FormColumns(urwid.Columns):
|
|||
return super().column_widths(size, focus)
|
||||
|
||||
if available < self.MIN_FIELD_WIDTH * len(field_idxs):
|
||||
|
||||
return super().column_widths(size, focus)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
overflow = requested - available
|
||||
slack = {i: max(0, nat[i] - self.MIN_FIELD_WIDTH) for i in field_idxs}
|
||||
total_slack = sum(slack.values())
|
||||
|
|
@ -482,6 +546,10 @@ def parse_line(line, state, url_delegate):
|
|||
elif line.startswith("`{"):
|
||||
return parse_partial(line[2:])
|
||||
|
||||
# Check for images
|
||||
elif line.startswith("`("):
|
||||
return parse_image(line[2:], state, url_delegate)
|
||||
|
||||
# Check for section heading reset
|
||||
elif first_char == "<":
|
||||
state["depth"] = 0
|
||||
|
|
@ -535,14 +603,14 @@ def parse_line(line, state, url_delegate):
|
|||
if len(line) == 2:
|
||||
divider_char = line[1]
|
||||
# Control characters don't make sense here and otherwise crash nomadnet
|
||||
if ord(divider_char) < 32:
|
||||
divider_char = "\u2500"
|
||||
else:
|
||||
divider_char = "\u2500"
|
||||
if state["depth"] == 0:
|
||||
return [urwid.Divider(divider_char)]
|
||||
else:
|
||||
return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))]
|
||||
if ord(divider_char) < 32: divider_char = "\u2500"
|
||||
# Check if the divider char is renderable
|
||||
try: urwid.Divider(divider_char).render((30,))
|
||||
except Exception as e: divider_char = "\u2500"
|
||||
else: divider_char = "\u2500"
|
||||
|
||||
if state["depth"] == 0: return [urwid.Divider(divider_char)]
|
||||
else: return [urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))]
|
||||
|
||||
output = make_output(state, line, url_delegate, pre_escape)
|
||||
|
||||
|
|
@ -1091,6 +1159,8 @@ class LinkableText(urwid.Text):
|
|||
super().__init__(text, align=align)
|
||||
self.delegate = delegate
|
||||
self._cursor_position = 0
|
||||
self._cursor_promised = None
|
||||
self._cursor_promised_at = 0
|
||||
self.key_timeout = 2
|
||||
self.in_columns = False
|
||||
if self.delegate != None:
|
||||
|
|
@ -1197,28 +1267,35 @@ class LinkableText(urwid.Text):
|
|||
def kt_event(self, loop, user_data):
|
||||
self._invalidate()
|
||||
|
||||
def cursor_visible(self):
|
||||
return self.delegate == None or time.time() < self.delegate.last_keypress+self.key_timeout
|
||||
|
||||
def render(self, size, focus=False):
|
||||
now = time.time()
|
||||
c = super().render(size, focus)
|
||||
|
||||
if focus and (self.delegate == None or now < self.delegate.last_keypress+self.key_timeout):
|
||||
c = urwid.CompositeCanvas(c)
|
||||
c.cursor = self.get_cursor_coords(size)
|
||||
if self.delegate != None:
|
||||
self.peek_link()
|
||||
|
||||
if focus:
|
||||
if time.time() < self._cursor_promised_at+0.5:
|
||||
coords = self._cursor_promised
|
||||
else:
|
||||
coords = self.get_cursor_coords(size)
|
||||
if coords != None:
|
||||
c = urwid.CompositeCanvas(c)
|
||||
c.cursor = coords
|
||||
if self.delegate != None:
|
||||
self.peek_link()
|
||||
return c
|
||||
|
||||
def get_cursor_coords(self, size):
|
||||
if self._cursor_position > len(self.text):
|
||||
return None
|
||||
|
||||
(maxcol,) = size
|
||||
trans = self.get_line_translation(maxcol)
|
||||
x, y = calc_coords(self.text, trans, self._cursor_position)
|
||||
if maxcol <= x:
|
||||
return None
|
||||
return x, y
|
||||
self._cursor_promised_at = time.time()
|
||||
self._cursor_promised = None
|
||||
if not self.cursor_visible() or self._cursor_position > len(self.text):
|
||||
return None
|
||||
(maxcol,) = size
|
||||
trans = self.get_line_translation(maxcol)
|
||||
x, y = calc_coords(self.text, trans, self._cursor_position)
|
||||
if maxcol <= x:
|
||||
return None
|
||||
self._cursor_promised = (x, y)
|
||||
return x, y
|
||||
|
||||
def mouse_event(self, size, event, button, x, y, focus):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -18,10 +18,19 @@ class NetworkDisplayShortcuts():
|
|||
self.app = app
|
||||
g = app.ui.glyphs
|
||||
|
||||
shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL "
|
||||
if app.config["textui"]["clipboard_copy"]:
|
||||
shortcut_text += "[C-y] Copy "
|
||||
shortcut_text += "[C-g] Fullscreen [C-s / C-b] Save Node"
|
||||
shortcut_text = "[C-l] Nodes/Announces [C-x] Remove [C-g] Fullscreen"
|
||||
self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar")
|
||||
|
||||
class BrowserShortcuts():
|
||||
def __init__(self, app, browser):
|
||||
self.app = app
|
||||
self.browser = browser
|
||||
g = app.ui.glyphs
|
||||
|
||||
shortcut_text = "[C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL "
|
||||
if app.config["textui"]["clipboard_copy"]: shortcut_text += "[C-y] Copy "
|
||||
shortcut_text += "[C-g] Fullscreen [C-s/b] Save Node"
|
||||
if browser.image_rendering_supported: shortcut_text += " [C-l] Load Images [C-x] Reload Images"
|
||||
self.widget = urwid.AttrMap(urwid.Text(shortcut_text), "shortcutbar")
|
||||
|
||||
class DialogLineBox(urwid.LineBox):
|
||||
|
|
@ -1722,7 +1731,9 @@ class NetworkDisplay():
|
|||
dividechars=0, focus_column=0
|
||||
)
|
||||
|
||||
self.shortcuts_display = NetworkDisplayShortcuts(self.app)
|
||||
self.list_shortcuts = NetworkDisplayShortcuts(self.app)
|
||||
self.browser_shortcuts = BrowserShortcuts(self.app, self.browser)
|
||||
self.shortcuts_display = self.list_shortcuts
|
||||
self.widget = self.columns
|
||||
|
||||
def toggle_list(self):
|
||||
|
|
@ -1799,6 +1810,12 @@ class NetworkDisplay():
|
|||
self.network_stats_display.start()
|
||||
|
||||
def shortcuts(self):
|
||||
try: focus_path = self.columns.get_focus_path()
|
||||
except Exception: return self.list_shortcuts
|
||||
try:
|
||||
if focus_path[0] == 0: return self.list_shortcuts
|
||||
if focus_path[0] == 1: return self.browser_shortcuts
|
||||
except: pass
|
||||
return self.shortcuts_display
|
||||
|
||||
def directory_change_callback(self):
|
||||
|
|
|
|||
18
nomadnet/ui/textui/images/__init__.py
Normal file
18
nomadnet/ui/textui/images/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# A minimal, dependency-free image widget for urwid, rendering images through
|
||||
# the Kitty terminal graphics protocol.
|
||||
|
||||
from ._termlib import get_cell_size, is_kitty_supported, reset_caches, diagnostics
|
||||
from ._imagedata import ImageData, parse_image_header
|
||||
from .widget import ImageWidget, ImageCanvas, UI_MARGIN
|
||||
from .screen import ImageScreen
|
||||
|
||||
__all__ = ( "ImageWidget",
|
||||
"ImageCanvas",
|
||||
"ImageScreen",
|
||||
"ImageData",
|
||||
"parse_image_header",
|
||||
"is_kitty_supported",
|
||||
"get_cell_size",
|
||||
"diagnostics",
|
||||
"reset_caches",
|
||||
"UI_MARGIN" )
|
||||
78
nomadnet/ui/textui/images/_ctlseqs.py
Normal file
78
nomadnet/ui/textui/images/_ctlseqs.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Kitty Terminal Graphics Protocol control sequences
|
||||
#
|
||||
# Minimal set of control sequences needed for the Kitty graphics protocol
|
||||
# side-channel image rendering used by the urwid image widget.
|
||||
|
||||
from base64 import standard_b64encode
|
||||
import re
|
||||
|
||||
# Basic building blocks
|
||||
APC = b"\x1b_" # Application Program Command
|
||||
ST = b"\x1b\\" # String Terminator
|
||||
CSI = b"\x1b["
|
||||
|
||||
# Kitty graphics: transmit-and-display with chunking support.
|
||||
# ESC _ G <control-data> ; <base64 payload> ESC \
|
||||
KITTY_TRANSMISSION = b"\x1b_G%s;%s\x1b\\"
|
||||
|
||||
# Delete images: d=A (all placements visible on screen), d=i (one
|
||||
# placement, data kept), d=I (whole image, data freed).
|
||||
KITTY_DELETE_ALL = b"\x1b_Ga=d,d=A\x1b\\"
|
||||
KITTY_DELETE_PLACEMENT = b"\x1b_Ga=d,d=i,i=%d,p=%d\x1b\\"
|
||||
KITTY_DELETE_IMAGE = b"\x1b_Ga=d,d=I,i=%d\x1b\\"
|
||||
|
||||
# Support query (payload is base64 of 4 zero bytes; expects "i=31;OK")
|
||||
KITTY_QUERY = b"\x1b_Ga=q,t=d,i=31,f=24,s=1,v=1,C=1,c=1,r=1;AAAA\x1b\\"
|
||||
KITTY_QUERY_RESPONSE_RE = re.compile(b"\x1b_Gi=31;(?P<message>[^\x1b]*)\x1b\\\\")
|
||||
|
||||
# Cursor: absolute positioning, erase characters, cursor forward
|
||||
CURSOR_POSITION = b"\x1b[%d;%dH"
|
||||
ERASE_CHARS = b"\x1b[%dX"
|
||||
|
||||
# Synchronized output (DEC 2026) - reduces flicker/tearing when redrawing
|
||||
BEGIN_SYNCED_UPDATE = b"\x1b[?2026h"
|
||||
END_SYNCED_UPDATE = b"\x1b[?2026l"
|
||||
|
||||
# Terminal queries: cell size in pixels (XTWINOPS 16) + primary DA
|
||||
CELL_SIZE_PX = b"\x1b[16t"
|
||||
DA1 = b"\x1b[c"
|
||||
# Response: CSI 4 ; <height-px> ; <width-px> t
|
||||
CELL_SIZE_RESPONSE_RE = re.compile(b"\x1b\\[4;(?P<h>\\d+);(?P<w>\\d+)t")
|
||||
|
||||
# Maximum payload (base64) per transmission chunk, as recommended by the
|
||||
# protocol documentation
|
||||
CHUNK_SIZE = 4096
|
||||
|
||||
# Control data for a transmit-only (a=t) with a quiet mode (q=1)
|
||||
def kitty_transmit_control(image_id): return b"a=t,i=%d,f=100,t=d,q=1" % image_id
|
||||
|
||||
# A placement command (a=p) for a previously transmitted image.
|
||||
# Re-sending the same (image id, placement id) pair replaces the
|
||||
# placement in place - used to move/resize/slice without flicker.
|
||||
# y/h select the source rectangle (image pixels) when a slice of
|
||||
# the image is displayed.
|
||||
def kitty_placement(image_id, placement_id, cols, rows, z, y=None, h=None):
|
||||
control = b"a=p,i=%d,p=%d,c=%d,r=%d,z=%d,C=1,q=1" % (image_id, placement_id, cols, rows, z)
|
||||
if y is not None and h is not None: control += b",y=%d,h=%d" % (y, h)
|
||||
return KITTY_TRANSMISSION % (control, b"")
|
||||
|
||||
# Encodes a kitty graphics transmission, chunking the base64-encoded
|
||||
# payload into ``chunk_size``-byte pieces.
|
||||
def kitty_transmission(control, payload, chunk_size=CHUNK_SIZE):
|
||||
encoded = standard_b64encode(payload)
|
||||
chunks = [encoded[i:i + chunk_size] for i in range(0, len(encoded), chunk_size)]
|
||||
if not chunks:
|
||||
chunks = [b""]
|
||||
|
||||
def control_for(index, n_chunks):
|
||||
if n_chunks == 1:
|
||||
return control
|
||||
if index == 0:
|
||||
# The control data appears only in the first chunk; the chunk
|
||||
# marker says more chunks are coming.
|
||||
return control + b",m=1"
|
||||
if index == n_chunks - 1:
|
||||
return b"m=0"
|
||||
return b"m=1"
|
||||
|
||||
return b"".join(KITTY_TRANSMISSION % (control_for(i, len(chunks)), chunk) for i, chunk in enumerate(chunks))
|
||||
142
nomadnet/ui/textui/images/_imagedata.py
Normal file
142
nomadnet/ui/textui/images/_imagedata.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Image file handling for the side-channel renderer
|
||||
#
|
||||
# The Kitty graphics protocol can decode and scale images in the terminal
|
||||
# itself (data format ``f=100``), which means the widget needs no image
|
||||
# decoding library at all. It only needs the *dimensions* of the image to lay
|
||||
# it out correctly in the urwid canvas; those are trivially parseable from the
|
||||
# file headers of the supported formats.
|
||||
#
|
||||
# Supported source formats:
|
||||
# - WebP (VP8, VP8L and VP8X)
|
||||
# - PNG
|
||||
#
|
||||
# The payload transmitted to the terminal is always a PNG file: PNG sources
|
||||
# are passed through verbatim, and WebP sources are converted transparently
|
||||
# by the multi-backend conversion chain in :mod:`._webp` (no required
|
||||
# dependencies). The conversion result is cached on disk, so each unique
|
||||
# WebP is converted at most once per system.
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
|
||||
MAX_CONVERTED_PAYLOAD_BYTES = 16 * 1024 * 1024
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
# Returns (width, height) from a PNG file.
|
||||
def _parse_png(data):
|
||||
if not data.startswith(_PNG_SIGNATURE): raise ValueError("not a PNG")
|
||||
if len(data) < 24 or data[12:16] != b"IHDR": raise ValueError("PNG missing IHDR chunk")
|
||||
width = int.from_bytes(data[16:20], "big")
|
||||
height = int.from_bytes(data[20:24], "big")
|
||||
if not (width > 0 and height > 0): raise ValueError("PNG has null dimensions")
|
||||
return width, height
|
||||
|
||||
# Returns (width, height) from a WebP file (VP8, VP8L or VP8X).
|
||||
def _parse_webp(data):
|
||||
if not data.startswith(b"RIFF") or data[8:12] != b"WEBP": raise ValueError("not a WebP")
|
||||
if len(data) < 30: raise ValueError("WebP file too short")
|
||||
fourcc = data[12:16]
|
||||
if fourcc == b"VP8X":
|
||||
# 10-byte chunk: flags(1) reserved(3) canvas-w-1(3 LE) canvas-h-1(3 LE)
|
||||
width = int.from_bytes(data[24:27], "little") + 1
|
||||
height = int.from_bytes(data[27:30], "little") + 1
|
||||
elif fourcc == b"VP8 ":
|
||||
# frame tag(3) start code 0x9d012a(3) width(2 LE, 14 bits) height(2 LE, 14 bits)
|
||||
width = int.from_bytes(data[26:28], "little") & 0x3FFF
|
||||
height = int.from_bytes(data[28:30], "little") & 0x3FFF
|
||||
elif fourcc == b"VP8L":
|
||||
# signature(1) then 4 bytes: w-1 (14 bits) | h-1 (14 bits) | ...
|
||||
bits = int.from_bytes(data[21:25], "little")
|
||||
width = (bits & 0x3FFF) + 1
|
||||
height = ((bits >> 14) & 0x3FFF) + 1
|
||||
else: raise ValueError("unsupported WebP variant %r" % fourcc)
|
||||
if not (width > 0 and height > 0): raise ValueError("WebP has null dimensions")
|
||||
return width, height
|
||||
|
||||
|
||||
def parse_image_header(data):
|
||||
if data[:8] == _PNG_SIGNATURE:
|
||||
w, h = _parse_png(data)
|
||||
return "PNG", w, h
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
w, h = _parse_webp(data)
|
||||
return "WebP", w, h
|
||||
raise ValueError("unsupported image format")
|
||||
|
||||
# Loads an image file and exposes its PNG bytes and dimensions.
|
||||
# The payload is always a PNG file, transmitted verbatim to the terminal
|
||||
# (Kitty's f=100 data format). The dimensions are used only for layout.
|
||||
# WebP sources are converted transparently to PNG on load.
|
||||
class ImageData(object):
|
||||
def __init__(self, path, max_bytes=MAX_PAYLOAD_BYTES, remote_source=False):
|
||||
self.path = path
|
||||
self.error = None
|
||||
self.format = None
|
||||
self.source_format = None
|
||||
self.conversion_backend = None
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
self.data = None
|
||||
try:
|
||||
with open(path, "rb") as f: data = f.read()
|
||||
except OSError as e:
|
||||
self.error = "Could not read file: %s" % e
|
||||
return
|
||||
|
||||
if len(data) > max_bytes:
|
||||
self.error = "File too large (%.1f MiB > %d MiB)" % (len(data) / (1024 * 1024), max_bytes // (1024 * 1024))
|
||||
return
|
||||
|
||||
if not data:
|
||||
self.error = "Empty file"
|
||||
return
|
||||
|
||||
try: self.format, self.width, self.height = parse_image_header(data)
|
||||
except ValueError as e:
|
||||
self.error = str(e)
|
||||
return
|
||||
|
||||
self.source_format = self.format
|
||||
if self.format == "WebP":
|
||||
from . import _webp
|
||||
png = _webp.convert_webp_to_png(data)
|
||||
if png is None:
|
||||
self.error = "WebP conversion failed (no working backend available)"
|
||||
return
|
||||
if len(png) > MAX_CONVERTED_PAYLOAD_BYTES:
|
||||
self.error = "Converted image too large (%.1f MiB > %d MiB)" % (len(png) / (1024 * 1024), MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024))
|
||||
return
|
||||
|
||||
self.data = png
|
||||
self.format = "PNG"
|
||||
self.conversion_backend = _webp.last_backend()
|
||||
|
||||
else:
|
||||
if remote_source:
|
||||
self.error = "Invalid image format, remote images must be WebP"
|
||||
return
|
||||
else:
|
||||
self.data = data
|
||||
|
||||
# Content key for de-duplication: identical bytes = same key, so
|
||||
# the same image transmitted once can be placed many times.
|
||||
self._key = hashlib.sha256(self.data).hexdigest()
|
||||
|
||||
@property
|
||||
def ok(self): return self.data is not None
|
||||
|
||||
@property
|
||||
def key(self): return getattr(self, "_key", None)
|
||||
|
||||
@property
|
||||
def size(self): return (self.width, self.height)
|
||||
|
||||
def description(self):
|
||||
if self.ok:
|
||||
if self.source_format == "WebP":
|
||||
return "WebP %dx%d -> PNG (%s)" % (self.width, self.height, self.conversion_backend or "cached")
|
||||
else:
|
||||
return "%s %dx%d" % (self.format, self.width, self.height)
|
||||
|
||||
return self.error
|
||||
90
nomadnet/ui/textui/images/_imagestore.py
Normal file
90
nomadnet/ui/textui/images/_imagestore.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Registry of terminal-side image data
|
||||
#
|
||||
# Tracks which image payloads have been transmitted to the terminal (Kitty
|
||||
# image ids), de-duplicates identical image data across widgets (one payload
|
||||
# transmitted once, placed many times), and counts live widget references so
|
||||
# that orphaned data can be evicted from the terminal's buffers.
|
||||
#
|
||||
# This module only tracks *state*; all protocol output is performed by
|
||||
# ImageScreen, which reads and updates the store.
|
||||
#
|
||||
# Eviction:
|
||||
#
|
||||
# - Automatic, widgets unregister when closed/destroyed; the screen evicts
|
||||
# (deletes from the terminal) any registered data with no live references.
|
||||
# - Explicit, the browser can call ImageScreen.purge_images() on page
|
||||
# navigation to drop everything at once.
|
||||
|
||||
DEFAULT_IMAGE_ID_MAX = 2 ** 32 - 1 # Protocol limit for image ids
|
||||
|
||||
class ImageStore(object):
|
||||
def __init__(self):
|
||||
self._entries = {} # content key -> {"image_id", "data", "refs", "transmitted"}
|
||||
self._next_image_id = 1
|
||||
|
||||
#############
|
||||
# Lifecycle #
|
||||
#############
|
||||
|
||||
# Registers image data and returns (key, is_new).
|
||||
# If identical data is already registered, the existing entry is
|
||||
# reused (de-duplication) and its reference count is incremented.
|
||||
def register(self, key, data):
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
image_id = self._next_image_id
|
||||
self._next_image_id += 1
|
||||
if image_id > DEFAULT_IMAGE_ID_MAX: raise OverflowError("image id space exhausted")
|
||||
entry = { "image_id": image_id, "data": data,
|
||||
"refs": 0, "transmitted": False }
|
||||
self._entries[key] = entry
|
||||
is_new = True
|
||||
else: is_new = False
|
||||
|
||||
entry["refs"] += 1
|
||||
return key, is_new
|
||||
|
||||
# Decrements the reference count of registered data
|
||||
def unregister(self, key):
|
||||
entry = self._entries.get(key)
|
||||
if entry is None: return
|
||||
entry["refs"] = max(0, entry["refs"] - 1)
|
||||
|
||||
###########
|
||||
# Queries #
|
||||
###########
|
||||
|
||||
def entry_for(self, key): return self._entries.get(key)
|
||||
|
||||
def transmitted(self): return [key for key, entry in self._entries.items() if entry["transmitted"]]
|
||||
|
||||
# Returns [(key, image_id), ...] for entries with no live
|
||||
# widget references that are still present in the terminal.
|
||||
def evict_orphans(self):
|
||||
return [ (key, entry["image_id"])
|
||||
for key, entry in self._entries.items()
|
||||
if entry["refs"] == 0 and entry["transmitted"] ]
|
||||
|
||||
############
|
||||
# Mutation #
|
||||
############
|
||||
|
||||
def mark_transmitted(self, key):
|
||||
entry = self._entries.get(key)
|
||||
if entry is not None:
|
||||
entry["transmitted"] = True
|
||||
|
||||
def mark_all_untransmitted(self):
|
||||
for entry in self._entries.values():
|
||||
entry["transmitted"] = False
|
||||
|
||||
# Drops the entry for evicted data (frees the payload bytes)
|
||||
def evict(self, key): self._entries.pop(key, None)
|
||||
|
||||
# Forgets all state (used by tests)
|
||||
def reset(self):
|
||||
self._entries.clear()
|
||||
self._next_image_id = 1
|
||||
|
||||
# Module-level singleton shared by widgets and the screen.
|
||||
image_store = ImageStore()
|
||||
718
nomadnet/ui/textui/images/_selftest.py
Normal file
718
nomadnet/ui/textui/images/_selftest.py
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
.. Images - headless self test
|
||||
|
||||
Exercises the widget + screen machinery without a terminal: image header
|
||||
parsing, display sizing, shard scanning, APC construction/chunking and the
|
||||
transmit/delete state machine at various scroll positions.
|
||||
|
||||
Run::
|
||||
|
||||
python3 -m nomadnet.ui.textui.images._selftest [image-file]
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
|
||||
|
||||
import urwid
|
||||
|
||||
from nomadnet.ui.textui.images import _ctlseqs
|
||||
from nomadnet.ui.textui.images import _imagestore
|
||||
from nomadnet.ui.textui.images import _termlib
|
||||
from nomadnet.ui.textui.images._imagedata import ImageData, parse_image_header
|
||||
from nomadnet.ui.textui.images.screen import ImageScreen
|
||||
from nomadnet.ui.textui.images.widget import ImageWidget
|
||||
|
||||
DEFAULT_IMAGE = os.path.expanduser("~/Scratchpad/test.png")
|
||||
VIEWPORT = (80, 24)
|
||||
CELL_SIZE = (1, 2) # forced fallback for deterministic layout
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def check(cond, label):
|
||||
global PASS, FAIL
|
||||
if cond:
|
||||
PASS += 1
|
||||
print(" ok - %s" % label)
|
||||
else:
|
||||
FAIL += 1
|
||||
print(" FAIL - %s" % label)
|
||||
|
||||
|
||||
def build_pile(image_path, wrap_attrmap=False):
|
||||
widgets = []
|
||||
for i in range(12):
|
||||
widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % i)) # 3 rows each
|
||||
img = ImageWidget(image_path)
|
||||
if wrap_attrmap:
|
||||
img = urwid.AttrMap(img, "body")
|
||||
widgets.append(urwid.Divider(" "))
|
||||
widgets.append(img)
|
||||
widgets.append(urwid.Divider(" "))
|
||||
for i in range(12):
|
||||
widgets.append(urwid.Text("[%02d]\nline one\nline two\n" % (13 + i)))
|
||||
return urwid.Pile(widgets)
|
||||
|
||||
|
||||
class RecordingScreen(ImageScreen):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.log = []
|
||||
|
||||
def write(self, data):
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode("ascii")
|
||||
self.log.append(data)
|
||||
return data
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
|
||||
def extract_transmit(log):
|
||||
"""Returns (image_id, joined_b64_payload) of the a=t transmission, or
|
||||
None if no transmission is present."""
|
||||
image_id = None
|
||||
payloads = []
|
||||
for item in log:
|
||||
if item.startswith("\x1b_Ga=t"):
|
||||
for piece in item.split("\x1b_G")[1:]:
|
||||
body = piece[:-2] # strip the trailing ST (ESC \)
|
||||
head, sep, payload = body.partition(";")
|
||||
if not sep:
|
||||
continue
|
||||
if image_id is None:
|
||||
image_id = int(head.split("i=")[1].split(",")[0])
|
||||
payloads.append(payload)
|
||||
if image_id is None:
|
||||
return None
|
||||
return image_id, "".join(payloads)
|
||||
|
||||
|
||||
def extract_placements(log):
|
||||
"""Returns the control data of every a=p placement command."""
|
||||
return [
|
||||
item[3:-2]
|
||||
for item in log
|
||||
if item.startswith("\x1b_Ga=p")
|
||||
]
|
||||
|
||||
|
||||
def extract_deletes(log):
|
||||
"""Returns the control data of every a=d delete command."""
|
||||
return [
|
||||
item[3:-2]
|
||||
for item in log
|
||||
if item.startswith("\x1b_Ga=d")
|
||||
]
|
||||
|
||||
|
||||
def screen_probe_placement_key(widget, row, col, canvas_cols, trim_top, vis_rows):
|
||||
"""Computes an ImageScreen placement key without a screen instance."""
|
||||
return ImageScreen._placement_key(
|
||||
None, widget, row, col, canvas_cols, trim_top, vis_rows)
|
||||
|
||||
|
||||
def test_header_parse(image_path):
|
||||
print("== header parsing ==")
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read()
|
||||
fmt, w, h = parse_image_header(data)
|
||||
check(fmt == "PNG", "detected PNG")
|
||||
check(w == 1280 and h == 960, "dimensions %dx%d" % (w, h))
|
||||
imgdata = ImageData(image_path)
|
||||
check(imgdata.ok, "ImageData loads without error")
|
||||
check(imgdata.size == (w, h), "ImageData size matches")
|
||||
|
||||
|
||||
def test_transmission_encoding(image_path):
|
||||
print("== APC encoding/chunking ==")
|
||||
small = b"\x89PNG\r\n\x1a\n" + b"x" * 100
|
||||
single = _ctlseqs.kitty_transmission(b"a=T,f=100,c=80,r=30,z=1", small)
|
||||
check(single.count(b"\x1b_G") == 1, "single-chunk transmission has one APC")
|
||||
check(single.startswith(b"\x1b_Ga=T,f=100,c=80,r=30,z=1;"), "control data correct")
|
||||
check(single.endswith(b"\x1b\\"), "ends with ST")
|
||||
payload = single.split(b";", 1)[1][:-2]
|
||||
check(base64.b64decode(payload) == small, "payload round-trips to original file")
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read()
|
||||
chunks = _ctlseqs.kitty_transmission(b"a=T,f=100", data, chunk_size=16)
|
||||
apcs = chunks.split(b"\x1b\\")[:-1]
|
||||
check(len(apcs) > 1, "multi-chunk transmission splits into %d APCs" % len(apcs))
|
||||
first, last = apcs[0], apcs[-1]
|
||||
heads = [a.split(b";", 1)[0][3:] for a in apcs]
|
||||
check(b",m=1;" in first, "first chunk carries control + m=1")
|
||||
check(last.startswith(b"\x1b_Gm=0;"), "last chunk carries m=0")
|
||||
check(sum(b"a=T" in h for h in heads) == 1, "control data appears only once")
|
||||
check(all(h == b"m=1" or h == b"m=0" for h in heads[1:]), "continuation chunks carry only the chunk marker")
|
||||
joined_b64 = b"".join(a.split(b";", 1)[1] for a in apcs)
|
||||
check(base64.b64decode(joined_b64) == data, "multi-chunk payload reassembles")
|
||||
|
||||
|
||||
_TINY_WEBP = base64.b64decode(
|
||||
"UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuUAAA=")
|
||||
|
||||
|
||||
def _webp_fixture():
|
||||
"""Returns (webp_bytes, (width, height)): a 4x4 WebP generated with
|
||||
Pillow when available (more representative), else a 1x1 constant."""
|
||||
try:
|
||||
from PIL import Image
|
||||
if _webp_module_available():
|
||||
image = Image.new("RGBA", (4, 4))
|
||||
for y in range(4):
|
||||
for x in range(4):
|
||||
image.putpixel((x, y), (x * 60, y * 60, x * y * 10, 255))
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="WEBP")
|
||||
data = buf.getvalue()
|
||||
if parse_image_header(data)[0] == "WebP":
|
||||
return data, (4, 4)
|
||||
except Exception:
|
||||
pass
|
||||
return _TINY_WEBP, (1, 1)
|
||||
|
||||
|
||||
def _webp_module_available():
|
||||
try:
|
||||
from PIL import features
|
||||
return bool(features.check("webp"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def test_webp_conversion():
|
||||
print("== webp conversion ==")
|
||||
from nomadnet.ui.textui.images import _webp
|
||||
from nomadnet.ui.textui.images._imagedata import MAX_CONVERTED_PAYLOAD_BYTES
|
||||
|
||||
fmt, w, h = parse_image_header(_TINY_WEBP)
|
||||
check((fmt, w, h) == ("WebP", 1, 1), "embedded fixture is a 1x1 WebP")
|
||||
|
||||
fixture, dims = _webp_fixture()
|
||||
check(parse_image_header(fixture)[1:] == dims,
|
||||
"webp fixture has expected dimensions")
|
||||
|
||||
cache_tmp = tempfile.mkdtemp(prefix="imgw-test-cache-")
|
||||
prev_cache_env = os.environ.get("NOMADNET_IMAGE_CACHE")
|
||||
os.environ["NOMADNET_IMAGE_CACHE"] = cache_tmp
|
||||
try:
|
||||
# Each available backend must convert the fixture to a valid PNG
|
||||
# with matching dimensions. Backends are invoked directly, since
|
||||
# the on-disk cache would otherwise mask all but the first one.
|
||||
_webp.reset()
|
||||
present = [name for name, avail in _webp.available_backends() if avail]
|
||||
converted = 0
|
||||
for name in present:
|
||||
png = _webp._BACKENDS[name](fixture, dims)
|
||||
ok = (png is not None and png.startswith(b"\x89PNG")
|
||||
and _webp._png_dims(png) == dims)
|
||||
check(ok, "%s backend converts to a valid PNG" % name)
|
||||
if ok:
|
||||
converted += 1
|
||||
check(converted >= 1, "at least one backend converts webp (got %d)" % converted)
|
||||
|
||||
# Repeat conversion is served from the on-disk cache: the backend
|
||||
# table is disabled and the conversion still succeeds.
|
||||
_webp.reset()
|
||||
png1 = _webp.convert_webp_to_png(fixture)
|
||||
check(png1 is not None, "first conversion succeeds")
|
||||
orig_backends = dict(_webp._BACKENDS)
|
||||
_webp._BACKENDS = {name: (lambda *a, **k: None) for name in _webp.BACKENDS}
|
||||
png2 = _webp.convert_webp_to_png(fixture)
|
||||
check(png1 == png2 and _webp.stats()["cache_hits"] >= 1,
|
||||
"repeat conversion served from the on-disk cache")
|
||||
_webp._BACKENDS = orig_backends
|
||||
|
||||
# ImageData-level transparency: a webp file loads as a PNG.
|
||||
_webp.reset()
|
||||
fd, webp_path = tempfile.mkstemp(suffix=".webp")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(fixture)
|
||||
d = ImageData(webp_path)
|
||||
check(d.ok and d.format == "PNG" and d.size == dims,
|
||||
"ImageData converts webp transparently to PNG")
|
||||
check(d.data.startswith(b"\x89PNG\r\n\x1a\n") and d.key is not None,
|
||||
"converted payload is a keyed PNG")
|
||||
check("-> PNG" in d.description(), "description notes the conversion")
|
||||
os.unlink(webp_path)
|
||||
|
||||
# Converted-payload cap.
|
||||
_webp.reset()
|
||||
orig_convert = _webp.convert_webp_to_png
|
||||
_webp.convert_webp_to_png = lambda data: b"x" * (MAX_CONVERTED_PAYLOAD_BYTES + 1)
|
||||
fd, webp_path = tempfile.mkstemp(suffix=".webp")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(fixture)
|
||||
d = ImageData(webp_path)
|
||||
check(not d.ok and "Converted image too large" in d.error,
|
||||
"converted payload capped at %d MiB"
|
||||
% (MAX_CONVERTED_PAYLOAD_BYTES // (1024 * 1024)))
|
||||
os.unlink(webp_path)
|
||||
_webp.convert_webp_to_png = orig_convert
|
||||
|
||||
# Undecodable webp (valid header/dimensions, garbage bitstream).
|
||||
_webp.reset()
|
||||
junk = (b"RIFF" + struct.pack("<I", 30) + b"WEBP" + b"VP8 "
|
||||
+ b"\x00" * 10 + b"\x10\x00\x10\x00" + b"\xff" * 12)
|
||||
check(parse_image_header(junk)[1:] == (16, 16),
|
||||
"junk fixture parses as a 16x16 WebP")
|
||||
fd, webp_path = tempfile.mkstemp(suffix=".webp")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(junk)
|
||||
d = ImageData(webp_path)
|
||||
check(not d.ok and "conversion failed" in d.error,
|
||||
"undecodable webp results in the error state")
|
||||
os.unlink(webp_path)
|
||||
finally:
|
||||
if prev_cache_env is not None:
|
||||
os.environ["NOMADNET_IMAGE_CACHE"] = prev_cache_env
|
||||
else:
|
||||
os.environ.pop("NOMADNET_IMAGE_CACHE", None)
|
||||
shutil.rmtree(cache_tmp, ignore_errors=True)
|
||||
_webp.reset()
|
||||
|
||||
|
||||
def test_winsize_parsing():
|
||||
print("== TIOCGWINSZ parsing ==")
|
||||
from nomadnet.ui.textui.images import _termlib
|
||||
# struct order is (rows, cols, xpixel, ypixel)
|
||||
check(_termlib._cell_size_from_winsize([40, 100, 1000, 800]) == (10, 20),
|
||||
"correct winsize -> (10, 20)")
|
||||
check(_termlib._cell_size_from_winsize([24, 80, 960, 480]) == (12, 20),
|
||||
"correct winsize -> (12, 20)")
|
||||
check(_termlib._cell_size_from_winsize([24, 80, 0, 0]) is None,
|
||||
"zero pixel fields -> None")
|
||||
check(_termlib._cell_size_from_winsize([40, 100, 800, 1000]) == (8, 25),
|
||||
"portrait window cell keeps valid aspect")
|
||||
check(_termlib._cell_size_from_winsize([30, 80, 1200, 240]) is None,
|
||||
"implausible aspect (broken reporting) -> None")
|
||||
check(_termlib._cell_size_from_winsize([0, 0, 0, 0]) is None,
|
||||
"empty winsize -> None")
|
||||
|
||||
|
||||
def test_widget_layout(image_path):
|
||||
print("== widget layout ==")
|
||||
_termlib.kitty_supported = True
|
||||
_termlib.cell_size = CELL_SIZE
|
||||
# Force a deterministic terminal size for the height-percent cases.
|
||||
_orig_terminal_size = _termlib.get_terminal_size
|
||||
_termlib.get_terminal_size = lambda: (80, 24)
|
||||
_orig_ui_margin = None
|
||||
from nomadnet.ui.textui.images import widget as _widget_mod
|
||||
_orig_ui_margin = _widget_mod.UI_MARGIN
|
||||
_widget_mod.UI_MARGIN = 8
|
||||
try:
|
||||
imgdata = ImageData(image_path)
|
||||
w = ImageWidget(image_path)
|
||||
iw, ih = imgdata.size
|
||||
expected_rows = max(1, round(80 * CELL_SIZE[0] * ih / (CELL_SIZE[1] * iw)))
|
||||
rows = w.rows((80,))
|
||||
check(rows == expected_rows,
|
||||
"default: rows((80,)) == %d, got %d" % (expected_rows, rows))
|
||||
canvas = w.render((80,))
|
||||
check(isinstance(canvas, urwid.SolidCanvas), "render returns a solid canvas")
|
||||
check(canvas.rows() == rows and canvas.cols() == 80, "canvas sized 80x%d" % rows)
|
||||
check(w._ti_rows == rows and w._ti_cols == 80, "widget stores display size")
|
||||
w.close()
|
||||
|
||||
# width as absolute columns (aspect preserved); the canvas fills the
|
||||
# allocated width while the image rect is the resolved size.
|
||||
w = ImageWidget(image_path, width=40)
|
||||
c, r = w._display_size(80)
|
||||
check((c, r) == (40, 15), "width=40 -> (40, 15), got (%d, %d)" % (c, r))
|
||||
canvas = w.render((80,))
|
||||
check((canvas.cols(), canvas.rows()) == (80, 15),
|
||||
"width=40 canvas fills the row (80, 15), got (%d, %d)"
|
||||
% (canvas.cols(), canvas.rows()))
|
||||
check((w._ti_cols, w._ti_rows) == (40, 15),
|
||||
"width=40 image rect stored (40, 15)")
|
||||
w.close()
|
||||
|
||||
# width as percent of the layout width
|
||||
w = ImageWidget(image_path, width="50%")
|
||||
c, r = w._display_size(80)
|
||||
check((c, r) == (40, 15), "width=50%% -> (40, 15), got (%d, %d)" % (c, r))
|
||||
w.close()
|
||||
|
||||
# height as absolute rows (width derived, aspect preserved)
|
||||
w = ImageWidget(image_path, height=20)
|
||||
c, r = w._display_size(80)
|
||||
check(r == 20 and c == 53, "height=20 -> (53, 20), got (%d, %d)" % (c, r))
|
||||
w.close()
|
||||
|
||||
# height as percent of the visible page height
|
||||
# page = terminal lines (24) - UI_MARGIN (8) = 16; 50% -> 8 rows
|
||||
w = ImageWidget(image_path, height="50%")
|
||||
c, r = w._display_size(80)
|
||||
check(r == 8 and c == 21, "height=50%% -> (21, 8), got (%d, %d)" % (c, r))
|
||||
w.close()
|
||||
|
||||
# both given: stretch to the exact rectangle
|
||||
w = ImageWidget(image_path, width=40, height=30)
|
||||
c, r = w._display_size(80)
|
||||
check((c, r) == (40, 30), "width=40,height=30 stretches -> (40, 30), got (%d, %d)" % (c, r))
|
||||
w.close()
|
||||
|
||||
# stretching is clamped uniformly when overflowing the space
|
||||
w = ImageWidget(image_path, width=400, height=200)
|
||||
c, r = w._display_size(80)
|
||||
check((c, r) == (80, 40), "overflowing stretch clamped -> (80, 40), got (%d, %d)" % (c, r))
|
||||
w.close()
|
||||
|
||||
# BOX layout: the canvas fills the box; the image rect is clamped
|
||||
# and re-derived (width from the clamped height).
|
||||
w = ImageWidget(image_path, name="box")
|
||||
canvas = w.render((80, 10))
|
||||
check((canvas.cols(), canvas.rows()) == (80, 10),
|
||||
"BOX render((80,10)) canvas fills the box, got (%d, %d)"
|
||||
% (canvas.cols(), canvas.rows()))
|
||||
check((w._ti_cols, w._ti_rows) == (27, 10),
|
||||
"BOX image rect clamped -> (27, 10), got (%d, %d)"
|
||||
% (w._ti_cols, w._ti_rows))
|
||||
w.close()
|
||||
|
||||
# invalid specifications
|
||||
def raises(ctor, want):
|
||||
try:
|
||||
ctor()
|
||||
except want:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
check(raises(lambda: ImageWidget(image_path, width="xx"), ValueError),
|
||||
"width='xx' raises ValueError")
|
||||
check(raises(lambda: ImageWidget(image_path, height=0), ValueError),
|
||||
"height=0 raises ValueError")
|
||||
check(raises(lambda: ImageWidget(image_path, width=1.5), TypeError),
|
||||
"width=1.5 raises TypeError")
|
||||
check(raises(lambda: ImageWidget(image_path, height="50"), ValueError),
|
||||
"height='50' raises ValueError")
|
||||
|
||||
# settable properties re-resolve and invalidate
|
||||
w = ImageWidget(image_path)
|
||||
w.width = "50%"
|
||||
c, r = w._display_size(80)
|
||||
check((c, r) == (40, 15), "set width=50%% property -> (40, 15)")
|
||||
w.height = 10
|
||||
c, r = w._display_size(80)
|
||||
check((c, r) == (40, 10), "width+height set -> stretch (40, 10), got (%d, %d)" % (c, r))
|
||||
w.width = None
|
||||
w.height = 10
|
||||
c, r = w._display_size(80)
|
||||
check((c, r) == (27, 10), "height-only after reset -> (27, 10), got (%d, %d)" % (c, r))
|
||||
w.close()
|
||||
|
||||
# native resolution specification ("n"): display at the image's own
|
||||
# pixel size, quantized to the terminal cell grid. Cell = (1, 2),
|
||||
# small fixture 64x32 -> native 64 cols, 16 rows.
|
||||
from nomadnet.ui.textui.images import _webp
|
||||
small_png = _webp._encode_png_rgba(bytes(64 * 32 * 4), 64, 32)
|
||||
fd, small_path = tempfile.mkstemp(suffix=".png")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(small_png)
|
||||
try:
|
||||
wn = ImageWidget(small_path, width="n")
|
||||
wn.render((80,)) # sets _ti_cols/_ti_rows
|
||||
check((wn._ti_cols, wn._ti_rows) == (64, 16),
|
||||
"width='n' displays native size (64, 16), got (%d, %d)"
|
||||
% (wn._ti_cols, wn._ti_rows))
|
||||
wn.close()
|
||||
hn = ImageWidget(small_path, height="n")
|
||||
hn.render((80,))
|
||||
check((hn._ti_cols, hn._ti_rows) == (64, 16),
|
||||
"height='n' displays native size (64, 16), got (%d, %d)"
|
||||
% (hn._ti_cols, hn._ti_rows))
|
||||
hn.close()
|
||||
bn = ImageWidget(small_path, width="n", height="n")
|
||||
bn.render((80,))
|
||||
check((bn._ti_cols, bn._ti_rows) == (64, 16),
|
||||
"width='n' height='n' displays native size (64, 16), got (%d, %d)"
|
||||
% (bn._ti_cols, bn._ti_rows))
|
||||
bn.close()
|
||||
big = ImageWidget(image_path, width="n") # 1280x960, overflows
|
||||
c, r = big._display_size(80)
|
||||
check((c, r) == (80, 30),
|
||||
"native of a large image clamps to available width (80, 30), got (%d, %d)" % (c, r))
|
||||
big.close()
|
||||
check(raises(lambda: ImageWidget(image_path, width="x"), ValueError),
|
||||
"width='x' raises ValueError")
|
||||
finally:
|
||||
os.unlink(small_path)
|
||||
|
||||
# alignment: placement column of a narrowed image inside its canvas
|
||||
w = ImageWidget(image_path, width=40) # default center
|
||||
w.render((80,)) # sets _ti_cols/_ti_rows
|
||||
check(w._ti_align == "|", "default alignment is center")
|
||||
key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
|
||||
check(key == (3, 20, 40, 15, None, None),
|
||||
"centered placement col -> %s" % (key,))
|
||||
w.align = "left"
|
||||
key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
|
||||
check(key == (3, 0, 40, 15, None, None), "left placement col -> 0")
|
||||
w.align = "right"
|
||||
key = screen_probe_placement_key(w, 3, 0, 80, 0, 15)
|
||||
check(key == (3, 40, 40, 15, None, None), "right placement col -> 40")
|
||||
check(raises(lambda: ImageWidget(image_path, align="top"), ValueError),
|
||||
"align='top' raises ValueError")
|
||||
w.close()
|
||||
finally:
|
||||
_termlib.get_terminal_size = _orig_terminal_size
|
||||
if _orig_ui_margin is not None:
|
||||
_widget_mod.UI_MARGIN = _orig_ui_margin
|
||||
|
||||
|
||||
def find_image_widget(pile):
|
||||
for widget, _opts in pile.contents:
|
||||
child = widget
|
||||
if isinstance(child, urwid.AttrMap):
|
||||
child = child.original_widget
|
||||
if isinstance(child, ImageWidget):
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def run_screen_test(image_path, wrap_attrmap, label):
|
||||
print("== screen state machine (%s) ==" % label)
|
||||
_termlib.kitty_supported = True
|
||||
_termlib.cell_size = CELL_SIZE
|
||||
_imagestore.image_store.reset()
|
||||
|
||||
pile = build_pile(image_path, wrap_attrmap=wrap_attrmap)
|
||||
scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
|
||||
screen = RecordingScreen()
|
||||
|
||||
img_widget = find_image_widget(pile)
|
||||
check(img_widget is not None, "image widget found in pile")
|
||||
z = img_widget._ti_z
|
||||
entry = _imagestore.image_store.entry_for(img_widget._ti_key)
|
||||
check(entry is not None and entry["refs"] == 1, "widget registered with image store")
|
||||
image_id = entry["image_id"]
|
||||
b64 = base64.b64encode(img_widget._ti_data.data).decode("ascii")
|
||||
|
||||
def entries_at(offset):
|
||||
scrollable.set_scrollpos(offset)
|
||||
canvas = scrollable.render(VIEWPORT)
|
||||
return screen._ti_scan(canvas)
|
||||
|
||||
# Build a timeline of image visibility across all scroll offsets.
|
||||
# state = (trim_top, row, visible_rows)
|
||||
timeline = []
|
||||
max_offset = 200
|
||||
for off in range(max_offset + 1):
|
||||
entries = entries_at(off)
|
||||
if not entries:
|
||||
timeline.append((off, None))
|
||||
continue
|
||||
widget, row, col, canvas_cols, trim_top, vis_rows = entries[0]
|
||||
timeline.append((off, (trim_top, row, vis_rows)))
|
||||
image_rows = img_widget._ti_rows
|
||||
visible = [t for t in timeline if t[1] is not None]
|
||||
check(len(visible) > 0, "image is visible at some scroll offset")
|
||||
if not visible:
|
||||
return
|
||||
|
||||
def first_offset(pred):
|
||||
for off, state in timeline:
|
||||
if state is not None and pred(state):
|
||||
return off
|
||||
return None
|
||||
|
||||
off_enter = first_offset(lambda s: s[0] == 0) # trim_top == 0
|
||||
off_clip = first_offset(lambda s: s[0] > 0) # top-clipped
|
||||
check(off_enter is not None, "found offset with image fully entered")
|
||||
check(off_clip is not None, "found offset with image top-clipped")
|
||||
if off_enter is None or off_clip is None:
|
||||
return
|
||||
# State at the entry offset: (trim_top, row, visible_rows)
|
||||
off_enter_vis_rows = timeline[off_enter][1][2]
|
||||
|
||||
last_visible_off = visible[-1][0]
|
||||
off_away = last_visible_off + 1 if last_visible_off < max_offset else None
|
||||
check(off_away is not None, "found offset past the image")
|
||||
|
||||
def update(offset):
|
||||
screen.log = []
|
||||
scrollable.set_scrollpos(offset)
|
||||
canvas = scrollable.render(VIEWPORT)
|
||||
screen._ti_update(canvas)
|
||||
return list(screen.log)
|
||||
|
||||
# 1. before the image is visible: nothing
|
||||
log = update(0)
|
||||
check(not any("a=t" in l or "a=p" in l for l in log),
|
||||
"nothing written while out of view")
|
||||
check(not screen._ti_images, "nothing tracked while out of view")
|
||||
|
||||
# 2. image entered (bottom-clipped): transmit once, place the visible
|
||||
# slice (y=0 crop) at the bottom.
|
||||
log = update(off_enter)
|
||||
trans = extract_transmit(log)
|
||||
placements = extract_placements(log)
|
||||
check(trans is not None, "image data transmitted on entry")
|
||||
if trans:
|
||||
trans_id, pb64 = trans
|
||||
check(trans_id == image_id, "transmit uses the store's image id")
|
||||
check(pb64 == b64, "transmitted payload is the original file")
|
||||
check(len(placements) == 1, "one placement on entry, got %d" % len(placements))
|
||||
if placements:
|
||||
ctrl = placements[0]
|
||||
check("i=%d" % image_id in ctrl and "p=%d" % z in ctrl,
|
||||
"placement references image id and placement id")
|
||||
check("c=80" in ctrl and "r=%d" % off_enter_vis_rows in ctrl,
|
||||
"placement has display size")
|
||||
check("y=0," in ctrl and "h=" in ctrl, "entry slice uses a y=0 crop")
|
||||
check("z=%d" % z in ctrl and "C=1" in ctrl and "q=1" in ctrl,
|
||||
"placement has z-index, C=1 and quiet mode")
|
||||
check(len(screen._ti_images) == 1 and
|
||||
screen._ti_images[id(img_widget)]["drawn"] is not None,
|
||||
"image tracked as drawn")
|
||||
check(entry["transmitted"], "store entry marked transmitted")
|
||||
|
||||
# 3. move while visible: in-place placement update, no re-transmit,
|
||||
# no delete.
|
||||
log = update(off_enter + 1)
|
||||
check(extract_transmit(log) is None, "position change does not re-transmit")
|
||||
check(len(extract_placements(log)) == 1, "position change re-places in place")
|
||||
check(not extract_deletes(log), "position change does not delete")
|
||||
|
||||
# 4. top-clipped: placement with a y>0 crop.
|
||||
log = update(off_clip)
|
||||
placements = extract_placements(log)
|
||||
check(len(placements) == 1, "top-clip places a slice, got %d" % len(placements))
|
||||
if placements:
|
||||
ctrl = placements[0]
|
||||
y_px = int(ctrl.split("y=")[1].split(",")[0].split(";")[0])
|
||||
check(",y=" in ctrl and "h=" in ctrl and y_px > 0,
|
||||
"top-clipped placement has y>0 crop (y=%d)" % y_px)
|
||||
|
||||
# 5. scrolled past: placement deleted, image data kept.
|
||||
log = update(off_away)
|
||||
deletes = extract_deletes(log)
|
||||
check(deletes == ["a=d,d=i,i=%d,p=%d" % (image_id, z)],
|
||||
"scroll-away deletes the placement only: %s" % deletes)
|
||||
check(extract_transmit(log) is None, "scroll-away does not transmit")
|
||||
check(id(img_widget) not in screen._ti_images, "widget untracked after scroll-away")
|
||||
|
||||
# 6. scroll back: re-placed from the kept data, no re-transmission.
|
||||
log = update(off_enter)
|
||||
check(extract_transmit(log) is None, "scroll-back does not re-transmit")
|
||||
check(len(extract_placements(log)) == 1, "scroll-back re-places the image")
|
||||
|
||||
# 7. still visible, unchanged position: nothing written.
|
||||
log = update(off_enter)
|
||||
check(not extract_placements(log) and not extract_deletes(log),
|
||||
"unchanged position writes nothing")
|
||||
|
||||
# 8. widget closed -> orphaned data evicted from the terminal.
|
||||
img_widget.close()
|
||||
log = update(off_away)
|
||||
deletes = extract_deletes(log)
|
||||
check("a=d,d=I,i=%d" % image_id in deletes,
|
||||
"orphaned image data evicted with d=I")
|
||||
check(_imagestore.image_store.entry_for(img_widget._ti_key) is None,
|
||||
"evicted entry dropped from the store")
|
||||
|
||||
|
||||
def test_eviction_and_purge(image_path):
|
||||
print("== eviction and purge ==")
|
||||
_termlib.kitty_supported = True
|
||||
_termlib.cell_size = CELL_SIZE
|
||||
_imagestore.image_store.reset()
|
||||
|
||||
# Shared source: two widgets with the same file share one store entry.
|
||||
w1 = ImageWidget(image_path)
|
||||
w2 = ImageWidget(image_path)
|
||||
entry = _imagestore.image_store.entry_for(w1._ti_key)
|
||||
check(entry["refs"] == 2, "identical sources share one registry entry (refs=%d)"
|
||||
% entry["refs"])
|
||||
check(w2._ti_key == w1._ti_key, "identical sources have the same content key")
|
||||
|
||||
w1.close()
|
||||
check(entry["refs"] == 1, "closing one widget decrements refcount")
|
||||
check(entry["transmitted"] is False, "no data transmitted yet")
|
||||
|
||||
# Place w2 via a screen update and verify orphan eviction after close.
|
||||
pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w2,
|
||||
urwid.Divider(" "), urwid.Text("y")])
|
||||
scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
|
||||
screen = RecordingScreen()
|
||||
image_id = entry["image_id"]
|
||||
|
||||
def update(offset):
|
||||
screen.log = []
|
||||
scrollable.set_scrollpos(offset)
|
||||
screen._ti_update(scrollable.render(VIEWPORT))
|
||||
return list(screen.log)
|
||||
|
||||
log = update(2)
|
||||
check(extract_transmit(log) is not None, "image transmitted when widget visible")
|
||||
check(entry["transmitted"], "entry marked transmitted")
|
||||
|
||||
w2.close()
|
||||
log = update(0)
|
||||
deletes = extract_deletes(log)
|
||||
check("a=d,d=I,i=%d" % image_id in deletes, "last reference closed -> d=I eviction")
|
||||
check(_imagestore.image_store.entry_for(w2._ti_key) is None,
|
||||
"entry dropped after eviction")
|
||||
|
||||
# Purge: after transmitting, purge_images() deletes everything and the
|
||||
# next redraw re-transmits for still-visible widgets.
|
||||
_imagestore.image_store.reset()
|
||||
w3 = ImageWidget(image_path)
|
||||
pile = urwid.Pile([urwid.Text("x"), urwid.Divider(" "), w3,
|
||||
urwid.Divider(" "), urwid.Text("y")])
|
||||
scrollable = urwid.Scrollable(pile, force_forward_keypress=True)
|
||||
screen2 = RecordingScreen()
|
||||
|
||||
def update2(offset):
|
||||
screen2.log = []
|
||||
scrollable.set_scrollpos(offset)
|
||||
screen2._ti_update(scrollable.render(VIEWPORT))
|
||||
return list(screen2.log)
|
||||
|
||||
log = update2(2)
|
||||
check(extract_transmit(log) is not None, "purge-test: image transmitted")
|
||||
screen2.log = []
|
||||
screen2.purge_images()
|
||||
check(any("a=d,d=A" in l for l in screen2.log), "purge deletes all (d=A)")
|
||||
check(_imagestore.image_store.transmitted() == [], "purge marks all untransmitted")
|
||||
|
||||
log = update2(2)
|
||||
check(extract_transmit(log) is not None, "visible image re-transmitted after purge")
|
||||
check(len(extract_placements(log)) == 1, "visible image re-placed after purge")
|
||||
w3.close()
|
||||
|
||||
|
||||
def main():
|
||||
image_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_IMAGE
|
||||
print("Using image: %s" % image_path)
|
||||
_termlib.reset_caches()
|
||||
print("ambient cell size (env): %s" % (_termlib._query_cell_size(),))
|
||||
|
||||
test_header_parse(image_path)
|
||||
test_transmission_encoding(image_path)
|
||||
test_webp_conversion()
|
||||
test_winsize_parsing()
|
||||
test_widget_layout(image_path)
|
||||
run_screen_test(image_path, wrap_attrmap=False, label="bare widget")
|
||||
run_screen_test(image_path, wrap_attrmap=True, label="attrmap-wrapped")
|
||||
test_eviction_and_purge(image_path)
|
||||
|
||||
print("\n%d passed, %d failed" % (PASS, FAIL))
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
199
nomadnet/ui/textui/images/_termlib.py
Normal file
199
nomadnet/ui/textui/images/_termlib.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Terminal queries for capability detection and layout metrics.
|
||||
#
|
||||
# All values are resolved once and cached; subsequent calls are instant.
|
||||
# Queries should be performed *before* starting the urwid main loop, because
|
||||
# they read directly from the terminal and would otherwise race with urwid's
|
||||
# own input handling. If no terminal is available, or the queries time out,
|
||||
# sensible fallbacks are used.
|
||||
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
|
||||
try: import termios
|
||||
except: termios = None
|
||||
|
||||
from . import _ctlseqs
|
||||
|
||||
QUERY_TIMEOUT = 0.3
|
||||
|
||||
kitty_supported = None
|
||||
cell_size = None
|
||||
|
||||
# Last raw terminal query response (bytes), for diagnostics.
|
||||
last_query_response = bytearray()
|
||||
last_kitty_response = bytearray()
|
||||
|
||||
# Last TIOCGWINSZ buffer (rows, cols, xpixel, ypixel), for diagnostics.
|
||||
winsize = [0, 0, 0, 0]
|
||||
|
||||
# Number of times a query attempt has been made; used to avoid repeated
|
||||
# slow queries in a broken environment.
|
||||
_query_failed = False
|
||||
|
||||
def _open_tty():
|
||||
# Opens the active terminal for reading and writing.
|
||||
for stream in ("out", "in", "err"):
|
||||
try: return os.open(os.ttyname(getattr(sys, "__std%s__" % stream).fileno()), os.O_RDWR)
|
||||
except (OSError, AttributeError): continue
|
||||
try: return os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
|
||||
except OSError: return -1
|
||||
|
||||
|
||||
def query_tty(request, timeout=QUERY_TIMEOUT):
|
||||
# Sends a query to the terminal and returns the raw response.
|
||||
global _query_failed
|
||||
if _query_failed or not termios: return b""
|
||||
|
||||
fd = _open_tty()
|
||||
if fd == -1:
|
||||
_query_failed = True
|
||||
return b""
|
||||
|
||||
# Never block indefinitely, even on a broken pseudo-terminal,
|
||||
# select has a deadline and reads are non-blocking.
|
||||
try: os.set_blocking(fd, False)
|
||||
except OSError: pass
|
||||
|
||||
old_attr = termios.tcgetattr(fd)
|
||||
new_attr = termios.tcgetattr(fd)
|
||||
# Raw-ish mode; disable canonical mode and echo. In canonical mode the
|
||||
# terminal buffers input until a newline, which terminal query responses
|
||||
# (for example. kitty's "i=31;OK") never contain. The read would block until
|
||||
# timeout and the query would always "fail".
|
||||
new_attr[3] &= ~(termios.ECHO | termios.ICANON)
|
||||
new_attr[6][termios.VMIN] = 0
|
||||
new_attr[6][termios.VTIME] = 0
|
||||
try:
|
||||
# TCSANOW (not TCSAFLUSH): TCSAFLUSH drains the output queue first,
|
||||
# which can block indefinitely on some pseudo-terminals.
|
||||
termios.tcsetattr(fd, termios.TCSANOW, new_attr)
|
||||
try: os.write(fd, request)
|
||||
except (BlockingIOError, OSError): return b""
|
||||
response = bytearray()
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0: break
|
||||
try: ready, _, _ = select.select([fd], [], [], remaining)
|
||||
except (OSError, ValueError): break
|
||||
if not ready: break
|
||||
try: chunk = os.read(fd, 4096)
|
||||
except (BlockingIOError, OSError): continue
|
||||
if not chunk: break
|
||||
response += chunk
|
||||
if response.endswith(b"\x1b\\") or response.endswith(b"c"): break
|
||||
last_query_response[:] = response
|
||||
return bytes(response)
|
||||
|
||||
finally:
|
||||
try: termios.tcsetattr(fd, termios.TCSANOW, old_attr)
|
||||
except termios.error: pass
|
||||
finally: os.close(fd)
|
||||
|
||||
def is_kitty_supported():
|
||||
global kitty_supported
|
||||
if kitty_supported is None:
|
||||
response = query_tty(_ctlseqs.KITTY_QUERY)
|
||||
last_kitty_response[:] = response
|
||||
kitty_supported = bool(response and _ctlseqs.KITTY_QUERY_RESPONSE_RE.search(response))
|
||||
return kitty_supported
|
||||
|
||||
def force_kitty_supported(status):
|
||||
# Forces the support status (used by tests).
|
||||
global kitty_supported
|
||||
kitty_supported = bool(status)
|
||||
|
||||
def _cell_size_from_winsize(buf):
|
||||
# Interprets a TIOCGWINSZ buffer as (cell_width_px, cell_height_px).
|
||||
# The struct layout is (rows, cols, xpixel, ypixel). Returns None if the
|
||||
# terminal does not report pixel dimensions, or if the result is
|
||||
# implausible (e.g. several times wider than tall - a sign of broken
|
||||
# winsize reporting).
|
||||
rows, cols, xpix, ypix = buf
|
||||
if not (cols and rows and xpix and ypix): return None
|
||||
cell_w, cell_h = max(1, xpix // cols), max(1, ypix // rows)
|
||||
if not (0.1 <= cell_w / cell_h <= 1.5):
|
||||
# Real terminal cells should be as wide as, or narrower than,
|
||||
# they are tall; anything far outside that range is bogus.
|
||||
return None
|
||||
return (cell_w, cell_h)
|
||||
|
||||
def _query_cell_size():
|
||||
# Determines the current cell size in pixels, or None if unknown.
|
||||
if not termios: return None
|
||||
fd = _open_tty()
|
||||
if fd != -1:
|
||||
# First, try the TIOCGWINSZ pixel fields (fast, no query)
|
||||
import array
|
||||
import fcntl
|
||||
try:
|
||||
buf = array.array("H", [0, 0, 0, 0])
|
||||
fcntl.ioctl(fd, termios.TIOCGWINSZ, buf)
|
||||
winsize[:] = buf
|
||||
cell = _cell_size_from_winsize(buf)
|
||||
if cell is not None: return cell
|
||||
|
||||
except OSError: pass
|
||||
finally: os.close(fd)
|
||||
|
||||
# Fall back to XTWINOPS 16 (cell size in pixels); DA1 is appended to
|
||||
# speed up the response (terminals treat queries as FIFO).
|
||||
response = query_tty(_ctlseqs.CELL_SIZE_PX + _ctlseqs.DA1)
|
||||
if response:
|
||||
match = _ctlseqs.CELL_SIZE_RESPONSE_RE.search(response)
|
||||
if match: return (int(match.group("w")), int(match.group("h")))
|
||||
return None
|
||||
|
||||
def get_cell_size():
|
||||
# Returns the terminal cell size in pixels as (width, height), or None
|
||||
# if it could not be determined.
|
||||
global cell_size
|
||||
if cell_size is None: cell_size = _query_cell_size()
|
||||
return cell_size
|
||||
|
||||
|
||||
def get_terminal_size():
|
||||
# Returns the active terminal size as (columns, lines).
|
||||
# Uses the tty directly when available (correct even with redirected
|
||||
# output), falling back to shutil.get_terminal_size.
|
||||
|
||||
fd = _open_tty()
|
||||
if fd != -1:
|
||||
try:
|
||||
size = os.get_terminal_size(fd)
|
||||
return (size.columns, size.lines)
|
||||
except OSError: pass
|
||||
finally: os.close(fd)
|
||||
size = shutil.get_terminal_size(fallback=(80, 24))
|
||||
return (size.columns, size.lines)
|
||||
|
||||
def reset_caches():
|
||||
# Clears all cached capability/metric values (used by tests).
|
||||
global kitty_supported, cell_size, _query_failed
|
||||
kitty_supported = None
|
||||
cell_size = None
|
||||
_query_failed = False
|
||||
last_query_response[:] = b""
|
||||
last_kitty_response[:] = b""
|
||||
winsize[:] = (0, 0, 0, 0)
|
||||
|
||||
def diagnostics():
|
||||
# Returns a list of diagnostic strings about the terminal environment,
|
||||
# for display/debugging.
|
||||
import os
|
||||
lines = [ "TERM=%r" % os.environ.get("TERM"),
|
||||
"TERM_PROGRAM=%r TERM_PROGRAM_VERSION=%r" % (os.environ.get("TERM_PROGRAM"), os.environ.get("TERM_PROGRAM_VERSION")),
|
||||
"kitty graphics protocol supported: %s" % is_kitty_supported(),
|
||||
"kitty query response: %r" % bytes(last_kitty_response),
|
||||
"cell size (px): %s" % (get_cell_size(),),
|
||||
"cell size query response: %r" % bytes(last_query_response),
|
||||
"winsize (rows, cols, xpix, ypix): %r" % (tuple(winsize),) ]
|
||||
try:
|
||||
from . import _webp
|
||||
lines.append("webp conversion: %s" % _webp.describe())
|
||||
except Exception: pass
|
||||
|
||||
return lines
|
||||
367
nomadnet/ui/textui/images/_webp.py
Normal file
367
nomadnet/ui/textui/images/_webp.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# Multi-backend WebP decoding
|
||||
#
|
||||
# Kitty's graphics protocol decodes PNG natively but not WebP, so WebP
|
||||
# sources are converted transparently at the ImageData layer. This module
|
||||
# provides a conversion chain that *should* work almost anywhere with
|
||||
# zero extra dependencies:
|
||||
#
|
||||
# - Pillow/PIL, if installed with WebP support
|
||||
# - libwebp via ctypes, decoding to RGBA, paired with a pure-Python PNG
|
||||
# encoder. The system libwebp is present on most Linux/macOS systems
|
||||
# as a transitive dependency of common graphics stacks and browsers
|
||||
# - sips - the macOS built-in image tool
|
||||
# - dwebp - from the system PATH (the "official" WebP tool)
|
||||
# - ImageMagick / GraphicsMagick (magick, convert, gm convert)
|
||||
# - ffmpeg / avconv
|
||||
# - gdk-pixbuf-thumbnailer (GTK/GNOME systems)
|
||||
#
|
||||
# Every backend's output is validated (it must be a valid PNG with the same
|
||||
# dimensions as the source WebP), so missing tools, or tools built without
|
||||
# WebP support, are simply skipped. The first backend that produces a valid
|
||||
# conversion is remembered and preferred for subsequent conversions, but the
|
||||
# preference is replaced whenever a different backend wins for a particular
|
||||
# image.
|
||||
#
|
||||
# Environment overrides:
|
||||
#
|
||||
# NOMADNET_IMAGE_BACKEND force a specific backend (strict)
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zlib
|
||||
|
||||
MAX_CACHE_BYTES = 192 * 1024 * 1024
|
||||
MAX_CACHE_FILES = 512
|
||||
CONVERSION_TIMEOUT = 5
|
||||
|
||||
# libwebp soname candidates, in order of preference.
|
||||
_LIBWEBP_NAMES = [ "libwebp.so.7",
|
||||
"libwebp.so.6",
|
||||
"libwebp.so.5",
|
||||
"libwebp.so",
|
||||
"libwebp.dylib",
|
||||
"libwebp-7.dll",
|
||||
"libwebp.dll" ]
|
||||
|
||||
# The built-in backend chain, in preference order.
|
||||
BACKENDS = ( "pil",
|
||||
"libwebp-ctypes",
|
||||
"sips",
|
||||
"dwebp",
|
||||
"magick",
|
||||
"convert",
|
||||
"gm",
|
||||
"ffmpeg",
|
||||
"avconv",
|
||||
"gdk-pixbuf" )
|
||||
|
||||
_ENV_BACKEND = os.environ.get("NOMADNET_IMAGE_BACKEND")
|
||||
_winner = None # name of the currently preferred backend
|
||||
_last_backend = None # backend used for the most recent conversion
|
||||
_convert_count = 0
|
||||
_cache_hits = 0
|
||||
|
||||
_libwebp_cache = {"lib": None, "probed": False}
|
||||
|
||||
###############################
|
||||
# PNG validation and encoding #
|
||||
###############################
|
||||
|
||||
# Returns (width, height) if png is a valid PNG, else None
|
||||
def _png_dims(png):
|
||||
if not png.startswith(b"\x89PNG\r\n\x1a\n") or len(png) < 24:
|
||||
return None
|
||||
if png[12:16] != b"IHDR":
|
||||
return None
|
||||
width = int.from_bytes(png[16:20], "big")
|
||||
height = int.from_bytes(png[20:24], "big")
|
||||
if not (width > 0 and height > 0):
|
||||
return None
|
||||
return (width, height)
|
||||
|
||||
|
||||
# Encodes RGBA pixel data as a PNG (8-bit RGBA, no filtering)
|
||||
def _encode_png_rgba(rgba, width, height):
|
||||
def chunk(tag, payload):
|
||||
return (struct.pack(">I", len(payload)) + tag + payload +
|
||||
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||
stride = width * 4
|
||||
raw = bytearray(height * (stride + 1))
|
||||
pos = 0
|
||||
for row in range(height):
|
||||
raw[pos] = 0 # filter type: none
|
||||
pos += 1
|
||||
start = row * stride
|
||||
raw[pos:pos + stride] = rgba[start:start + stride]
|
||||
pos += stride
|
||||
|
||||
return (b"\x89PNG\r\n\x1a\n" +
|
||||
chunk(b"IHDR", ihdr) +
|
||||
chunk(b"IDAT", zlib.compress(bytes(raw), 9)) +
|
||||
chunk(b"IEND", b""))
|
||||
|
||||
|
||||
####################
|
||||
# Conversion cache #
|
||||
####################
|
||||
|
||||
CONV_CACHE_BASE = None
|
||||
def cache_dir():
|
||||
global CONV_CACHE_BASE
|
||||
if not CONV_CACHE_BASE:
|
||||
from nomadnet.NomadNetworkApp import NomadNetworkApp
|
||||
CONV_CACHE_BASE = NomadNetworkApp._shared_instance.dispcachepath
|
||||
return CONV_CACHE_BASE
|
||||
|
||||
def _cache_path(source_data):
|
||||
return os.path.join(cache_dir(), hashlib.sha256(source_data).hexdigest() + ".png")
|
||||
|
||||
def _cache_get(source_data):
|
||||
try:
|
||||
with open(_cache_path(source_data), "rb") as f: png = f.read()
|
||||
if _png_dims(png) is not None: return png
|
||||
except OSError: pass
|
||||
return None
|
||||
|
||||
def _cache_put(source_data, png):
|
||||
try:
|
||||
directory = cache_dir()
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f: f.write(png)
|
||||
os.replace(tmp_path, _cache_path(source_data))
|
||||
except OSError:
|
||||
try: os.unlink(tmp_path)
|
||||
except OSError: pass
|
||||
_prune_cache()
|
||||
except OSError: pass
|
||||
|
||||
# Evicts least-recently-used entries beyond the size/file limits
|
||||
def _prune_cache():
|
||||
try:
|
||||
entries = []
|
||||
for name in os.listdir(cache_dir()):
|
||||
path = os.path.join(cache_dir(), name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
if st.st_size > 0:
|
||||
entries.append((st.st_mtime, st.st_size, path))
|
||||
except OSError: pass
|
||||
|
||||
total = sum(e[1] for e in entries)
|
||||
entries.sort()
|
||||
while len(entries) > MAX_CACHE_FILES or total > MAX_CACHE_BYTES:
|
||||
_, size, path = entries.pop(0)
|
||||
total -= size
|
||||
try: os.unlink(path)
|
||||
except OSError: pass
|
||||
except OSError: pass
|
||||
|
||||
#######################
|
||||
# Individual backends #
|
||||
#######################
|
||||
|
||||
# Runs a conversion CLI over temp files
|
||||
def _run_cli(argv, data):
|
||||
if shutil.which(argv[0]) is None: return None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="nomadnet-img-") as td:
|
||||
src = os.path.join(td, "source.webp")
|
||||
dst = os.path.join(td, "output.png")
|
||||
with open(src, "wb") as f: f.write(data)
|
||||
args = [a.replace("{src}", src).replace("{dst}", dst) for a in argv]
|
||||
subprocess.run(
|
||||
args,
|
||||
timeout=CONVERSION_TIMEOUT,
|
||||
# stdin must be closed: some tools (e.g. ffmpeg) block
|
||||
# on inherited stdin otherwise.
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
with open(dst, "rb") as f: return f.read()
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError): return None
|
||||
|
||||
def _backend_pil(data, dims):
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL import features
|
||||
if not features.check("webp"): return None
|
||||
except ImportError: return None
|
||||
try:
|
||||
image = Image.open(io.BytesIO(data))
|
||||
image.load()
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception: return None
|
||||
|
||||
# Loads the system libwebp via ctypes, caching the result.
|
||||
def _get_libwebp():
|
||||
if _libwebp_cache["probed"]: return _libwebp_cache["lib"]
|
||||
_libwebp_cache["probed"] = True
|
||||
lib = None
|
||||
names = list(_LIBWEBP_NAMES)
|
||||
found = ctypes.util.find_library("webp")
|
||||
if found and found not in names: names.insert(0, found)
|
||||
for name in names:
|
||||
try:
|
||||
lib = ctypes.CDLL(name)
|
||||
break
|
||||
except OSError: continue
|
||||
|
||||
if lib is not None:
|
||||
lib.WebPGetInfo.restype = ctypes.c_int
|
||||
lib.WebPGetInfo.argtypes = [ ctypes.c_char_p, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ]
|
||||
|
||||
lib.WebPDecodeRGBA.restype = ctypes.c_void_p
|
||||
lib.WebPDecodeRGBA.argtypes = [ ctypes.c_char_p, ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int) ]
|
||||
lib.WebPFree.restype = None
|
||||
lib.WebPFree.argtypes = [ctypes.c_void_p]
|
||||
|
||||
_libwebp_cache["lib"] = lib
|
||||
return lib
|
||||
|
||||
def _backend_ctypes(data, dims):
|
||||
lib = _get_libwebp()
|
||||
if lib is None: return None
|
||||
try:
|
||||
buf = ctypes.create_string_buffer(data, len(data))
|
||||
width = ctypes.c_int(0)
|
||||
height = ctypes.c_int(0)
|
||||
if not lib.WebPGetInfo(buf, len(data), ctypes.byref(width), ctypes.byref(height)): return None
|
||||
if width.value <= 0 or height.value <= 0: return None
|
||||
ptr = lib.WebPDecodeRGBA(buf, len(data), ctypes.byref(width), ctypes.byref(height))
|
||||
if not ptr: return None
|
||||
try: rgba = ctypes.string_at(ptr, width.value * height.value * 4)
|
||||
finally: lib.WebPFree(ptr)
|
||||
return _encode_png_rgba(rgba, width.value, height.value)
|
||||
except Exception: return None
|
||||
|
||||
def _backend_gdkpixbuf(data, dims):
|
||||
if dims is None: return None
|
||||
width, height = dims
|
||||
return _run_cli(["gdk-pixbuf-thumbnailer", "-s", "%dx%d" % (width, height), "{src}", "{dst}"], data)
|
||||
|
||||
def _backend_sips(data, dims): return _run_cli(["sips", "-s", "format", "png", "{src}", "--out", "{dst}"], data)
|
||||
def _backend_dwebp(data, dims): return _run_cli(["dwebp", "{src}", "-o", "{dst}"], data)
|
||||
def _backend_magick(data, dims): return _run_cli(["magick", "{src}", "{dst}"], data)
|
||||
def _backend_convert(data, dims): return _run_cli(["convert", "{src}", "{dst}"], data)
|
||||
def _backend_gm(data, dims): return _run_cli(["gm", "convert", "{src}", "{dst}"], data)
|
||||
def _backend_ffmpeg(data, dims): return _run_cli(["ffmpeg", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data)
|
||||
def _backend_avconv(data, dims): return _run_cli(["avconv", "-y", "-loglevel", "error", "-i", "{src}", "-frames:v", "1", "{dst}"], data)
|
||||
|
||||
_BACKENDS = { "pil": _backend_pil,
|
||||
"libwebp-ctypes": _backend_ctypes,
|
||||
"sips": _backend_sips,
|
||||
"dwebp": _backend_dwebp,
|
||||
"magick": _backend_magick,
|
||||
"convert": _backend_convert,
|
||||
"gm": _backend_gm,
|
||||
"ffmpeg": _backend_ffmpeg,
|
||||
"avconv": _backend_avconv,
|
||||
"gdk-pixbuf": _backend_gdkpixbuf }
|
||||
|
||||
#####################
|
||||
# Conversion driver #
|
||||
#####################
|
||||
|
||||
def _webp_dims(data):
|
||||
from ._imagedata import parse_image_header
|
||||
try: fmt, width, height = parse_image_header(data)
|
||||
except ValueError: return None
|
||||
if fmt != "WebP": return None
|
||||
return (width, height)
|
||||
|
||||
def _backend_order():
|
||||
if _ENV_BACKEND: return [_ENV_BACKEND] if _ENV_BACKEND in BACKENDS else []
|
||||
order = list(BACKENDS)
|
||||
if _winner in order:
|
||||
order.remove(_winner)
|
||||
order.insert(0, _winner)
|
||||
return order
|
||||
|
||||
# Converts WebP data to PNG bytes, or None on failure
|
||||
def convert_webp_to_png(data):
|
||||
global _cache_hits
|
||||
png = _cache_get(data)
|
||||
if png is not None:
|
||||
_cache_hits += 1
|
||||
return png
|
||||
return _convert_uncached(data)
|
||||
|
||||
def _convert_uncached(data):
|
||||
global _convert_count, _winner, _last_backend
|
||||
dims = _webp_dims(data)
|
||||
if dims is None: return None
|
||||
for name in _backend_order():
|
||||
try: png = _BACKENDS[name](data, dims)
|
||||
except Exception: png = None
|
||||
if png is not None and _png_dims(png) == dims:
|
||||
_convert_count += 1
|
||||
_last_backend = name
|
||||
if not _ENV_BACKEND: _winner = name
|
||||
_cache_put(data, png)
|
||||
return png
|
||||
|
||||
return None
|
||||
|
||||
##################################
|
||||
# Introspection and test support #
|
||||
##################################
|
||||
|
||||
# Returns [(name, available), ...] for the built-in chain
|
||||
def available_backends():
|
||||
out = []
|
||||
for name in BACKENDS:
|
||||
if name == "pil":
|
||||
try:
|
||||
from PIL import features
|
||||
out.append((name, bool(features.check("webp"))))
|
||||
except Exception: out.append((name, False))
|
||||
elif name == "libwebp-ctypes": out.append((name, _get_libwebp() is not None))
|
||||
elif name == "gdk-pixbuf": out.append((name, shutil.which("gdk-pixbuf-thumbnailer") is not None))
|
||||
else: out.append((name, shutil.which(name) is not None))
|
||||
|
||||
return out
|
||||
|
||||
# A diagnostic string describing the conversion setup
|
||||
def describe():
|
||||
if _ENV_BACKEND: return "backend forced via NOMADNET_IMAGE_BACKEND: %s" % _ENV_BACKEND
|
||||
present = [name for name, avail in available_backends() if avail]
|
||||
if not present: return "no conversion backend available (WebP images will not render)"
|
||||
return "backend(s) available: %s; preferred: %s" % (", ".join(present), _last_backend or _winner or "none yet")
|
||||
|
||||
# Usage statistics for diagnostics
|
||||
def stats():
|
||||
return { "backend": _last_backend, "conversions": _convert_count,
|
||||
"cache_hits": _cache_hits, "cache_dir": cache_dir() }
|
||||
|
||||
def last_backend(): return _last_backend
|
||||
|
||||
# Forces a specific backend name (testing/debugging); None to clear.
|
||||
def force_backend(name):
|
||||
global _ENV_BACKEND
|
||||
_ENV_BACKEND = name if name in BACKENDS else None
|
||||
|
||||
# Clears dynamic state (winner, counters); used by tests.
|
||||
def reset():
|
||||
global _winner, _last_backend, _convert_count, _cache_hits
|
||||
_winner = None
|
||||
_last_backend = None
|
||||
_convert_count = 0
|
||||
_cache_hits = 0
|
||||
_libwebp_cache["lib"] = None
|
||||
_libwebp_cache["probed"] = False
|
||||
BIN
nomadnet/ui/textui/images/demo.webp
Normal file
BIN
nomadnet/ui/textui/images/demo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
210
nomadnet/ui/textui/images/screen.py
Normal file
210
nomadnet/ui/textui/images/screen.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import urwid
|
||||
|
||||
from . import _ctlseqs
|
||||
from ._imagestore import image_store
|
||||
from ._termlib import is_kitty_supported
|
||||
from .widget import ImageWidget
|
||||
|
||||
# A raw display screen subclass with Kitty TGP side-channel support
|
||||
class ImageScreen(urwid.raw_display.Screen):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._ti_images = {}
|
||||
|
||||
##########
|
||||
# Output #
|
||||
##########
|
||||
|
||||
def write(self, data):
|
||||
if isinstance(data, bytes): data = data.decode("ascii")
|
||||
return super().write(data)
|
||||
|
||||
#############
|
||||
# Lifecycle #
|
||||
#############
|
||||
|
||||
def clear(self):
|
||||
# Clears all images and forces a full repaint
|
||||
if is_kitty_supported():
|
||||
self.write(_ctlseqs.KITTY_DELETE_ALL)
|
||||
self._ti_images = {}
|
||||
image_store.mark_all_untransmitted()
|
||||
return super().clear()
|
||||
|
||||
def _start(self, *args, **kwargs):
|
||||
ret = super()._start(*args, **kwargs)
|
||||
if is_kitty_supported():
|
||||
# Clear any images left over from a previous run/instance.
|
||||
self.write(_ctlseqs.KITTY_DELETE_ALL)
|
||||
self._ti_images = {}
|
||||
image_store.mark_all_untransmitted()
|
||||
return ret
|
||||
|
||||
def _stop(self):
|
||||
self.purge_images()
|
||||
try: return super()._stop()
|
||||
finally: self._ti_images = {}
|
||||
|
||||
def purge_images(self):
|
||||
# Purges all image data from the terminal buffer.
|
||||
if not is_kitty_supported(): return
|
||||
if self._ti_images or image_store.transmitted():
|
||||
self.write(_ctlseqs.KITTY_DELETE_ALL)
|
||||
self._ti_images = {}
|
||||
image_store.mark_all_untransmitted()
|
||||
|
||||
###########
|
||||
# Drawing #
|
||||
###########
|
||||
|
||||
def draw_screen(self, size, canvas):
|
||||
# Draws the canvas, then updates on-screen images within a
|
||||
# synchronized-update region to prevent flicker.
|
||||
self.write(_ctlseqs.BEGIN_SYNCED_UPDATE)
|
||||
try:
|
||||
super().draw_screen(size, canvas)
|
||||
self._ti_update(canvas)
|
||||
finally:
|
||||
self.write(_ctlseqs.END_SYNCED_UPDATE)
|
||||
self.flush()
|
||||
|
||||
def _ti_update(self, canvas):
|
||||
# Places/updates/deletes image placements to match the current
|
||||
# canvas, and evicts orphaned image data.
|
||||
if not is_kitty_supported(): return
|
||||
|
||||
entries = self._ti_scan(canvas)
|
||||
current = {}
|
||||
for widget, row, col, canvas_cols, trim_top, vis_rows in entries:
|
||||
current[id(widget)] = (widget, row, col, canvas_cols, trim_top, vis_rows)
|
||||
|
||||
# For widgets that scrolled completely out of view, remove their
|
||||
# placement only. The image data is kept in the terminal so that
|
||||
# scrolling back just re-places it.
|
||||
for wid, info in list(self._ti_images.items()):
|
||||
if wid in current: continue
|
||||
self._ti_unplace(info["widget"])
|
||||
del self._ti_images[wid]
|
||||
|
||||
# For visible widgets, place/update the visible slice in place.
|
||||
for wid, (widget, row, col, canvas_cols, trim_top, vis_rows) in current.items():
|
||||
info = self._ti_images.setdefault(wid, {"widget": widget, "drawn": None})
|
||||
if vis_rows <= 0 or trim_top >= widget._ti_rows:
|
||||
if info["drawn"] is not None:
|
||||
self._ti_unplace(widget)
|
||||
info["drawn"] = None
|
||||
continue
|
||||
|
||||
key = self._placement_key(widget, row, col, canvas_cols, trim_top, vis_rows)
|
||||
if key is None:
|
||||
if info["drawn"] is not None:
|
||||
self._ti_unplace(widget)
|
||||
info["drawn"] = None
|
||||
continue
|
||||
|
||||
if info["drawn"] == key: continue
|
||||
self._ti_place(widget, key)
|
||||
info["drawn"] = key
|
||||
|
||||
# Evict image data that no live widget references any more
|
||||
for key, image_id in image_store.evict_orphans():
|
||||
self.write(_ctlseqs.KITTY_DELETE_IMAGE % image_id)
|
||||
image_store.evict(key)
|
||||
|
||||
#############
|
||||
# Internals #
|
||||
#############
|
||||
|
||||
def _ti_scan(self, canvas):
|
||||
# Walks the canvas shards and returns a list of
|
||||
# (widget, row, col, canvas_cols, trim_top, visible_rows)
|
||||
# for every visible image canvas.
|
||||
found = []
|
||||
if not isinstance(canvas, urwid.CompositeCanvas): return found
|
||||
|
||||
def process_shard_tails():
|
||||
nonlocal col
|
||||
while col in shard_tails:
|
||||
*trim, cols, rows, canv = shard_tails[col]
|
||||
if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv)
|
||||
else: del shard_tails[col]
|
||||
col += cols
|
||||
|
||||
shard_tails = {}
|
||||
row = 0
|
||||
for n_rows, cviews in canvas.shards:
|
||||
col = 0
|
||||
for cview in cviews:
|
||||
process_shard_tails()
|
||||
*trim, cols, rows, _, canv = cview
|
||||
widget = self._ti_widget_of(canv)
|
||||
if widget is not None: found.append((widget, row, col, cols, trim[1], rows))
|
||||
if rows > n_rows: shard_tails[col] = (*trim, cols, rows - n_rows, canv)
|
||||
col += cols
|
||||
|
||||
process_shard_tails()
|
||||
row += n_rows
|
||||
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
def _ti_widget_of(canv):
|
||||
# Returns the ImageWidget for a leaf canvas, or None
|
||||
widget_info = getattr(canv, "_widget_info", None)
|
||||
if not widget_info: return None
|
||||
widget = widget_info[0]
|
||||
if isinstance(widget, ImageWidget) and widget._ti_data.ok and not widget._ti_closed: return widget
|
||||
return None
|
||||
|
||||
def _placement_key(self, widget, row, col, canvas_cols, trim_top, vis_rows):
|
||||
# Compute placement parameters for the visible slice of
|
||||
# an image, including the alignment offset.
|
||||
|
||||
cols = widget._ti_cols
|
||||
rows = widget._ti_rows
|
||||
if not (cols and rows): return None
|
||||
place_col = col
|
||||
if cols < canvas_cols:
|
||||
align = widget._ti_align
|
||||
spare = canvas_cols - cols
|
||||
if align == ">": place_col += spare
|
||||
elif align == "|": place_col += spare // 2
|
||||
|
||||
vis_rows = max(1, min(vis_rows, rows))
|
||||
y_px = h_px = None
|
||||
if not (trim_top == 0 and vis_rows == rows):
|
||||
# Crop to the visible slice of the image, in image pixels
|
||||
ih = widget._ti_data.height
|
||||
y_px = min(ih - 1, round(trim_top * ih / rows))
|
||||
h_px = max(1, min(ih - y_px, round(vis_rows * ih / rows)))
|
||||
|
||||
return (row, place_col, cols, vis_rows, y_px, h_px)
|
||||
|
||||
def _ti_place(self, widget, key):
|
||||
# Place (or in-place update) the visible slice of an image
|
||||
row, place_col, cols, vis_rows, y_px, h_px = key
|
||||
entry = image_store.entry_for(widget._ti_key)
|
||||
if entry is None:
|
||||
# This should not happen, but if the image data was evicted
|
||||
# while the image was still alive, re-register it so the
|
||||
# data can be re-transmitted to the terminal buffer.
|
||||
image_store.register(widget._ti_key, widget._ti_data.data)
|
||||
entry = image_store.entry_for(widget._ti_key)
|
||||
image_id = entry["image_id"]
|
||||
|
||||
if not entry["transmitted"]:
|
||||
payload = widget._ti_data.data
|
||||
b64_len = (len(payload) * 4 + 2) // 3
|
||||
n_chunks = max(1, (b64_len + _ctlseqs.CHUNK_SIZE - 1) // _ctlseqs.CHUNK_SIZE)
|
||||
self.write(_ctlseqs.kitty_transmission(_ctlseqs.kitty_transmit_control(image_id), payload))
|
||||
entry["transmitted"] = True
|
||||
|
||||
self.write(_ctlseqs.CURSOR_POSITION % (row + 1, place_col + 1))
|
||||
self.write(_ctlseqs.kitty_placement(image_id, widget._ti_z, cols, vis_rows, widget._ti_z, y=y_px, h=h_px))
|
||||
|
||||
def _ti_unplace(self, widget):
|
||||
# Delete a widget's placement, but keep the
|
||||
# image data in the terminal buffer.
|
||||
entry = image_store.entry_for(widget._ti_key)
|
||||
if entry is None: return
|
||||
self.write(_ctlseqs.KITTY_DELETE_PLACEMENT % (entry["image_id"], widget._ti_z))
|
||||
283
nomadnet/ui/textui/images/widget.py
Normal file
283
nomadnet/ui/textui/images/widget.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
# An urwid widget that occupies the space an image should fill in a layout,
|
||||
# without embedding any escape sequences in its canvas. The actual terminal
|
||||
# graphics are transmitted by the ImageScreen class which tracks the widget's
|
||||
# on-screen position after every draw.
|
||||
#
|
||||
# This separation keeps the canvas trivially simple (urwid sees only blank
|
||||
# rows) and lets the screen handle the hard parts; positioning, deletion when
|
||||
# scrolled away, terminal resize and redraws - all of which are driven by
|
||||
# urwid's own shard bookkeeping rather than by fragile escape-sequence
|
||||
# manipulation inside canvas rows.
|
||||
#
|
||||
# When the Kitty graphics protocol is unavailable (or the image cannot be
|
||||
# loaded), the widget renders a simple text placeholder instead.
|
||||
|
||||
import os
|
||||
import re
|
||||
import urwid
|
||||
|
||||
from . import _termlib
|
||||
from ._imagestore import image_store
|
||||
|
||||
DEFAULT_CELL_SIZE = (1, 2) # Fallback, assume a 1:2 cell aspect ratio
|
||||
MAX_Z_INDEX = 2 ** 31 - 1
|
||||
UI_MARGIN = 8
|
||||
_SIZE_SPEC_RE = re.compile(r"(\d+(?:\.\d+)?)%")
|
||||
_ALIGNS = { "left": "<", "<": "<",
|
||||
"center": "|", "|": "|",
|
||||
"right": ">", ">": ">" }
|
||||
|
||||
def _check_align(value):
|
||||
if value not in _ALIGNS: raise ValueError("invalid alignment %r (expected 'left', 'center' or 'right')" % (value,))
|
||||
return _ALIGNS[value]
|
||||
|
||||
|
||||
def _check_size_spec(value, name):
|
||||
# None = auto, int = absolute cells/rows, "NN%" = percent, "n" = native resolution
|
||||
if value is None: return None
|
||||
|
||||
if isinstance(value, int):
|
||||
if value <= 0: raise ValueError("%s must be a positive integer, got %r" % (name, value))
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
if value.lower() == "n": return "n"
|
||||
match = _SIZE_SPEC_RE.fullmatch(value)
|
||||
if not match or float(match.group(1)) <= 0:
|
||||
raise ValueError("invalid %s specification %r (expected a positive integer, a percent string like '50%%' or 'n' for native resolution)" % (name, value))
|
||||
return value
|
||||
|
||||
raise TypeError("invalid type for %s (got %s)" % (name, type(value).__name__))
|
||||
|
||||
|
||||
class ImageWidget(urwid.Widget):
|
||||
# A widget that displays an image via the Kitty graphics protocol.
|
||||
# The widget is a plain FLOW/BOX widget; its canvas is blank. The
|
||||
# ImageScreen class performs the actual drawing over the Kitty
|
||||
# TGP side-channel.
|
||||
|
||||
placeholder_glyph = ""
|
||||
|
||||
_sizing = frozenset((urwid.FLOW, urwid.BOX))
|
||||
ignore_focus = True
|
||||
|
||||
# Z-index allocation. Each widget instance gets a unique z-index so that
|
||||
# the screen can delete exactly one image when it scrolls away or moves.
|
||||
_ti_next_z = 1
|
||||
_ti_free_z = set()
|
||||
|
||||
def __init__(self, path, name=None, width=None, height=None, align="center"):
|
||||
if path and not isinstance(path, (str, os.PathLike)): raise TypeError("path must be a string or path-like object")
|
||||
self.path = path
|
||||
self._notice = None
|
||||
self._ti_path = None
|
||||
self._ti_data = None
|
||||
self._ti_width = _check_size_spec(width, "width")
|
||||
self._ti_height = _check_size_spec(height, "height")
|
||||
self._ti_align = _check_align(align)
|
||||
self._ti_name = name if name is not None else ""
|
||||
self._ti_z = self._ti_alloc_z()
|
||||
self.image_id = self._ti_z
|
||||
|
||||
# Display size (cols, rows) from the most recent render.
|
||||
# Used by the screen to place the image.
|
||||
self._ti_cols = None
|
||||
self._ti_rows = None
|
||||
self._ti_closed = False
|
||||
self._ti_key = None
|
||||
super().__init__()
|
||||
if self.path: self.load()
|
||||
|
||||
def load(self, path=None, remote_source=False):
|
||||
if path: self.path = path
|
||||
if self.path:
|
||||
from ._imagedata import ImageData
|
||||
self._ti_path = os.path.abspath(os.fspath(self.path))
|
||||
self._ti_data = ImageData(self._ti_path, remote_source=remote_source)
|
||||
|
||||
if self._ti_data.ok:
|
||||
# Register with the image store (de-duplicates identical data
|
||||
# and allocates the terminal-side image id on first use).
|
||||
self._ti_key = self._ti_data.key
|
||||
image_store.register(self._ti_key, self._ti_data.data)
|
||||
self._invalidate()
|
||||
else:
|
||||
error_msg = self._ti_data.error or "Unknown error"
|
||||
self.notice(f"Error: {error_msg}")
|
||||
|
||||
def notice(self, msg=""):
|
||||
if not msg: self._notice = None
|
||||
else: self._notice = str(msg)
|
||||
self._invalidate()
|
||||
|
||||
def __del__(self):
|
||||
try: type(self)._ti_free_z.add(self._ti_z)
|
||||
except Exception: pass
|
||||
try: self.close()
|
||||
except Exception: pass
|
||||
|
||||
def close(self):
|
||||
# Releases the widget's reference to its image data.
|
||||
# The data is evicted from the terminal by the screen as soon as no
|
||||
# live widget references it any more. Safe to call multiple times and
|
||||
# from __del__.
|
||||
try:
|
||||
if not self._ti_closed:
|
||||
self._ti_closed = True
|
||||
if self._ti_key is not None: image_store.unregister(self._ti_key)
|
||||
except Exception: pass
|
||||
|
||||
@classmethod
|
||||
def _ti_alloc_z(cls):
|
||||
if cls._ti_free_z: return cls._ti_free_z.pop()
|
||||
z = cls._ti_next_z
|
||||
if z >= MAX_Z_INDEX: raise RuntimeError("Too many image widgets")
|
||||
cls._ti_next_z += 1
|
||||
return z
|
||||
|
||||
##########
|
||||
# Sizing #
|
||||
##########
|
||||
|
||||
width = property(
|
||||
lambda self: self._ti_width,
|
||||
doc="""Display width specification: None (full width), a positive
|
||||
int (columns), a percent string ("NN%") of the available layout
|
||||
width, or "n" for native resolution.""",
|
||||
)
|
||||
|
||||
@width.setter
|
||||
def width(self, value):
|
||||
self._ti_width = _check_size_spec(value, "width")
|
||||
self._invalidate()
|
||||
|
||||
height = property(
|
||||
lambda self: self._ti_height,
|
||||
doc="""Display height specification: None (derived, aspect
|
||||
preserving), a positive int (rows), a percent string ("NN%") of
|
||||
the visible page height, or "n" for native resolution.""",
|
||||
)
|
||||
|
||||
@height.setter
|
||||
def height(self, value):
|
||||
self._ti_height = _check_size_spec(value, "height")
|
||||
self._invalidate()
|
||||
|
||||
align = property(
|
||||
lambda self: self._ti_align,
|
||||
doc="""Horizontal alignment of the image within its allocated
|
||||
space: ``"<"`` (left), ``"|"`` (center) or ``">"`` (right).""",
|
||||
)
|
||||
|
||||
@align.setter
|
||||
def align(self, value):
|
||||
self._ti_align = _check_align(value)
|
||||
self._invalidate()
|
||||
|
||||
def _short(self): return "img#%x" % (id(self) & 0xFFFFF)
|
||||
|
||||
def _display_size(self, maxcol, maxrow=None):
|
||||
if not self._ti_data.ok: return (min(maxcol, 1), 1)
|
||||
iw, ih = self._ti_data.size
|
||||
cell_w, cell_h = _termlib.get_cell_size() or DEFAULT_CELL_SIZE
|
||||
w_spec, h_spec = self._ti_width, self._ti_height
|
||||
|
||||
def _pct(value): return float(value[:-1]) / 100.0
|
||||
|
||||
def _raw_width():
|
||||
if w_spec is None: return float(maxcol)
|
||||
if isinstance(w_spec, int): return float(w_spec)
|
||||
if w_spec == "n": return iw / cell_w
|
||||
return maxcol * _pct(w_spec)
|
||||
|
||||
def _raw_height():
|
||||
if h_spec is None: return None
|
||||
if isinstance(h_spec, int): return float(h_spec)
|
||||
if h_spec == "n": return ih / cell_h
|
||||
columns, lines = _termlib.get_terminal_size()
|
||||
page = max(1, lines - UI_MARGIN)
|
||||
return page * _pct(h_spec)
|
||||
|
||||
def _height_for_width(cols): return cols * cell_w * ih / (cell_h * iw)
|
||||
def _width_for_height(rows): return rows * cell_h * iw / (cell_w * ih)
|
||||
|
||||
if w_spec is not None and h_spec is not None:
|
||||
# Stretch mode: Target rectangle, uniformly scaled down only if
|
||||
# it would overflow the available space.
|
||||
target_w = max(1.0, _raw_width())
|
||||
target_h = max(1.0, _raw_height())
|
||||
scale = min(1.0, maxcol / target_w)
|
||||
if maxrow is not None: scale = min(scale, maxrow / target_h)
|
||||
cols = max(1, round(target_w * scale))
|
||||
rows = max(1, round(target_h * scale))
|
||||
elif w_spec is not None:
|
||||
# Width given: Height derived, aspect preserving.
|
||||
cols = max(1, min(maxcol, round(_raw_width())))
|
||||
rows = max(1, round(_height_for_width(cols)))
|
||||
if maxrow is not None and rows > maxrow:
|
||||
rows = maxrow
|
||||
cols = max(1, min(maxcol, round(_width_for_height(rows))))
|
||||
elif h_spec is not None:
|
||||
# Height given: Width derived, aspect preserving.
|
||||
rows = max(1, round(_raw_height()))
|
||||
cols = max(1, min(maxcol, round(_width_for_height(rows))))
|
||||
if maxrow is not None and rows > maxrow:
|
||||
rows = maxrow
|
||||
cols = max(1, min(maxcol, round(_width_for_height(rows))))
|
||||
else:
|
||||
# Default: Full width, height derived.
|
||||
cols = maxcol
|
||||
rows = max(1, round(_height_for_width(cols)))
|
||||
if maxrow is not None and rows > maxrow:
|
||||
rows = maxrow
|
||||
cols = max(1, min(maxcol, round(_width_for_height(rows))))
|
||||
|
||||
return (cols, rows)
|
||||
|
||||
def rows(self, size, focus=False):
|
||||
maxcol = size[0]
|
||||
if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()):
|
||||
return len(self._placeholder_lines())
|
||||
n = self._display_size(maxcol)[1]
|
||||
return n
|
||||
|
||||
def render(self, size, focus=False):
|
||||
if len(size) == 2:
|
||||
maxcol, maxrow = size
|
||||
is_box = True
|
||||
else:
|
||||
maxcol, maxrow = size[0], None
|
||||
is_box = False
|
||||
|
||||
if not (self._ti_data and self._ti_data.ok and _termlib.is_kitty_supported()):
|
||||
return self._placeholder_canvas(maxcol, maxrow, focus)
|
||||
|
||||
cols, rows = self._display_size(maxcol, maxrow)
|
||||
self._ti_cols, self._ti_rows = cols, rows
|
||||
# urwid requires canvases to fill the allocated space exactly
|
||||
# (width == maxcol for FLOW, (maxcol, maxrow) for BOX). The image
|
||||
# itself is placed by the screen at its resolved size inside the
|
||||
# canvas, left-aligned; the rest of the canvas stays blank.
|
||||
if is_box: return ImageCanvas(maxcol, maxrow)
|
||||
else: return ImageCanvas(maxcol, rows)
|
||||
|
||||
#########################
|
||||
# Placeholder rendering #
|
||||
#########################
|
||||
|
||||
def _placeholder_lines(self):
|
||||
return [f"{self.placeholder_glyph}{self._notice or self._ti_name}"]
|
||||
|
||||
def _placeholder_canvas(self, maxcol, maxrow, focus):
|
||||
align = {"<": "left", "|": "center", ">": "right"}.get(self._ti_align, "center")
|
||||
text = urwid.Text("\n".join(self._placeholder_lines()), align=align)
|
||||
if maxrow is not None: return urwid.Filler(text).render((maxcol, maxrow), focus)
|
||||
else: return text.render((maxcol,), focus)
|
||||
|
||||
|
||||
class ImageCanvas(urwid.SolidCanvas):
|
||||
# A blank canvas marking the space occupied by an image.
|
||||
# Urwid sets _widget_info on this canvas to the owning
|
||||
# ImageWidget. ImageScreen finds image canvases through
|
||||
# that reference during its shard scan.
|
||||
def __init__(self, cols, rows): super().__init__(" ", cols, rows)
|
||||
51
nomadnet/vendor/Scrollable.py
vendored
51
nomadnet/vendor/Scrollable.py
vendored
|
|
@ -9,6 +9,7 @@
|
|||
# GNU General Public License for more details
|
||||
# http://www.gnu.org/licenses/gpl-3.0.txt
|
||||
|
||||
import RNS
|
||||
import time
|
||||
import urwid
|
||||
from urwid.widget import BOX, FIXED, FLOW
|
||||
|
|
@ -83,6 +84,10 @@ class Scrollable(urwid.WidgetDecoration):
|
|||
|
||||
if canv_cols <= maxcol and canv_rows <= maxrow:
|
||||
# Canvas is small enough to fit without trimming
|
||||
if self._trim_top != 0 or self._scroll_action is not None:
|
||||
# Reset potentially stale _trim_top
|
||||
self._trim_top = 0
|
||||
self._scroll_action = None
|
||||
return canv
|
||||
|
||||
self._adjust_trim_top(canv, size)
|
||||
|
|
@ -367,6 +372,11 @@ class ScrollBar(urwid.WidgetDecoration):
|
|||
self._sevt_interval = 0
|
||||
self._sevt_start = 0
|
||||
self._sfreq_deque = deque(maxlen=5)
|
||||
|
||||
self._debug_hz = 0
|
||||
self._debug_hz_smoothed = 0
|
||||
self._debug_scroll_ms = 0
|
||||
self._debug_scroll_started = 0
|
||||
|
||||
def render(self, size, focus=False):
|
||||
maxcol, maxrow = size
|
||||
|
|
@ -386,11 +396,24 @@ class ScrollBar(urwid.WidgetDecoration):
|
|||
ow_rows_max = ow_base.rows_max(ow_size, focus)
|
||||
|
||||
ow_canv = ow.render(ow_size, focus)
|
||||
|
||||
if self._debug_scroll_started:
|
||||
total_ms = (time.time() - self._debug_scroll_started) * 1000
|
||||
# RNS.log(f" accel={self._accel} , hz={RNS.prettyfrequency(self._debug_hz)} smoothed={RNS.prettyfrequency(self._debug_hz_smoothed)} scroll={self._debug_scroll_ms:.1f}ms total={total_ms:.1f}ms", RNS.LOG_DEBUG)
|
||||
self._debug_scroll_started = 0
|
||||
|
||||
self._original_widget_size = ow_size
|
||||
|
||||
pos = ow_base.get_scrollpos(ow_size, focus)
|
||||
posmax = ow_rows_max - maxrow
|
||||
|
||||
# Clamp position to valid scroll range. The position
|
||||
# reported by the underlying widget can transiently
|
||||
# fall outside [0, posmax] if its layout state is
|
||||
# inconsistent.
|
||||
if pos < 0: pos = 0
|
||||
elif pos > posmax: pos = posmax
|
||||
|
||||
# Thumb shrinks/grows according to the ratio of
|
||||
# <number of visible lines> / <number of total lines>
|
||||
thumb_weight = min(1, maxrow / max(1, ow_rows_max))
|
||||
|
|
@ -402,9 +425,12 @@ class ScrollBar(urwid.WidgetDecoration):
|
|||
if top_height == 0 and top_weight > 0:
|
||||
top_height = 1
|
||||
|
||||
# Bottom part is remaining space
|
||||
# Bottom part is remaining space. Guard against
|
||||
# negative values so the scrollbar canvas can
|
||||
# never be taller than maxrow.
|
||||
bottom_height = maxrow - thumb_height - top_height
|
||||
assert thumb_height + top_height + bottom_height == maxrow
|
||||
if bottom_height < 0: bottom_height = 0
|
||||
assert thumb_height + top_height + bottom_height <= maxrow
|
||||
|
||||
# Create scrollbar canvas
|
||||
# Creating SolidCanvases of correct height may result in "cviews do not
|
||||
|
|
@ -423,10 +449,6 @@ class ScrollBar(urwid.WidgetDecoration):
|
|||
combinelist = [(ow_canv, None, True, ow_size[0]),
|
||||
(sb_canv, None, False, sb_width)]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
self._sb_visible = True
|
||||
self._sb_thumb_height = thumb_height
|
||||
self._sb_ow_rows_max = ow_rows_max
|
||||
|
|
@ -527,23 +549,22 @@ class ScrollBar(urwid.WidgetDecoration):
|
|||
sevt_freq = 1/self._sevt_interval
|
||||
self._sfreq_deque.append(sevt_freq)
|
||||
freq_smth = sum(self._sfreq_deque)/len(self._sfreq_deque)
|
||||
self._debug_hz = sevt_freq
|
||||
self._debug_hz_smoothed = freq_smth
|
||||
if len(self._sfreq_deque) >= self._sfreq_deque.maxlen:
|
||||
if freq_smth > 25.0: self._accel = max(self._accel, 3)
|
||||
elif freq_smth > 12.0: self._accel = max(self._accel, 2)
|
||||
else: self._accel = max(self._accel, 1)
|
||||
|
||||
# import RNS; RNS.log(f"a={self._accel} s={RNS.prettyfrequency(freq_smth)} f={RNS.prettyfrequency(sevt_freq)}", RNS.LOG_CRITICAL)
|
||||
|
||||
step = self._accel
|
||||
if button == 4: # scroll wheel up
|
||||
if button in (4, 5):
|
||||
scroll_started = time.time()
|
||||
pos = ow.get_scrollpos(ow_size)
|
||||
newpos = pos - step
|
||||
if newpos < 0: newpos = 0
|
||||
if button == 4: newpos = max(0, pos - step)
|
||||
else: newpos = pos + step
|
||||
ow.set_scrollpos(newpos)
|
||||
return True
|
||||
elif button == 5: # scroll wheel down
|
||||
pos = ow.get_scrollpos(ow_size)
|
||||
ow.set_scrollpos(pos + step)
|
||||
self._debug_scroll_started = scroll_started
|
||||
self._debug_scroll_ms = (time.time() - scroll_started) * 1000
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
3
setup.py
3
setup.py
|
|
@ -13,6 +13,7 @@ if "--getversion" in sys.argv:
|
|||
package_data = {
|
||||
"": [
|
||||
"examples/messageboard/*",
|
||||
"ui/textui/images/demo.webp",
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +36,6 @@ setuptools.setup(
|
|||
entry_points= {
|
||||
'console_scripts': ['nomadnet=nomadnet.nomadnet:main']
|
||||
},
|
||||
install_requires=["rns>=1.5.2", "lxmf>=1.1.1", "urwid>=3.0.5", "qrcode"],
|
||||
install_requires=["rns>=1.5.5", "lxmf>=1.2.0", "urwid>=3.0.5", "qrcode"],
|
||||
python_requires=">=3.8",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue