From 759991e9022b8d0c0e5a5540aa4e5cf255ee9c31 Mon Sep 17 00:00:00 2001 From: Isaiah Odhner Date: Thu, 25 May 2023 23:13:04 -0400 Subject: [PATCH] Add DOM inspector panel --- cspell.json | 1 + src/textual_paint/inspector.py | 285 +++++++++++++++++++++++++++++++++ src/textual_paint/paint.py | 10 +- 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 src/textual_paint/inspector.py diff --git a/cspell.json b/cspell.json index 71f0f3a..0bb0c34 100644 --- a/cspell.json +++ b/cspell.json @@ -33,6 +33,7 @@ "dasharray", "Deutsch", "DIALOGEX", + "domtree", "Figlet", "Fira", "fudgedness", diff --git a/src/textual_paint/inspector.py b/src/textual_paint/inspector.py new file mode 100644 index 0000000..451e328 --- /dev/null +++ b/src/textual_paint/inspector.py @@ -0,0 +1,285 @@ +"""Layout inspector development tool for Textual.""" + +import asyncio +from typing import NamedTuple +from rich.text import Text +from textual import events +from textual.app import ComposeResult +from textual.color import Color +from textual.containers import Container +from textual.dom import DOMNode +from textual.geometry import Offset +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Button, Tree +from textual.widgets.tree import TreeNode +from textual.css._style_properties import BorderDefinition + +class DOMTree(Tree[DOMNode]): + """A widget that displays the widget hierarchy.""" + + class Hovered(Message, bubble=True): + """Posted when a node in the tree is hovered with the mouse or highlighted with the keyboard. + + Handled by defining a `on_domtree_hovered` method on a parent widget. + """ + + def __init__( + self, tree: "DOMTree", tree_node: TreeNode[DOMNode], dom_node: DOMNode + ) -> None: + """Initialise the Hovered message. + + Args: + tree: The `DOMTree` that had a node hovered. + tree_node: The tree node for the file that was hovered. + dom_node: The DOM node that was hovered. + """ + super().__init__() + self.tree: DOMTree = tree + """The `DOMTree` that had a node hovered.""" + self.tree_node: TreeNode[DOMNode] = tree_node + """The tree node that was hovered. Only _represents_ the DOM node.""" + self.dom_node: DOMNode = dom_node + """The DOM node that was hovered.""" + + # @property + # def control(self) -> "DOMTree": + # """The `DOMTree` that had a node hovered. + + # This is an alias for [`Hovered.tree`][textual_paint.inspector.DOMTree.Hovered.tree] + # which is used by the [`on`][textual.on] decorator. + # """ + # return self.tree + + def __init__( + self, + root: DOMNode, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): + super().__init__( + root.css_identifier_styled, + root, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + self._root_dom_node = root + + def _on_tree_node_expanded(self, event: Tree.NodeExpanded[DOMNode]) -> None: + event.stop() + dom_node = event.node.data + if dom_node is None: + return + # event.node.remove_children() + for child in dom_node.children: + exists = False + for node in event.node.children: + if node.data == child: + exists = True + break + if exists: + continue + event.node.add( + child.css_identifier_styled, + data=child, + allow_expand=len(child.children) > 0, + ) + + def _on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DOMNode]) -> None: + """Called when a node is highlighted with the keyboard.""" + event.stop() + dom_node = event.node.data + if dom_node is None: + return + self.post_message(self.Hovered(self, event.node, dom_node)) + + 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.""" + super().watch_hover_line(previous_hover_line, hover_line) + node: TreeNode[DOMNode] | None = self._get_node(hover_line) + if node is not None: + assert isinstance(node.data, DOMNode), "All nodes in DOMTree should have DOMNode data, got: " + repr(node.data) + self.post_message(self.Hovered(self, node, node.data)) + # 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 OriginalStyles(NamedTuple): + """The original styles of a widget before highlighting.""" + + widget: Widget + """The widget whose styles are stored.""" + border: BorderDefinition + """The original border of the widget.""" + border_title: str | Text | None + """The original border title of the widget.""" + background: Color | None + """The original background of the widget.""" + tint: Color | None + """The original tint of the widget.""" + +ALLOW_INSPECTING_INSPECTOR = False +"""Whether widgets in the inspector can be picked for inspection.""" + +class Inspector(Container): + """UI for inspecting the layout of the application.""" + + DEFAULT_CSS = """ + Inspector { + dock: right; + width: 40; + border-left: wide $panel-darken-2; + background: $panel; + } + Inspector Button { + margin: 1; + width: 100%; + } + Inspector Tree { + margin: 1; + } + """ + + def __init__(self): + """Initialise the inspector.""" + + super().__init__() + + self._picking: bool = False + """Whether the user is picking a widget to inspect.""" + self._highlight: Container | None = None + """A simple widget that highlights the widget being inspected.""" + self._highlight_styles: list[OriginalStyles] = [] + """Stores the original styles of any Hovered widgets.""" + + def compose(self) -> ComposeResult: + """Add sub-widgets.""" + inspect_icon = "โ‡ฑ" # Alternatives: ๐Ÿ” ๐ŸŽฏ ๐Ÿฎฐ ๐Ÿฎต โฎน โ‡ฑ ๐Ÿข„ ๐Ÿกด ๐Ÿกค ๐Ÿก” ๐Ÿขฐ (โ†–๏ธ arrow emoji unreliable) + expand_icon = "+" # Alternatives: + โจ ๐Ÿชœ ๐ŸŽŠ ๐Ÿก ๐Ÿ”ฌ (โ†•๏ธ arrow emoji unreliable) + yield Button(f"{inspect_icon} Inspect Element", classes="inspect_button") + yield Button(f"{expand_icon} Expand All Visible", classes="expand_all_button") + yield DOMTree(self.app) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle a button being clicked.""" + if event.button.has_class("expand_all_button"): + self.query_one(DOMTree).root.expand_all() + elif event.button.has_class("inspect_button"): + self._picking = not self._picking + self.reset_highlight() + if self._picking: + self.capture_mouse() + else: + self.release_mouse() + + def on_mouse_move(self, event: events.MouseMove) -> None: + """Handle the mouse moving.""" + if not self._picking: + return + self.highlight(self.get_widget_under_mouse(event.screen_offset)) + + def get_widget_under_mouse(self, screen_offset: Offset) -> Widget | None: + # This can raise NoWidget. Will it in practice? + leaf_widget, _ = self.app.get_widget_at(*screen_offset) + if self in leaf_widget.ancestors_with_self and not ALLOW_INSPECTING_INSPECTOR: + return None + return leaf_widget + + async def on_mouse_down(self, event: events.MouseDown) -> None: + """Handle the mouse being pressed.""" + self.reset_highlight() + if not self._picking: + return + leaf_widget = self.get_widget_under_mouse(event.screen_offset) + self.release_mouse() + self._picking = False + + if leaf_widget is None: + return + + # Expand the tree to the selected widget. + tree = self.query_one(DOMTree) + tree_node = tree.root + for dom_node in reversed(leaf_widget.ancestors_with_self): + for node in (*tree_node.children, tree_node): # tree_node in case it's the root + if node.data == dom_node: + tree_node = node + tree_node.expand() + async def wait_for_expand() -> None: + # while not tree_node.is_expanded: # this is set immediately + # await asyncio.sleep(0.01) + await asyncio.sleep(0.01) + task = asyncio.create_task(wait_for_expand()) + self._wait_for_expand = task + await task + del self._wait_for_expand + break + # Select the widget in the tree. + tree.select_node(tree_node) + tree.scroll_to_node(tree_node) + + def on_domtree_hovered(self, event: DOMTree.Hovered) -> None: + """Handle a DOM node being hovered/highlighted.""" + self.highlight(event.dom_node) + + def reset_highlight(self) -> None: + if self._highlight is not None: + self._highlight.remove() + for old in self._highlight_styles: + old.widget.styles.border = old.border + old.widget.border_title = old.border_title + old.widget.styles.background = old.background + old.widget.styles.tint = old.tint + + def highlight(self, dom_node: DOMNode | None) -> None: + """Highlight a DOM node.""" + self.reset_highlight() + if dom_node is None: + return + if not isinstance(dom_node, Widget): + # Only widgets have a region, App (the root) doesn't. + return + + """ + if dom_node and dom_node is not self.screen: + for i, widget in enumerate(dom_node.ancestors_with_self): + if not isinstance(widget, Widget): + continue + self._highlight_styles.append(OriginalStyles( + widget=widget, + background=widget.styles.background, + border=widget.styles.border, + border_title=widget.border_title, + tint=widget.styles.tint, + )) + # widget.styles.background = Color.from_hsl(i / 10, 1, 0.3) + # if not event.ctrl: + # widget.styles.border = ("round", Color.from_hsl(i / 10, 1, 0.5)) + # widget.border_title = widget.css_identifier_styled + widget.styles.tint = Color.from_hsl(i / 10, 1, 0.5).with_alpha(0.5) + """ + + widget = dom_node + self._highlight_styles.append(OriginalStyles( + widget=widget, + background=widget.styles.background, + border=widget.styles.border, + border_title=widget.border_title, + tint=widget.styles.tint, + )) + widget.styles.tint = Color.parse("aquamarine").with_alpha(0.5) + + """ + self._highlight = Container() + self._highlight.styles.border = ("round", "blue") + self._highlight.border_title = dom_node.css_identifier_styled + self._highlight.styles.width = dom_node.region.width + self._highlight.styles.height = dom_node.region.height + self._highlight.styles.offset = (dom_node.region.x, dom_node.region.y) + # self._highlight.styles.layer = "inspector_highlight" + # self._highlight.styles.dock = "top" + """ diff --git a/src/textual_paint/paint.py b/src/textual_paint/paint.py index e9b0d88..19baac5 100755 --- a/src/textual_paint/paint.py +++ b/src/textual_paint/paint.py @@ -37,6 +37,7 @@ from PIL import Image, UnidentifiedImageError from pyfiglet import Figlet, FigletFont from menus import MenuBar, Menu, MenuItem, Separator +from inspector import Inspector from windows import Window, DialogWindow, CharacterSelectorDialogWindow, MessageBox, get_warning_icon, get_question_icon from file_dialogs import SaveAsDialogWindow, OpenDialogWindow from edit_colors import EditColorsDialogWindow @@ -2177,7 +2178,9 @@ class PaintApp(App[None]): # Temporary quick access to work on a specific dialog. # Can be used together with `--press f3` when using `textual run` to open the dialog at startup. # Would be better if all dialogs were accessible from the keyboard. - Binding("f3", "custom_zoom", _("Custom Zoom")) + Binding("f3", "custom_zoom", _("Custom Zoom")), + # Dev tool to inspect the widget tree. + Binding("f12", "toggle_inspector", _("Toggle Inspector")), ] show_tools_box = var(True) @@ -3422,6 +3425,10 @@ class PaintApp(App[None]): window.content.mount(Button(_("OK"), classes="ok submit")) self.mount(window) + def action_toggle_inspector(self) -> None: + inspector = self.query_one(Inspector) + inspector.display = not inspector.display + def compose(self) -> ComposeResult: """Add our widgets.""" yield Header() @@ -3509,6 +3516,7 @@ class PaintApp(App[None]): Static(id="status_dimensions"), id="status_bar", ) + yield Inspector() def on_mount(self) -> None: """Called when the app is mounted."""