Le poste de contrôle (PC) / Les graphes /
Ajout et suppression de nodes depuis l'UI

Édition simplifiée de graphes, à la souris et au clavier


Avant-propos

Préparation : à faire obligatoirement !

La création de nodes par drag & drop nécessite un réaménagement du code.

Correction de ces conséquences : Elle consiste à remplacer du code.

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()).
N'oubliez pas d'importer 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 :


Description

  1. Suppression du code provisoire.
  2. Création du node par compilation dynamique.
  3. Préparation du drag.
  4. Lancement du drag.
  5. Réception du drop et création du node.
  6. Suppression de nodes.

1 - Suppression du code provisoire :
A faire :
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'>

 

Oui mais ... pour chaque node, la méthode show_nodes() délègue la compilation dynamique à la méthode compile_node(), qui nexiste pas !


2 - Création du node par compilation dynamique :
    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

Bon coding !


3 - Création d'un node par drag & drop : préparation du drag :

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 😁 !


4 - Création d'un node par drag & drop : lancement du drag :
  1. Le lancement du drag est capturé par un événement qui appelle la méthode des QWidgets nommée startDrag().
  2. On surcharge cette méthode.
  3. On récupère dans ce code les infos précédemment ajoutées (icon et path)
  4. On utilise un QDataStream() comme moyen pour transporter ces données (sérialisées).
  5. Le nom de ce "transporteur" est arbitraire. Choisissons "my_robot". Il est nécessaire pour le reconnaître à l'arrivée (au drop).
  6. On charge l'icone pour l'afficher : drag.setPixmap(icon)
  7. On lance le drag : 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.


5 - Création d'un node par drag & drop : réception du drop et création du node :

Bon coding !


6 - Suppression de nodes :

Vérification

TDD : Méthodes CtrlScene.compile_node() et CtrlScene.get_gr_node().
  • Depuis CtrlScene, créez le fichier /tests/test_ctrl_scene.py
  • Collez-y le code suivant.
    /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

Essayez de résoudre cette fonctionnalité par vous-même.
Consultez les réponses (snippets) seulement si vous n'avez pas trop de temps.

Bonjour les codeurs !