From 144f8641c3ba7ade38038bdee0bd0eb49027a9bc Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Sat, 31 Oct 2026 15:52:33 -0500 Subject: [PATCH] feat(sdk): give a panel tree its nodes and choose one Closes #2 --- README.md | 8 +++--- plugin/rustgis_plugin.py | 57 +++++++++++++++++++++++++++++++++++++--- tests/test_sdk.py | 28 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6190e0b..7060947 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,16 @@ appears in RustGIS: |---|---|---| | [`commands`](starters/commands) (the default) | buttons in a group named after the plugin on the **Plugins** tab | reading the map, running RustGIS commands, adding a layer, translated messages | | [`tab`](starters/tab) | a ribbon tab of its own, with groups, a dropdown menu and a tool button | `tab` in `plugin.json`, moving the map, a tool opened from the ribbon | -| [`panel`](starters/panel) | a pane beside the map, opened from the **Plugins** tab | `panel` in `plugin.json`: a slider, a checkbox, texts and buttons bound to commands, and the state the plugin answers | +| [`panel`](starters/panel) | a pane beside the map, opened from the **Plugins** tab | `panel` in `plugin.json`: a slider, a checkbox, texts and buttons bound to commands, and the state the plugin answers (a `tree` of nodes it gives too) | | [`tools`](starters/tools) | geoprocessing tools in the **Tools** pane, under **Plugins ›** | tool parameters (layer, field, choice, number, bool), new layers and a selection, made with Shapely from `requirements.txt` | The sections are independent: one plugin can have commands, tools, a tab and a panel. Start from one starter and copy what you need from the others. Panels need a RustGIS newer than 0.3.0. Older versions ignore `panel`, and the panel starter's -commands still appear on the Plugins tab. +commands still appear on the Plugins tab. A panel `tree` (nodes the plugin gives with +`Panel().nodes(...)` and `Node`, the chosen one's id in `req.panel`) needs a RustGIS newer than +0.4.0, which refuses a manifest with one. ## Quick start @@ -51,7 +53,7 @@ Everything RustGIS runs is in [`plugin/`](plugin): |---|---| | `plugin.json` | the manifest: id, version, permissions, commands, tools, tab, panel, translations, and `runtime`, `python` and `run` | | `main.py` | the plugin | -| `rustgis_plugin.py` | the SDK: the protocol, `host` requests, `Request`, `ToolOutput`, `Panel` (standard library only) | +| `rustgis_plugin.py` | the SDK: the protocol, `host` requests, `Request`, `ToolOutput`, `Panel`, `Node` (standard library only) | | `requirements.txt` | the packages the plugin needs | Put any other modules or data files the plugin needs in `plugin/` as well; the package holds diff --git a/plugin/rustgis_plugin.py b/plugin/rustgis_plugin.py index 6861f6f..0aed30a 100644 --- a/plugin/rustgis_plugin.py +++ b/plugin/rustgis_plugin.py @@ -26,11 +26,12 @@ def handle(req: Request): import base64 import json import sys -from collections.abc import Callable +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field from pathlib import Path from typing import Any -__all__ = ["ABI", "Panel", "PluginError", "Request", "ToolOutput", "geojson", "host", "serve"] +__all__ = ["ABI", "Node", "Panel", "PluginError", "Request", "ToolOutput", "geojson", "host", "serve"] #: The plugin ABI this SDK speaks. plugin.json's `abi`, when given, must match it. ABI = 1 @@ -381,6 +382,39 @@ def to_dict(self) -> dict: return out +@dataclass +class Node: + """A node of a panel tree: a theme, a dataset... + + Its id is the tree's value when it is chosen: + + env = Node("env", "Environment", count=2, children=[Node("parks", "Parks"), Node("rivers", "Rivers")]) + return Panel().nodes("catalogue", [env, Node("roads", "Roads")]).answer() + """ + + #: What `req.panel.get(tree)` gives when this node is chosen. + id: str + #: The text the tree shows. + label: str + #: A number shown beside the label (the datasets in a theme, say); None shows none. + count: int | None = None + #: The nodes under this one. + children: list[Node] = field(default_factory=list) + + def to_dict(self) -> dict: + """The node, as RustGIS reads it: `count` and `children` only when it has them. + + Returns: + `{"id", "label", "count"?, "children"?}`. + """ + out: dict[str, Any] = {"id": self.id, "label": self.label} + if self.count is not None: + out["count"] = self.count + if self.children: + out["children"] = [c.to_dict() for c in self.children] + return out + + class Panel: """A panel's new state, which any command of a plugin with a panel can answer. @@ -393,8 +427,9 @@ def __init__(self) -> None: self.values: dict[str, Any] = {} self.items: dict[str, dict] = {} - def value(self, widget: str, value: bool | int) -> Panel: - """Sets a checkbox (`True`/`False`) or a slider (a whole number). + def value(self, widget: str, value: bool | int | str | None) -> Panel: + """Sets a checkbox (`True`/`False`), a slider (a whole number) or a tree (a node's id, or + `None` for no node chosen). Args: widget: The widget's id. @@ -471,6 +506,20 @@ def slider(self, widget: str, lo: int, hi: int, labels: list[str] | None = None) fields["labels"] = labels return self._item(widget, **fields) + def nodes(self, widget: str, nodes: Iterable[Node]) -> Panel: + """Gives a tree its nodes, replacing the ones it had. RustGIS keeps at most 5,000 nodes, 8 + levels deep, and leaves out a node without an id, or whose id is taken already (with its + children). + + Args: + widget: The tree's id. + nodes: Its top nodes, with their children. + + Returns: + This state. + """ + return self._item(widget, nodes=[n.to_dict() for n in nodes]) + def _item(self, widget: str, **fields: Any) -> Panel: self.items.setdefault(widget, {}).update(fields) return self diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 18f8410..9905676 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -104,6 +104,34 @@ def test_tool_output_and_panel_state(): } +def test_panel_trees_take_nodes_and_give_the_chosen_one(): + req = rp.Request({"panel": {"values": {"catalogue": "parks", "empty": None}, "changed": ["catalogue"]}}) + assert (req.panel.get("catalogue"), req.panel.get("empty"), req.panel.get("gone")) == ("parks", None, None) + + env = rp.Node("env", "Environment", count=2, children=[rp.Node("parks", "Parks"), rp.Node("rivers", "Rivers", count=0)]) + p = rp.Panel().nodes("catalogue", [env, rp.Node("roads", "Roads")]).value("catalogue", "parks") + assert p.to_dict() == { + "values": {"catalogue": "parks"}, + "items": { + "catalogue": { + "nodes": [ + { + "id": "env", + "label": "Environment", + "count": 2, + "children": [{"id": "parks", "label": "Parks"}, {"id": "rivers", "label": "Rivers", "count": 0}], + }, + {"id": "roads", "label": "Roads"}, + ] + } + }, + } + assert rp.Panel().nodes("catalogue", []).value("catalogue", None).to_dict() == {"values": {"catalogue": None}, "items": {"catalogue": {"nodes": []}}} + # Nodes replace the ones the tree had; other fields of the widget stay. + p = rp.Panel().label("catalogue", "Data").nodes("catalogue", [env]).nodes("catalogue", iter([rp.Node("roads", "Roads")])) + assert p.items == {"catalogue": {"label": "Data", "nodes": [{"id": "roads", "label": "Roads"}]}} + + def test_geojson_helpers(): assert rp.geojson.polygon([[0, 0], [1, 0], [1, 1]])["coordinates"][0][-1] == [0, 0] multi = {"type": "MultiPolygon", "coordinates": [[[[0, 0], [1, 0], [0, 1], [0, 0]]]]}