Source code for magicclass.help

from __future__ import annotations
from magicgui.widgets import FunctionGui, Image, Slider
from magicgui.widgets._bases.widget import Widget
from magicgui.widgets._function_gui import _docstring_to_html
import numpy as np

import qtpy
from qtpy.QtWidgets import QWidget, QTreeWidget, QTreeWidgetItem, QSplitter
from qtpy.QtCore import Qt
from typing import Any, Callable, Iterator

from magicclass.widgets.containers import SplitterContainer
from magicclass.widgets.misc import ConsoleTextEdit

from .gui.mgui_ext import Action, PushButtonPlus, WidgetAction
from .gui._base import MagicTemplate
from .widgets import DraggableContainer, FreeWidget
from .gui.class_gui import CollapsibleClassGui, DraggableClassGui, ScrollableClassGui, ButtonClassGui
from .utils import iter_members, extract_tooltip, get_signature

# TODO: find
# TODO: key-binding

class _HelpWidget(QSplitter):
    """
    A Qt widget that will show information of a magic-class widget, built from its
    class structure, function docstrings and type annotations.
    """    
    _initial_image_size = 250
    
    def __init__(self, ui=None, parent=None) -> None:
        super().__init__(orientation=Qt.Horizontal, parent=parent)
        self.setWindowFlag(Qt.Window)
        self._tree = QTreeWidget(self)
        self._tree.itemClicked.connect(self._on_treeitem_clicked)
        self._text = ConsoleTextEdit()
        self._text.read_only = True
        self._mgui_image = Image()
        c = DraggableContainer(widgets=[self._mgui_image])
        
        self._mgui_slider = Slider(value=self._initial_image_size, min=50, max=1000, step=50)
        self._mgui_slider.changed.connect(self._resize_image)
        self._mgui_slider.parent = c
        self._mgui_slider.native.setGeometry(4, 4, 100, 20)
        self._mgui_slider.max_height = 20
        self._mgui_slider.max_width = 100
        c.min_height = 120
        self._resize_image(self._initial_image_size)
        
        widget_right = SplitterContainer(widgets=[c, self._text])
        
        self.insertWidget(0, self._tree)
        self.insertWidget(1, widget_right.native)
        
        if ui is not None:
            self.set_tree(ui)
            self._update_ui_view(ui)
            
        width = self.width()
        left_width = width//3
        self.setSizes([left_width, width - left_width])
    
    def set_tree(self, ui: MagicTemplate, root: UiBoundTreeItem = None):
        name = ui.name
        if root is None:
            root = UiBoundTreeItem(self._tree, ui)
            root.setText(0, name)
            root.setExpanded(True)
            self._tree.invisibleRootItem().addChild(root)
                
        for i, widget in enumerate(_iter_unwrapped_children(ui)):
            if isinstance(widget, MagicTemplate):
                child = UiBoundTreeItem(root, ui=widget)
                self.set_tree(widget, root=child)
                if child.childCount() == 1 and isinstance(child.child(0).ui, MagicTemplate):
                    # If only one magic class is nested, we don't create redundant items
                    grandchild = child.takeChild(0)
                    grandchild.setText(0, f"({i+1}) {widget.name} > {grandchild.ui.name}")
                    root.removeChild(child)
                    child = grandchild
                else:
                    child.setText(0, f"({i+1}) {widget.name}")
                    
            else:
                child = UiBoundTreeItem(root, ui=None)
                child.setText(0, f"({i+1}) {widget.name}")
            
            root.addChild(child)
    
    def _resize_image(self, v: float):
        self._mgui_image.min_height = v
        self._mgui_image.max_height = v
        self._mgui_image.min_width = v
        self._mgui_image.max_width = v
    
    def _update_ui_view(self, ui: MagicTemplate):
        img, docs = get_help_info(ui)
        
        # set image
        self._mgui_image.value = img
        
        # set text
        htmls = [f"<h1>{ui.name}</h1><p>{extract_tooltip(ui)}</p>"]
        
        if docs:
            htmls.append("<h2>Contents</h2>")
        
        for i, (name, doc) in enumerate(docs.items()):
            htmls.append(f"<h3>({i+1}) {name}</h3><p>{doc}</p>")
        self._text.value = "".join(htmls)

    def _on_treeitem_clicked(self, item: UiBoundTreeItem, i: int = 0):
        if item.ui is not None:
            self._update_ui_view(item.ui)
            item.setExpanded(True)
        else:
            self._on_treeitem_clicked(item.parent())
        self._resize_image(self._initial_image_size)

    
