Emission d'événements par API et par interaction graphique

Cet article est né suite à une discussion au boulot avec un collègue à propos de l'émission d'événements : je soutenais que pour un événement associé à une action donnée dans un composant graphique, celui-ci devait toujours partir, que l'action ait été engendrée par une interaction graphique (un click par exemple) ou par l'appel d'une API dédiée ; mon collègue jugeait plutôt que l'événement ne devait partir que lorsque l'action était engendrée par l'interaction graphique ; l'appel à l'API devait lui rester « muet »...

De quoi parle-t-on ?

Image

Exemple de TreeView.

Partons du cas concret qui nous a occupé. Soit un composant graphique chargé de présenter des items de données sous forme d'arbre hiérarchique (un outline) ; c'est ce qu'on appelle une « TreeView ». Les noeuds ayant des enfants peuvent être soient pliés (« collapsed ») - les enfants sont alors invisibles - soient dépliés (« expanded ») - les enfants sont alors visibles. L'état du noeud peut varier au cours du temps :

  1. si l'utilisateur (dé)plie le noeud à l'aide d'un click de souris.
  2. si un code appelle une API dédiée pour (dé)plier le noeud.

Ce changement d'état s'accompagne de l'émission d'un événement. La question est alors de savoir si cet événement doit être émis uniquement quand l'utilisateur interragit graphiquement avec le composant (1) ou s'il doit aussi l'être quand on fait appel à l'API publique de la TreeView permettant de définir l'état (2). Voyons comment certains toolkits graphiques répondent à la question...

Exemple en SWT

package org.rnb.tests.treeview;

import org.eclipse.swt.SWT;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.events.TreeEvent;
import org.eclipse.swt.events.TreeListener;
import org.eclipse.swt.layout.FillLayout;
import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Tree;
import org.eclipse.swt.widgets.TreeItem;

public class SwtTree implements TreeListener, SelectionListener {

    private Display display;
    private Shell shell;
    private Tree tree;
    private Button button;
    
    public SwtTree() {
        this.display = new Display();
        this.shell = new Shell(this.display);
        this.shell.setLayout(new FillLayout());
        this.appendTree();
        this.appendButton();
        this.shell.setSize(220, 150);
        this.shell.open();
        while (!this.shell.isDisposed()) {
            if (!this.display.readAndDispatch()) {
                this.display.sleep();
            }
        }
        this.display.dispose();
    }
    
    private void appendTree() {
        this.tree = new Tree(this.shell, SWT.BORDER);
        this.tree.addTreeListener(this);
        TreeItem root = new TreeItem(this.tree, 0);
        root.setText("Root");
        for (int i = 0; i < 4; i++) {
            TreeItem iItem = new TreeItem(root, 0);
            iItem.setText("TreeItem " + i);
        }
    }
    
    private void appendButton() {
        this.button = new Button(this.shell, SWT.PUSH);
        this.button.setText("Toggle");
        this.button.addSelectionListener(this);
    }
    
    private void toggleTreeRoot() {
        TreeItem root = this.tree.getItem(0);
        root.setExpanded(!root.getExpanded());
    }
    
    @Override
    public void treeExpanded(TreeEvent e) {
        System.out.println("expanded");
    }
    
    @Override
    public void treeCollapsed(TreeEvent e) {
        System.out.println("collapsed");
    }
        
    @Override
    public void widgetSelected(SelectionEvent e) {
        System.out.println("Selected");
        this.toggleTreeRoot();
    }
    
    @Override
    public void widgetDefaultSelected(SelectionEvent e) {
        System.out.println("selected");
    }
    
    public static void main(String[] args) {
        new SwtTree();
    }
}
Plier / déplier un noeud de TreeView SWT à l'aide d'un bouton.

Image

Exemple en SWT.

je ne suis pas spécialiste de SWT : le nombre de fois que j'ai eu à utiliser ce toolkit graphique doit se compter sur les doigts d'une main, l'écriture de ce petit exemple inclut ! Les APIs sont cependant suffisamment explicites pour ne pas trop se tromper. Il s'agit ici d'attacher un écouteur TreeListener à la TreeView pour être informé de la fermeture (treeCollapsed) et de l'ouverture (treeExpanded) d'un noeud avec enfants.

