Édition simplifiée de graphes, à la souris et au clavier
Avant-propos
La création de nodes par drag & drop nécessite un réaménagement du code.
pos
à CtrlNode.__init__()
, pour placer le centre du nouveau node à la position du drop.width
et height
à CtrlNode
, pour simplifier le code et les calculs.CtrlNode
provoquent des erreurs.CtrlScene.provisoire()
provoque des erreurs.width
et height
n'ont plus lieu d'exister dans CtrlNode.d_display()
, on les supprime.width
et height
dans UiNode.init_ui()
est faux.UiNode.boundingRect()
.CtrlNode.__init__()
(Ajout du paramètre pos
et des 2 attributs width
et height
) :
def __init__(self, o_scene, s_id, pos):
self.o_scene = o_scene
self.s_id = s_id # Clé d'entrée dans le super-dictionnaire pkl.
self.od_pkl = self.o_scene.o_pkl.od_pkl
self.o_gr_node = None
self.o_params = None
self.pos = pos # pos = (x, y)
self.width = 96 # Valeur par défaut.
self.height = 60 # Valeur par défaut.
self.main_key = f"Paramètres du node '{self.s_id}'"
self.b_chk = self.od_pkl.read([self.s_id, 'b_chk'], True)
self.b_on = self.b_chk # Provisoire.
CtrlNode.setup()
(Prise en compte du nouveau node dropé) :
def setup(self, child_file):
""" Code appelant : classe dérivée, on connait donc ses attributs, notamment width et height. """
""" Paramètres. """
self.o_params = Parameters(self)
""" pos_default est utilisée lors de la création du node par drag & drop.
Le centre du nouveau node apparaît exactement à la position du curseur de la souris. """
pos_default = self.pos[0] - self.width // 2, self.pos[1] - self.height // 2
b_drop = self.pos != (0, 0) # Nouveau node (node dropé).
self.pos = self.od_pkl.read([self.s_id, 'position'], pos_default)
self.o_gr_node = UiNode(self) # Gestion de l'UI (Interface utilisateur) : dessin couleurs, ...
self.o_scene.o_gr_scene.addItem(self.o_gr_node) # Incorporation dans la scène.
if b_drop:
""" Cas d'un nouveau node (dropé). On mémorise son path et sa position. """
path = 'nodes' + child_file.split('nodes')[1].replace('/', '.').replace('\\', '.')[:-3]
self.od_pkl.write([self.s_id, 'path'], path)
self.o_gr_node.save_pos() # Important ! à la fin (contient o_pkl.backup()).
os
.CtrlNode.d_display()
(Les clés width
et height
, devenues inutiles, ont été supprimées.):
@property
def d_display(self):
""" Attributs par défaut pouvant être surchargés dans les classes dérivées. """
return {
'geometry': {'h_title': 24, 'round': 6},
'col_pen': {'select': '#ffa637', 'hover': '#888', 'on': "#000", 'off': '#aaaa00'}, # ordre = priorité.
'col_brush': {'on': "#8888bbcc", 'off': '#cccccccc'},
'thick_pen': {'hover': 4., 'leave': 2.}
}
Classe-fille dérivée → signaux.py > Node.__init__()
(Ajout du paramètre pos
et surcharge des 2 attributs width
et height
):
def __init__(self, o_scene, s_id, pos):
super().__init__(o_scene, s_id, pos)
self.width = 120 # Option provisoire.
self.height = 100 # Option provisoire.
self.setup()
Classe-fille dérivée → signaux.py > Node.setup()
(Ajout du paramètre "chemin du fichier" child_file
) :
def setup(self, child_file=__file__):
# Code spécifique ...
super().setup(child_file)
Faire de même pour les autres classes dérivées (plot, macd
, etc). width
et height
sont facultatifs.
Vérifier les éventuelles surcharges @property d_display()
: retirer les clés width
et height
devenues inutiles, les remplacer par des attributs dans __init__()
.
CtrlScene.provisoire()
(Les positions (x, y) ont été ajoutées dans les arguments d'appel) :
def provisoire(self):
""" Provisoire. Ajout de 3 nodes pour la mise au point. A supprimer après le coding du drag & drop. """
from nodes.generateurs.signaux import Node
Node(self, 'Node1', (-10, -10)) # La position du centre du node (-10, -10) a été ajoutée.
from nodes.afficheurs.plot import Node
Node(self, 'Node2', (10, 10)) # Remarque : La position mémorisée n'est pas celle-ci ...
from nodes.indicateurs.macd import Node
Node(self, 'Node3', (30, 30)) # ... mais celle du coin haut-gauche.
UiNode.init_ui()
(Remplacement des anciens width
et height
par self.o_node.width
et self.o_node.height
) :
def init_ui(self):
""" Paramètres. """
h_title, n_round = self.o_node.d_display['geometry']['h_title'], self.o_node.d_display['geometry']['round']
""" Position. """
self.setPos(*self.o_node.pos)
""" Titre. """
geometry = (0, 0, self.o_node.width, h_title, n_round, n_round)
self.path_title.setFillRule(Qt.WindingFill)
self.path_title.addRoundedRect(*geometry)
self.path_title.addRect(0, h_title - n_round, self.o_node.width, n_round)
# |_ Enlève les arrondis du bas du titre, pour éviter un effet disgracieux (Commenter cette ligne pour le voir)
self.title_item.setFont(QFont('Arial', 9)) # Police et taille
self.title_item.setPos(10, 1)
self.set_title()
""" Dessin du node : rectangle aux coins arrondis. """
self.path_outline.addRoundedRect(0, 0, self.o_node.width, self.o_node.height, n_round, n_round)
""" On / Off """
QGraphicsProxyWidget(self).setWidget(self.on_off)
self.on_off.move(self.o_node.width-10, -4)
self.on_off.setChecked(self.o_node.b_chk)
""" Flags. """
self.setFlag(QGraphicsItem.ItemIsMovable) # Peut être déplacé.
self.setFlag(QGraphicsItem.ItemIsSelectable) # Peut être sélectionné.
self.setAcceptHoverEvents(True) # Accepte le survol.
self.setToolTip(f"{self.o_node.get_param('Titre du node', 'Le titre')}\n{self.o_node.pos}")
""" Événements. """
self.on_off.stateChanged.connect(self.o_node.set_checked)
UiNode.boundingRect()
(Remplacement des anciens width
et height
par self.o_node.width
et self.o_node.height
) :
def boundingRect(self):
return QRectF(0, 0, self.o_node.width, self.o_node.height).normalized()
Vérifier :
CtrlNode
: macd.py
, plot.py
, signaux.py
, etc.
@property d_display
: Vérifier que les clés width
et height
ont bien été retirées.Description
CtrlScene.provisoire()
nous a permis d'amorcer la création des nodes au démarrage du programme.od_pkl
pour rechercher les nodes.od_pkl
, on remarque que le path n'existe pas. Exemple de path : nodes.generateurs.signaux
self.provisoire()
par self.show_nodes()
, à la fin du setup()
.show_nodes()
chargée d'exécuter l'algorithme.params.pkl
des sous-dossiers de /backups/
self.o_pkl.od_pkl.print()
) - Le contenu minimum de od_pkl
doit être :Node1: ----------------------------------------------------- <class 'dict'> path: nodes.generateurs.signaux ------------------------ <class 'str'> position: (-70.0, -60.0) ------------------------------- <class 'tuple'> Node2: ----------------------------------------------------- <class 'dict'> path: nodes.afficheurs.plot ---------------------------- <class 'str'> position: (-50.0, -30.0) ------------------------------- <class 'tuple'> Node3: ----------------------------------------------------- <class 'dict'> path: nodes.indicateurs.macd --------------------------- <class 'str'> position: (-18.0, 0.0) --------------------------------- <class 'tuple'>
CtrlScene.provisoire()
.CtrlScene.setup()
, remplacez l'appel self.provisoire()
par self.show_nodes()
.CtrlScene.show_nodes()
:
def show_nodes(self):
""" Parcours des grappes (depth = 0) du dictionnaire pkl du graphe de cette scène.
Recherche de celles correspondant à un node :
|_ La clé (s_id) doit être 'Node12' par exemple, la valeur (d_node) doit être un dictionnaire.
|_ Le dictionnaire 'd_node' doit contenir la clé 'path'
"""
for s_id, d_node in self.o_pkl.od_pkl.items():
""" On s'assure qu'il s'agit bien d'un node et que les données sont conformes. """
if s_id[:4] == 'Node' and s_id[4:].isnumeric() and isinstance(d_node, dict) and 'path' in d_node:
path = d_node['path']
self.compile_node(s_id, path)
Oui mais ... pour chaque node, la méthode show_nodes()
délègue la compilation dynamique à la méthode compile_node()
, qui nexiste pas !
CtrlScene.compile_node()
:
def compile_node(self, s_id, path, pos=(0, 0)):
"""
:param s_id: Clé à la racine du super-dictionnaire od_pkl. Exemple : 'Node7'
:param path: Chemin sur disque dur au format des imports. Exemple : 'nodes.indicateurs.macd'
:param pos: tuple. (0, 0) lors du setup (lecture du pkl), (pos_x, pos_y) si appel suite au drag & drop.
:return: Objet o_node si réussite, None si échec.
"""
d_context = {'o_scene': self, 's_id': s_id, 'pos': pos} # Définit 'o_scene', 's_id' et 'pos' dans le contexte.
code = f"from {path} import Node; o_node=Node(o_scene, s_id, pos=pos)" # Ajoute o_node dans le contexte.
# à coder
return o_node
dock_nodes.py > GroupNodes.populate_tab()
qui permet l'affichage d'icones dans le dockable des nodes, représentatives des nodes disponibles.item
, créé dans cette méthode, pour initier le drag, en lui ajoutant 2 données :
La méthode GroupNodes.populate_tab()
modifiée devient :
def populate_tab(self, ld_datas):
"""
Affichage des icones et de leur libellé à partir de la liste 'self.ld_datas'.
:param ld_datas: Liste de dictionnaires : un par node, contenant le nom de l'icone et de son libellé.
:return: NA
"""
for d_datas in ld_datas:
""" Chaque item contient des informations : icône, libellé, flags, datas """
name = d_datas['name']
icon = self.path_name + d_datas['icon']
item = QListWidgetItem(name, self)
icon = QPixmap(icon)
item.setIcon(QIcon(icon))
""" Ajout d'informations, pour le drag. """
item.setData(Qt.UserRole, icon) # Affichage de l'icone sous la souris pendant le drag.
item.setData(Qt.UserRole + 1, d_datas['path']) # Nécéssaire pour la compilation, lancée par le drop.
Inutile de préciser qu'il faut importer Qt
😁 !
d_datas
(dernière ligne), ne contient pas la clé path
.ld_datas
, c'est à dire get_datas()
, pour ajouter cette info.GroupNodes.get_datas()
:
def get_datas(self):
""" 1 - Liste de tous les fichiers .py du sous-dossier => py_files
2 - Seuls les fichiers qui possèdent un dictionnaire 'd_datas' sont candidats.
3 - Création d'une liste de ces dictionnaires => ld_datas
:return: Liste de dictionnaires de datas 'ld_datas'
"""
ld_datas = list() # ld_ = Liste de dictionnaires.
if not os.path.isdir(self.path_name):
return list() # Si le dossier n'existe pas => onglet vide.
l_files = next(os.walk(self.path_name))[2]
py_files = [py_file[:-3] for py_file in l_files if os.path.splitext(py_file)[-1].lower() == '.py']
for py_file in py_files:
code = f"from nodes.{self.dir_name}.{py_file} import d_datas"
try:
""" Compilation dynamique. """
eval(compile(code, '<string>', 'exec'), globals(), globals())
d_datas['path'] = f'nodes.{self.dir_name}.{py_file}'
ld_datas.append(d_datas) # Si souligné rouge -> Mark all unresolved attributes ...
except (Exception,):
continue
return ld_datas
Notez la ligne ajoutée : d_datas['path'] = f'nodes.{self.dir_name}.{py_file}'
QWidgets
nommée startDrag()
.icon
et path
)QDataStream()
comme moyen pour transporter ces données (sérialisées).drag.setPixmap(icon)
drag.exec_(Qt.MoveAction)
GroupNodes.startDrag()
:
def startDrag(self, *args, **kwargs):
""" Appelée automatiquement dès qu'un drag est amorcé dans la dock-nodes.
- Les infos ajoutées dans populate_tab() seront récupérés par le destinaire du drop.
"""
mime_data = QMimeData() # Enregistre les données au format voulu (ici : 'my_robot')
item = self.currentItem()
item_data = QByteArray()
""" Datas """
icon = item.data(Qt.UserRole)
path = item.data(Qt.UserRole + 1)
data_stream = QDataStream(item_data, QIODevice.WriteOnly) # Sérialisation.
data_stream << icon # Sérialisation de l'image.
data_stream.writeQString(path)
mime_data.setData('my_robot', item_data)
drag = QDrag(self) # Prend en charge le transport des données {mime_data}.
drag.setMimeData(mime_data)
drag.setHotSpot(QPoint(icon.width() // 2, icon.height() // 2)) # Curseur centré sur l'icone.
drag.setPixmap(icon)
drag.exec_(Qt.MoveAction)
super().startDrag(*args, **kwargs)
On ne dit plus qu'il faut importer les classes soulignées ... on ne le dit plus 😉 !
Vérifier : Lancer un drag ⇒ L'icone apparaît sous la souris.
dragMoveEvent()
.UiView.dragMoveEvent()
:
def dragMoveEvent(self, ev):
ev.acceptProposedAction()
Acceptation du drag.
dropEvent()
.UiView.dropEvent()
:
def dropEvent(self, ev):
md = ev.mimeData()
if not md.hasFormat('my_robot'):
return
event_data = md.data('my_robot')
data_stream = QDataStream(event_data, QIODevice.ReadOnly)
data_stream >> QPixmap()
path = data_stream.readQString()
mouse_pos = ev.pos()
scene_pos = self.mapToScene(mouse_pos) # Conversion entre repères scène et vue.
pos_new_node = scene_pos.x(), scene_pos.y()
self.o_scene.new_node(path, pos_new_node)
En dernière ligne, la création du node est déléguée à la scène. La méthode new_node()
n'existe pas.
Créons la. CtrlScene.newNode()
:
def new_node(self, path, pos_node):
""" Recherche du s_id, compile, sélectionne puis affiche les paramètres. """
s_id = self.get_new_sid() # Recherche du premier s_id disponible.
o_node = self.compile_node(s_id, path, pos_node) # Compilation dynamique.
if o_node is not None:
for item in self.o_gr_scene.items():
item.setSelected(False) # Désélection de tous les items de la scène.
o_node.o_gr_node.setSelected(True) # Sélection du nouveau node.
self.show_params() # Affichage des paramètres par défaut.
Cette méthode appelle la compilation dynamique que nous avons déjà codé.
Oui mais ... elle a besoin de lui transmettre l'identifiant du node s_id
, fourni par la méthode get_new_sid()
qui nexiste pas.
Créons la. CtrlScene.get_new_sid()
:
def get_new_sid(self):
""" La liste des nodes, fournie par get_gr_nodes(), doit être triée. """
l_gr_nodes = self.get_gr_nodes()
new_id = len(l_gr_nodes)
for k, o_gr_node in enumerate(l_gr_nodes):
node_id = int(o_gr_node.s_id[4:])
if k < node_id:
new_id = k
break
return f'Node{new_id}'
Oui mais ... cette méthode utilise la liste des nodes existants, triée sur l'indice de leur s_id
.
CtrlScene.get_gr_nodes()
fournit cette liste, mais non triée.CtrlScene.get_gr_nodes()
:
def get_gr_nodes(self):
""" Renvoie la liste de tous les o_gr_nodes contenus dans la scène, triée sur l'indice du s_id.
Important : le tri est effectué sur l'indice du s_id (int) et non sur le s_id (str).
Exemple : alphabétiquement, 'Node2' vient après 'Node14', numériquement, il vient avant. """
l_items = self.o_gr_scene.items()
l_gr_nodes = list()
for o_gr_node in l_items:
if o_gr_node.__class__.__name__ == 'UiNode':
l_gr_nodes.append(o_gr_node)
# à coder ... -> Retourne la liste 'l_gr_nodes', triée.
Bon coding !
signaux.py
, plot.py
et macd.py
.Suppr
du clavier.keyPressEvent()
puis keyReleaseEvent()
.UiView.keyReleaseEvent()
:
def keyReleaseEvent(self, ev):
super().keyReleaseEvent(ev)
key = ev.key()
if key == Qt.Key_Delete:
self.o_scene.delete_items()
delete_items()
de la scène, qui n'existe pas. Créons la.CtrlScene.delete_items()
:
def delete_items(self):
""" Les items sont les nodes, les liens et autres widgets de la scène. """
selected = self.o_gr_scene.selectedItems()
l_gr_nodes = list()
for sel in selected:
if sel.__class__.__name__ == 'UiNode':
l_gr_nodes.append(sel)
nb_nodes = len(l_gr_nodes)
if nb_nodes == 0:
return
elif nb_nodes == 1:
msg = "Vous êtes sur le point de supprimer un node.\nAcceptez-vous ?"
else:
msg = f"Vous êtes sur le point de supprimer {nb_nodes} nodes.\nAcceptez-vous ?"
if ut.msg_yesno('Confirmation', msg, self):
for o_gr_node in l_gr_nodes:
self.remove_node(o_gr_node)
self.o_pkl.backup()
self.show_params()
Oui mais ... la boite de dialogue msg_yesno
n'existe pas.
Or, nous serons amenés à utiliser des boîtes de dialogue tout au long de ce cours.
A la fin de la classe Utils
, ajouter ces 3 méthodes.
utils.py > Utils
:
# **********************************************************
# Messages Messages Messages Messages Messages Messages *
# **********************************************************
@staticmethod
def msg_info(title, msg, parent=None, sound=True, typed='info'):
"""
Si parent == None => Affichage au milieu de l'écran.
|_ Si parent est l'objet-widget appelant => Affichage au centre de sa fenêtre.
"""
if sound:
winsound.MessageBeep() # winsound.MB_ICONASTERISK)
if typed == 'info':
QMessageBox.information(parent, title, msg)
elif typed == 'error':
QMessageBox.critical(parent, title, msg)
elif typed == 'yesno':
response = QMessageBox.question(parent, title, msg, QMessageBox.Yes, QMessageBox.No)
return response == QMessageBox.Yes # True si oui.
def msg_error(self, title, msg, parent=None, sound=True):
self.msg_info(title, msg, parent, sound, 'error')
def msg_yesno(self, title, msg, parent=None, sound=True):
return self.msg_info(title, msg, parent, sound, 'yesno')
remove_node()
est appelée. Cette méthode n'existe pas.Créons-la. CtrlScene.remove_node()
:
def remove_node(self, o_gr_node):
self.o_pkl.od_pkl.delete(o_gr_node.s_id)
self.o_gr_scene.removeItem(o_gr_node)
Vérification
CtrlScene.compile_node()
et CtrlScene.get_gr_node()
.CtrlScene
, créez le fichier /tests/test_ctrl_scene.py
/tests/test_ctrl_scene.py
:
from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import QTimer
from pc.main import UiMain
import pytest
import os
import sys
class TestScene:
g_appli = QApplication(sys.argv)
tempo = QTimer()
tempo.setSingleShot(True)
@pytest.fixture(scope='session')
def setup(self):
bk_test = os.path.abspath(f"{os.path.dirname(__file__)}/../backups/tests")
os.makedirs(bk_test, exist_ok=True) # Création dossier.
o_main = self.open_window()
o_main.resize(800, 600)
o_main.open_graph('tests')
o_scene = None
for i in range(o_main.tabGraphs.count()):
if o_main.tabGraphs.widget(i).graph_name == 'tests':
o_scene = o_main.tabGraphs.widget(i)
break
assert o_scene is not None
assert o_scene.__class__.__name__ == 'CtrlScene'
return {
'main': o_main,
'scene': o_scene
}
@staticmethod
@pytest.fixture(scope="session", autouse=True)
def cleanup(request):
def remove_files():
""" Appel final pour effacer les fichiers temporaires créés par les tests. """
test_folder = os.path.dirname(__file__).replace('tests', f'backups{os.sep}tests')
if os.path.isdir(test_folder):
os.rmdir(test_folder)
request.addfinalizer(remove_files)
@staticmethod
def open_window():
win = UiMain()
win.show()
return win
# noinspection PyUnresolvedReferences
def connect(self, function, delay):
try:
self.tempo.timeout.disconnect()
except TypeError:
pass
self.tempo.timeout.connect(function)
self.tempo.start(delay)
# @pytest.mark.skip
def test_compile_node(self, setup):
o_main = setup['main']
o_scene = setup['scene']
self.connect(o_main.close, 1000)
o_node = o_scene.compile_node('Node0', 'nodes.generateurs.signaux', (10, 10))
assert o_node.__class__.__name__ == 'Node'
o_scene.o_gr_scene.removeItem(o_node.o_gr_node) # Suppression du node.
self.g_appli.exec()
@pytest.mark.skip
def test_get_gr_node(self, setup):
o_main = setup['main']
o_scene = setup['scene']
self.connect(o_main.close, 1000)
o_scene.compile_node('Node3', 'nodes.generateurs.signaux', (10, 10))
o_scene.compile_node('Node0', 'nodes.indicateurs.macd', (10, 10))
o_scene.compile_node('Node7', 'nodes.indicateurs.macd', (10, 10))
l_nodes = [o_node.s_id for o_node in o_scene.get_gr_nodes()]
assert l_nodes == ['Node0', 'Node3', 'Node7']
self.g_appli.exec()
Exécuter les tests.
Bon coding et bon courage !
Snippets
Bonjour les codeurs !