[docs]class UiBoundTreeItem(QTreeWidgetItem): def __init__(self, parent, ui=None): super().__init__(parent) self.ui = ui # TODO: use weakref?
[docs] def child(self, index: int) -> UiBoundTreeItem: # Just for typing return super().child(index)
[docs] def takeChild(self, index: int) -> UiBoundTreeItem: # Just for typing return super().takeChild(index)
[docs]class HelpWidget(FreeWidget): def __init__(self, ui=None, parent=None): super().__init__() self._help_widget = _HelpWidget(ui, parent) self.set_widget(self._help_widget) self.native.setWindowTitle("Help")
def _issubclass(child: Any, parent: Any): try: return issubclass(child, parent) except TypeError: return False
[docs]def get_help_info(ui: MagicTemplate) -> tuple[np.ndarray, dict[str, str]]: import matplotlib as mpl import matplotlib.pyplot as plt backend = mpl.get_backend() try: _has_inner_widget = (ScrollableClassGui, DraggableClassGui, CollapsibleClassGui, ButtonClassGui) if isinstance(ui, _has_inner_widget): inner_widget = ui._widget._inner_widget visible = inner_widget.isVisible() inner_widget.setVisible(True) img = _render(inner_widget) inner_widget.setVisible(visible) # elif isinstance(ui, TabbedClassGui): # TODO: Cannot assign correct position of tabs now. else: img = ui.render() scale = _screen_scale() docs: dict[str, str] = {} mpl.use("Agg") with plt.style.context("default"): fig, ax = plt.subplots(1, 1) ax.axis("off") fig.patch.set_alpha(0.05) ax.imshow(img) for i, widget in enumerate(_iter_unwrapped_children(ui)): x, y = _get_relative_pos(widget) ax.text(x*scale, y*scale, f"({i+1})", ha="center", va="center", color="white", backgroundcolor="black", fontfamily="Arial") docs[widget.name] = _get_doc(widget) fig.tight_layout() fig.canvas.draw() data = np.asarray(fig.canvas.renderer.buffer_rgba(), dtype=np.uint8) finally: mpl.use(backend) return data, docs
def _screen_scale() -> float: from qtpy.QtGui import QGuiApplication screen = QGuiApplication.screens()[0] return screen.devicePixelRatio() def _get_doc(widget) -> str: if isinstance(widget, MagicTemplate): doc = widget.__doc__ or "" elif isinstance(widget, (Action, PushButtonPlus)): doc = _docstring_to_html(widget._doc) elif isinstance(widget, FunctionGui): doc = _docstring_to_html(widget._function.__doc__) elif isinstance(widget, (Widget, WidgetAction)): doc = widget.tooltip or "" else: raise TypeError(type(widget)) doc = doc.rstrip("<h3>Parameters</h3><ul></ul>") # If parameter info was not given if doc == "": doc = "(No document found)" return doc def _render(qwidget: QWidget) -> np.ndarray: """Render Qt widgets. Used in certain type of containers.""" img = qwidget.grab().toImage() bits = img.constBits() h, w, c = img.height(), img.width(), 4 if qtpy.API_NAME == "PySide2": arr = np.array(bits).reshape(h, w, c) else: bits.setsize(h * w * c) arr = np.frombuffer(bits, np.uint8).reshape(h, w, c) return arr[:, :, [2, 1, 0, 3]] def _iter_unwrapped_children(ui: MagicTemplate) -> Iterator[Widget]: for widget in ui: if not getattr(widget, "_unwrapped", False): yield widget def _get_relative_pos(widget: Widget) -> tuple[int, int]: """Get relative position of a widget seen from its parent.""" w = widget._labeled_widget() if w is None: w = widget qpos = w.native.mapToParent(w.native.rect().topLeft()) return qpos.x(), qpos.y()
[docs]def get_keymap(ui: MagicTemplate | type[MagicTemplate]): from .signature import get_additional_option from .gui.keybinding import as_shortcut keymap: dict[str, Callable] = {} if isinstance(ui, MagicTemplate): cls = ui.__class__ elif _issubclass(ui, MagicTemplate): cls = ui else: raise TypeError("'get_keymap' can only be called with MagicTemplate input.") for name, attr in iter_members(cls, exclude_prefix=" "): if isinstance(attr, type) and issubclass(attr, MagicTemplate): child_keymap = get_keymap(attr) keymap.update(child_keymap) else: kb = get_additional_option(attr, "keybinding", None) if kb: keystr = as_shortcut(kb).toString() keymap[keystr] = (name, extract_tooltip(attr)) return keymap