mirror of
https://github.com/1j01/textual-paint.git
synced 2024-09-11 23:57:12 +03:00
Inspect properties of DOM nodes
This commit is contained in:
parent
6b97dfc165
commit
dba6721164
@ -65,6 +65,7 @@
|
|||||||
"pypixelart",
|
"pypixelart",
|
||||||
"pyright",
|
"pyright",
|
||||||
"pyxdg",
|
"pyxdg",
|
||||||
|
"qualname",
|
||||||
"rects",
|
"rects",
|
||||||
"renderable",
|
"renderable",
|
||||||
"rmam",
|
"rmam",
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
"""Layout inspector development tool for Textual."""
|
"""Layout inspector development tool for Textual."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, Iterable, NamedTuple, TypeGuard
|
from typing import Any, Iterable, NamedTuple, Optional, TypeGuard
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
from rich.highlighter import ReprHighlighter
|
||||||
|
# from rich.syntax import Syntax
|
||||||
from textual import events
|
from textual import events
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.color import Color
|
from textual.color import Color
|
||||||
@ -11,8 +13,9 @@ from textual.dom import DOMNode
|
|||||||
from textual.errors import NoWidget
|
from textual.errors import NoWidget
|
||||||
from textual.geometry import Offset
|
from textual.geometry import Offset
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
|
from textual.reactive import var
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.widgets import Button, Tree
|
from textual.widgets import Button, Label, Static, Tree
|
||||||
from textual.widgets.tree import TreeNode
|
from textual.widgets.tree import TreeNode
|
||||||
from textual.css._style_properties import BorderDefinition
|
from textual.css._style_properties import BorderDefinition
|
||||||
|
|
||||||
@ -52,6 +55,30 @@ class DOMTree(Tree[DOMNode]):
|
|||||||
# """
|
# """
|
||||||
# return self.tree
|
# return self.tree
|
||||||
|
|
||||||
|
class Selected(Message, bubble=True):
|
||||||
|
"""Posted when a node in the tree is selected.
|
||||||
|
|
||||||
|
Handled by defining a `on_domtree_selected` method on a parent widget.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, tree: "DOMTree", tree_node: TreeNode[DOMNode], dom_node: DOMNode
|
||||||
|
) -> None:
|
||||||
|
"""Initialise the Selected message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tree: The `DOMTree` that had a node selected.
|
||||||
|
tree_node: The tree node for the file that was selected.
|
||||||
|
dom_node: The DOM node that was selected.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.tree: DOMTree = tree
|
||||||
|
"""The `DOMTree` that had a node selected."""
|
||||||
|
self.tree_node: TreeNode[DOMNode] = tree_node
|
||||||
|
"""The tree node that was selected. Only _represents_ the DOM node."""
|
||||||
|
self.dom_node: DOMNode = dom_node
|
||||||
|
"""The DOM node that was selected."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
root: DOMNode,
|
root: DOMNode,
|
||||||
@ -99,6 +126,14 @@ class DOMTree(Tree[DOMNode]):
|
|||||||
return
|
return
|
||||||
self.post_message(self.Hovered(self, event.node, dom_node))
|
self.post_message(self.Hovered(self, event.node, dom_node))
|
||||||
|
|
||||||
|
def _on_tree_node_selected(self, event: Tree.NodeSelected[DOMNode]) -> None:
|
||||||
|
"""Called when a node is selected with the mouse or keyboard."""
|
||||||
|
event.stop()
|
||||||
|
dom_node = event.node.data
|
||||||
|
if dom_node is None:
|
||||||
|
return
|
||||||
|
self.post_message(self.Selected(self, event.node, dom_node))
|
||||||
|
|
||||||
def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None:
|
def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None:
|
||||||
"""Extend the hover line watcher to post a message when a node is hovered."""
|
"""Extend the hover line watcher to post a message when a node is hovered."""
|
||||||
super().watch_hover_line(previous_hover_line, hover_line)
|
super().watch_hover_line(previous_hover_line, hover_line)
|
||||||
@ -109,6 +144,150 @@ class DOMTree(Tree[DOMNode]):
|
|||||||
# TODO: post when None? it seems to be reset anyways? but not if you move the mouse off the whole tree without moving it off a node
|
# TODO: post when None? it seems to be reset anyways? but not if you move the mouse off the whole tree without moving it off a node
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInfo(Container):
|
||||||
|
|
||||||
|
dom_node: var[DOMNode | None] = var[Optional[DOMNode]](None)
|
||||||
|
"""The DOM node being inspected."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Add sub-widgets."""
|
||||||
|
yield Label("[b]Properties[/b]")
|
||||||
|
yield Tree("", classes="properties")
|
||||||
|
yield Label("[b]Styles[/b]")
|
||||||
|
yield Static(classes="styles")
|
||||||
|
yield Label("[b]Key Bindings[/b]")
|
||||||
|
yield Static(classes="key_bindings")
|
||||||
|
yield Label("[b]Events[/b]")
|
||||||
|
yield Static(classes="events")
|
||||||
|
|
||||||
|
def watch_dom_node(self, dom_node: DOMNode | None) -> None:
|
||||||
|
"""Update the info displayed when the DOM node changes."""
|
||||||
|
print("watch_dom_node", dom_node)
|
||||||
|
properties_tree = self.query_one(".properties", Tree)
|
||||||
|
styles_static = self.query_one(".styles", Static)
|
||||||
|
key_bindings_static = self.query_one(".key_bindings", Static)
|
||||||
|
events_static = self.query_one(".events", Static)
|
||||||
|
|
||||||
|
if dom_node is None:
|
||||||
|
properties_tree.reset("Nothing selected", None)
|
||||||
|
styles_static.update("Nothing selected")
|
||||||
|
key_bindings_static.update("Nothing selected")
|
||||||
|
events_static.update("Nothing selected")
|
||||||
|
return
|
||||||
|
|
||||||
|
properties_tree.reset("", dom_node)
|
||||||
|
self.add_data(properties_tree.root, dom_node)
|
||||||
|
|
||||||
|
# styles_static.update(dom_node.css_tree)
|
||||||
|
# styles_static.update(dom_node._css_styles.css)
|
||||||
|
styles_static.update(dom_node.styles.css)
|
||||||
|
# styles_static.update(Syntax(f"all styles {{\n{dom_node.styles.css}\n}}", "css"))
|
||||||
|
|
||||||
|
# key_bindings_static.update("\n".join(map(repr, dom_node.BINDINGS)) or "(None defined with BINDINGS)")
|
||||||
|
highlighter = ReprHighlighter()
|
||||||
|
key_bindings_static.update(Text("\n").join(map(lambda binding: highlighter(repr(binding)), dom_node.BINDINGS)) or "(None defined with BINDINGS)")
|
||||||
|
|
||||||
|
# For events, look for class properties that are subclasses of Message
|
||||||
|
# to determine what events are available.
|
||||||
|
available_events = []
|
||||||
|
for cls in type(dom_node).__mro__:
|
||||||
|
for name, value in cls.__dict__.items():
|
||||||
|
if isinstance(value, type) and issubclass(value, Message):
|
||||||
|
available_events.append(value)
|
||||||
|
if available_events:
|
||||||
|
events_static.update("\n".join(map(lambda message_class: message_class.__qualname__, available_events)))
|
||||||
|
# events_static.update("\n".join(map(repr, available_events)))
|
||||||
|
else:
|
||||||
|
events_static.update(f"(No message types exported by {type(dom_node).__name__!r} or its superclasses)")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_data(cls, node: TreeNode, data: object) -> None:
|
||||||
|
"""Adds data to a node.
|
||||||
|
|
||||||
|
Based on https://github.com/Textualize/textual/blob/65b0c34f2ed6a69795946a0735a51a463602545c/examples/json_tree.py
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node (TreeNode): A Tree node.
|
||||||
|
data (object): Any object ideally should work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
highlighter = ReprHighlighter()
|
||||||
|
|
||||||
|
# uses equality, not (just) identity; is that a problem?
|
||||||
|
# visited: set[object] = set()
|
||||||
|
# well lists aren't hashable so we can't use a set
|
||||||
|
# visited: list[object] = []
|
||||||
|
# but the in operator still uses equality, right? so the question stands
|
||||||
|
# P.S. might want both a set and a list, for performance (for hashable and non-hashable types)
|
||||||
|
|
||||||
|
max_depth = 3
|
||||||
|
max_keys_per_level = 100
|
||||||
|
def key_filter(key: str) -> bool:
|
||||||
|
# TODO: allow toggling filtering of private properties
|
||||||
|
# (or show in a collapsed node)
|
||||||
|
return not key.startswith("_")
|
||||||
|
|
||||||
|
def add_node(name: str, node: TreeNode, data: object, depth: int = 0, visited: tuple = ()) -> None:
|
||||||
|
"""Adds a node to the tree.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the node.
|
||||||
|
node (TreeNode): Parent node.
|
||||||
|
data (object): Data associated with the node.
|
||||||
|
depth (int, optional): Depth of recursion. Defaults to 0.
|
||||||
|
visited (tuple, optional): Objects visited in this branch. Defaults to ().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def with_name(text: Text) -> Text:
|
||||||
|
return Text.assemble(
|
||||||
|
Text.from_markup(f"[b]{name}[/b]="), text
|
||||||
|
)
|
||||||
|
|
||||||
|
if depth > max_depth:
|
||||||
|
node.allow_expand = False
|
||||||
|
node.set_label(with_name(Text.from_markup("[i]<max depth>[/i]")))
|
||||||
|
return
|
||||||
|
if data in visited:
|
||||||
|
node.allow_expand = False
|
||||||
|
node.set_label(with_name(Text.from_markup("[i]<cyclic reference>[/i]")))
|
||||||
|
return
|
||||||
|
# visited.append(data)
|
||||||
|
if isinstance(data, list):
|
||||||
|
node.set_label(Text(f"[] {name}"))
|
||||||
|
for index, value in enumerate(data):
|
||||||
|
new_node = node.add("")
|
||||||
|
add_node(str(index), new_node, value, depth + 1, visited + (data,))
|
||||||
|
if index >= max_keys_per_level:
|
||||||
|
# TODO: load more on click
|
||||||
|
node.add("...").allow_expand = False
|
||||||
|
break
|
||||||
|
elif isinstance(data, str) or isinstance(data, int) or isinstance(data, float) or isinstance(data, bool):
|
||||||
|
node.allow_expand = False
|
||||||
|
if name:
|
||||||
|
label = with_name(highlighter(repr(data)))
|
||||||
|
else:
|
||||||
|
label = Text(repr(data))
|
||||||
|
node.set_label(label)
|
||||||
|
elif hasattr(data, "__dict__"):
|
||||||
|
node.set_label(Text(f"{{}} {name}"))
|
||||||
|
index = 0
|
||||||
|
for key, value in data.__dict__.items():
|
||||||
|
if not key_filter(key):
|
||||||
|
continue
|
||||||
|
new_node = node.add("")
|
||||||
|
add_node(str(key), new_node, value, depth + 1, visited + (data,))
|
||||||
|
index += 1
|
||||||
|
if index >= max_keys_per_level:
|
||||||
|
# TODO: load more on click
|
||||||
|
node.add("...").allow_expand = False
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
node.allow_expand = False
|
||||||
|
node.set_label(with_name(Text(repr(data))))
|
||||||
|
|
||||||
|
add_node("Properties", node, data)
|
||||||
|
|
||||||
|
|
||||||
class OriginalStyles(NamedTuple):
|
class OriginalStyles(NamedTuple):
|
||||||
"""The original styles of a widget before highlighting."""
|
"""The original styles of a widget before highlighting."""
|
||||||
|
|
||||||
@ -133,6 +312,7 @@ class Inspector(Container):
|
|||||||
width: 40;
|
width: 40;
|
||||||
border-left: wide $panel-darken-2;
|
border-left: wide $panel-darken-2;
|
||||||
background: $panel;
|
background: $panel;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
Inspector Button {
|
Inspector Button {
|
||||||
margin: 1;
|
margin: 1;
|
||||||
@ -140,6 +320,15 @@ class Inspector(Container):
|
|||||||
}
|
}
|
||||||
Inspector Tree {
|
Inspector Tree {
|
||||||
margin: 1;
|
margin: 1;
|
||||||
|
max-height: 20;
|
||||||
|
height: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
Inspector NodeInfo {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
Inspector Static {
|
||||||
|
margin-bottom: 1;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -162,6 +351,7 @@ class Inspector(Container):
|
|||||||
yield Button(f"{inspect_icon} Inspect Element", classes="inspect_button")
|
yield Button(f"{inspect_icon} Inspect Element", classes="inspect_button")
|
||||||
yield Button(f"{expand_icon} Expand All Visible", classes="expand_all_button")
|
yield Button(f"{expand_icon} Expand All Visible", classes="expand_all_button")
|
||||||
yield DOMTree(self.app)
|
yield DOMTree(self.app)
|
||||||
|
yield NodeInfo()
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle a button being clicked."""
|
"""Handle a button being clicked."""
|
||||||
@ -222,12 +412,19 @@ class Inspector(Container):
|
|||||||
# Select the widget in the tree.
|
# Select the widget in the tree.
|
||||||
tree.select_node(tree_node)
|
tree.select_node(tree_node)
|
||||||
tree.scroll_to_node(tree_node)
|
tree.scroll_to_node(tree_node)
|
||||||
|
tree.action_select_cursor()
|
||||||
|
|
||||||
|
def on_domtree_selected(self, event: DOMTree.Selected) -> None:
|
||||||
|
"""Handle a node being selected in the DOM tree."""
|
||||||
|
print("Inspecting DOM node:", event.dom_node)
|
||||||
|
self.query_one(NodeInfo).dom_node = event.dom_node
|
||||||
|
|
||||||
def on_domtree_hovered(self, event: DOMTree.Hovered) -> None:
|
def on_domtree_hovered(self, event: DOMTree.Hovered) -> None:
|
||||||
"""Handle a DOM node being hovered/highlighted."""
|
"""Handle a DOM node being hovered/highlighted."""
|
||||||
self.highlight(event.dom_node)
|
self.highlight(event.dom_node)
|
||||||
|
|
||||||
def reset_highlight(self, except_widgets: Iterable[Widget] = ()) -> None:
|
def reset_highlight(self, except_widgets: Iterable[Widget] = ()) -> None:
|
||||||
|
"""Reset the highlight."""
|
||||||
if self._highlight is not None:
|
if self._highlight is not None:
|
||||||
self._highlight.remove()
|
self._highlight.remove()
|
||||||
for widget, old in list(self._highlight_styles.items()):
|
for widget, old in list(self._highlight_styles.items()):
|
||||||
@ -240,6 +437,7 @@ class Inspector(Container):
|
|||||||
del self._highlight_styles[widget]
|
del self._highlight_styles[widget]
|
||||||
|
|
||||||
def is_list_of_widgets(self, value: Any) -> TypeGuard[list[Widget]]:
|
def is_list_of_widgets(self, value: Any) -> TypeGuard[list[Widget]]:
|
||||||
|
"""Test whether a value is a list of widgets. The TypeGuard tells the type checker that this function ensures the type."""
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return False
|
return False
|
||||||
for item in value: # type: ignore
|
for item in value: # type: ignore
|
||||||
|
Loading…
Reference in New Issue
Block a user