Et que constate-t-on ? Que l'événement TreeEvent est émis lorsque l'on plie / déplie le noeud par interaction graphique (click sur la petite boîte du noeud racine) MAIS PAS lorsque l'on plie / déplie le noeud via l'API (setExpanded).

Exemple en Swing

package org.rnb.tests.treeview;

import java.awt.FlowLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JTree;
import javax.swing.event.TreeExpansionEvent;
import javax.swing.event.TreeExpansionListener;
import javax.swing.tree.DefaultMutableTreeNode;

public class SwingTree implements ActionListener, TreeExpansionListener {
    
    private JFrame frame;
    private JPanel panel;
    private JTree tree;
    private JButton button;
    
    public SwingTree() {
        this.frame = new JFrame();
        this.frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        this.panel = new JPanel(new FlowLayout());
        this.appendTree();
        this.appendButton();
        this.frame.add(this.panel);
        this.frame.setSize(220, 150);
        this.frame.setVisible(true);
    }
    
    private void appendTree() {
        DefaultMutableTreeNode root = new DefaultMutableTreeNode("Root");
        for (int i = 0; i < 4; i++) {
            root.add(new DefaultMutableTreeNode("Item " + i));
        }
        this.tree = new JTree(root);
        this.tree.addTreeExpansionListener(this);
        this.panel.add(tree);
    }
    
    private void appendButton() {
        this.button = new JButton("Toggle");
        this.button.addActionListener(this);
        this.panel.add(button);
    }
    
    private void toggleTreeRoot() {
        if (this.tree.isExpanded(0)) {
            this.tree.collapseRow(0);
        } else {
            this.tree.expandRow(0);
        }
    }
    
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println("action");
        this.toggleTreeRoot();
    }
    
    @Override
    public void treeExpanded(TreeExpansionEvent e) {
        System.out.println("expanded");
    }
    
    @Override
    public void treeCollapsed(TreeExpansionEvent e) {
        System.out.println("collapsed");
    }
    
    public static void main(String args[]) {
        new SwingTree();
    }
}
Plier / déplier un noeud de TreeView Swing à l'aide d'un bouton.

Image

Exemple en Swing.

Idem que précédemment : je ne suis pas du tout spécialiste de Swing mais là encore les objets à manipuler sont assez courants. Nous devons cette fois-ci attacher un écouteur TreeExpansionListener à la TreeView pour connaître la fermeture et l'ouverture des noeuds de l'arborescence.

Ici, l'évenement TreeExpansionEvent est émis lors d'une interaction graphique (click sur le noeud racine) MAIS AUSSI lorsque l'on passe par l'API dédiée (collapseRow ou expandRow).

Exemple en Qt4

#!/usr/bin/python
# -*- coding: utf-8 -*-

import sys
from PyQt4 import QtGui


class QtTreeViewTest(QtGui.QWidget):
    def __init__(self):
        super(QtTreeViewTest, self).__init__()
        self.append_tree()
        self.append_button()
        self.resize(220, 150)
        self.move(300, 300)
        self.setWindowTitle('QtTreeView test')
        self.show()

    def append_button(self):
        self.button = QtGui.QPushButton('Toggle', self)
        self.button.move(160, 0)
        self.button.clicked.connect(self.on_button_clicked)

    def append_tree(self):
        self.tree = QtGui.QTreeWidget(self)
        self.tree.resize(150,150)
        self.tree.collapsed.connect(self.on_collapsed)
        self.tree.expanded.connect(self.on_expanded)
        self.tree.itemCollapsed.connect(self.on_item_collapsed)
        self.tree.itemExpanded.connect(self.on_item_expanded)
        self.root = QtGui.QTreeWidgetItem(['Root'])
        self.tree.addTopLevelItem(self.root)
        for i in range(4):
            self.root.addChild(QtGui.QTreeWidgetItem(['Item ' + str(i)]))
    
    def toggle_tree_root(self):
        self.root.setExpanded(self.root.isExpanded() is False)

    def on_collapsed(self, index):
        print('collapsed')

    def on_expanded(self, index):
        print('expanded')

    def on_item_collapsed(self, item):
        print('collapsed')

    def on_item_expanded(self, item):
        print('expanded')

    def on_button_clicked(self):
        print('toggle')
        self.toggle_tree_root()

def main():
    app = QtGui.QApplication(sys.argv)
    win = QtTreeViewTest()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()
Plier / déplier un noeud de TreeView Qt à l'aide d'un bouton.

