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 qtpy
from qtpy.QtWidgets import QWidget, QTreeWidget, QTreeWidgetItem, QSplitter
from qtpy.QtCore import Qt
from typing import Any, Callable, Iterator, TYPE_CHECKING
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
if TYPE_CHECKING:
import numpy as np
# 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)
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 numpy as np
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."""
import numpy as np
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