Image

Exemple en Qt4.

Mon expérience de Qt4 est encore moins grande que celles sur les toolkits graphiques java mais quand on a vu une TreeView, on les a toutes vues. L'écriture de ce petit test a d'ailleurs balayé les a priori que j'avais à propos de cette librairie graphique, qui est plutôt agréable à utiliser. Ici, nous pouvons au choix être à l'écoute des événements itemCollapsed et itemExpanded émis par QTreeWidget ou à l'écoute des événements collapsed et expanded émis par QTreeView dont QTreeWidget hérite.

Les événements sont émis à la fois quand on plie / déplier le noeud via l'interface graphique ET quand on appelle l'API dédiée (setExpanded sur les objets QTreeWidgetItem).

Exemple en GTK 3

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys

from gi.repository import Gtk

class GtkTreeViewWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        Gtk.Window.__init__(self, title='GTK TreeView test', application=app)
        self.set_default_size(220, 150)
        self.button = None
        self.tree = None
        self.box = Gtk.Box(spacing=5)
        self.add(self.box)
        self.append_tree()
        self.append_button()

    def append_button(self):
        self.button = Gtk.Button('Toggle')
        self.button.connect('clicked', self.on_button_clicked)
        self.box.pack_start(self.button, True, True, 0)

    def append_tree(self):
        self.tree = Gtk.TreeView()
        self.tree.connect('row-expanded', self.on_row_expanded)
        self.tree.connect('row-collapsed', self.on_row_collapsed)
        col = Gtk.TreeViewColumn('Items', Gtk.CellRendererText(), text=0)
        self.tree.append_column(col)
        store = Gtk.TreeStore(str)
        self.root = store.append(None, ['Root'])
        for i in range(4):
            store.append(self.root, ['Item ' + str(i)])
        self.tree.set_model(store)
        self.box.pack_start(self.tree, True, True, 1)

    def toggle_tree_root(self):
        model = self.tree.get_model()
        path = model.get_path(self.root)
        if self.tree.row_expanded(path):
            self.tree.collapse_row(path)
        else:
            self.tree.expand_row(path, False)

    def on_row_expanded(self, tree, treeiter, path, data=None):
        print('expanded')

    def on_row_collapsed(self, tree, treeiter, path, data=None):
        print('collapsed')
        
    def on_button_clicked(self, button, data=None):
        print('action')
        self.toggle_tree_root()


class GtkTreeViewApplication(Gtk.Application):

    def __init__(self):
        Gtk.Application.__init__(self)

    def do_activate(self):
        win = GtkTreeViewWindow(self)
        win.show_all()

    def do_startup(self):
        Gtk.Application.do_startup(self)

if __name__ == "__main__":
    app = GtkTreeViewApplication()
    exit_status = app.run(sys.argv)
    sys.exit(exit_status)
Plier / déplier un noeud de TreeView GTK à l'aide d'un bouton.

Image

Exemple en GTK.

Avec GTK, je me retrouve avec un toolkit graphique que je connais beaucoup mieux et je savais déjà comment allait être émis les événements marquant la fermeture (row-collapsed) et l'ouverture (row-expanded) d'une TreeView. Ces événements sont émis quand l'action à une source graphique (click sur le triangle de pliage du noeud) ET quand on appelle l'API dédiée (expand_row ou collapse_row).

Discussions

Les exemples ci-dessus ne permettent pas de faire le tour de la question et ce n'est de toute façon pas le but. Ce que je souhaite simplement (dé)montrer, c'est que les 2 cas de figures peuvent exister et qu'il n'y a rien d'incohérent à voir un événement associé à une action partir à la fois quand cette action est induite par une interaction graphique et quand elle l'est par l'appel à une API.

Emission de l'événement d'expansion d'un noeud de TreeView.
Toolkit Interaction graphique Interaction par API
SWT oui non
Swing oui oui
Qt4 oui oui
GTK 3 oui oui

Ressources et références

Titre
GTK+ 3
Chapitre
GtkTreeView
Editeur
Gnome Developer
Titre
Swing
chapter
JTree
Éditeur
docs.oracle.com
Titre
SWT
chapter
Tree
Éditeur
Eclipse Documentation
Titre
QTreeView Class Reference
Éditeur
Qt Project
Titre
QTreeWidget Class Reference
Éditeur
Qt Project