II. Support de l'annulation▲
II-1. Présentation du framework Undo fourni par Qt▲
La possibilité d'annuler et réappliquer des modifications dans un document est devenue extrêmement courant. C'est en effet
une fonctionnalité essentielle pour faciliter l'utilisation de votre logiciel. Les éditeurs 3D ou encore de niveaux n'échappent pas à
la règle, nous allons donc voir comment implémenter ce système avec Qt.
Le framework Undo de Qt est basé sur le design pattern "Command". Je vous encourage à lire la définition écrite par Baptiste Wicht sur
ce même site. Pour une
description plus détaillée avec un exemple complet, vous pouvez vous référer à
cette page.
L'idée est donc de wrapper nos actions sur la scène dans des objets, des instances d'une classe dérivant de
QUndoCommand. Lors de toute modification de la scène, un
tel objet sera donc créé de façon à définir comment annuler la modification, ainsi que la façon de l'appliquer. Chacun de ces objets sera
poussé dans une QUndoStack. Cette dernière est en générale
propre à chaque document. La dernière classe que nous utiliserons ici sera
QUndoView. Elle permet d'afficher le contenu d'une QUndoStack,
ainsi que d'y naviguer (c'est-à-dire cliquer sur un état pour voir le document tel qu'il l'était lors de ce snapshot).
Dans le cas d'une application permettant de gérer plusieurs documents, l'utilisation de
QUndoGroup est recommandée. L'expression "plusieurs documents"
est à prendre au sens général du terme. Prenons un éditeur 3D: vous pouvez vouloir gérer une pile d'undo pour les modifications de la
scène, ainsi qu'une autre indépendante pour gérer un panel de matériau. Cette classe ne sera pas utilisée ici.
II-2. Implémentation▲
II-2-1. Préparatif▲
Avant d'entrer dans les détails de chaque modification, nous allons mettre en place le nécessaire pour supporter l'undo/redo.
Voici les éléments que nous voulons présenter:
- ajout au menu "Divers" d'une action "Annuler" suivi d'une description de l'action,
- ajout au menu "Divers" d'une action "Refaire" suivi d'une description de l'action,
- annulation du changement de couleur de fond ainsi que du déplacement d'une entité,
- possibilité de fusionner des déplacements de l'entité si le temps écoulé entre 2 commandes est de moins de 5 dixièmes de seconde (afin de prendre en compte le déplacement constant à la souris, au clavier ou aux spinners) et que l'entité est la même que la précédente,
- possibilité d'afficher la pile des modifications.
La première étape consiste à créer une QUndoStack. Notre petit "éditeur" étant mono-document, nous allons la gérer à partir de la classe MainWindow. Une fois doté d'une instance de cette classe, nous allons ajouter les entrées de menus permettant de faire et défaire des actions:
QAction *
undoAct =
undoStack.createUndoAction(this
, "Annuler : "
);
undoAct->
setShortcut(QKeySequence("Ctrl+Z"
));
QAction *
redoAct =
undoStack.createRedoAction(this
, "Refaire : "
);
redoAct->
setShortcut(QKeySequence("Ctrl+Shift+Z"
));
Ces actions seront automatiquement mises à jour par QUndoStack lors de l'insertion ou du retrait d'éléments.
Nous allons maintenant ajouter "l'inspecteur", ce petit widget permettra d'avoir une vue sur l'évolution de la pile d'annulation. Il sera
présenté dans un QDockWidget dont l'affichage sera pilotable par une entrée dans le menu "Divers". Voici le code nécessaire à la création
de ce dock:
// Constructeur de MainWindow:
undoView =
new
QUndoView(&
undoStack, this
);
undoView->
setSizePolicy(QSizePolicy::
Expanding, QSizePolicy::
Expanding);
undoView->
setEmptyLabel("<Scene initiale>"
);
undoView->
setMinimumWidth(150
);
undoView->
setMaximumWidth(150
);
// [...]
// Ajout à MainWindow::createActionMenus():
QAction *
undoHistoryAct =
undoViewDock->
toggleViewAction();
menu->
addAction(undoHistoryAct);
// [...]
// Ajout à MainWindow::createDockWidget():
undoViewDock =
new
QDockWidget(this
);
undoViewDock->
setAllowedAreas(Qt::
LeftDockWidgetArea |
Qt::
RightDockWidgetArea);
undoViewDock->
setFeatures(QDockWidget::
DockWidgetMovable |
QDockWidget::
DockWidgetFloatable |
QDockWidget::
DockWidgetClosable);
undoViewDock->
setWidget(undoView);
undoViewDock->
setWindowTitle("Historique"
);
undoViewDock->
setVisible(true
);
addDockWidget(Qt::
LeftDockWidgetArea, undoViewDock);
Prenez bien garde à appeler createDockWidget avant createActionMenus si vous vous servez du QAction fourni par
QDockWidget pour piloter son affichage.
Il nous reste une dernière chose à faire avant de passer à l'implémentation des commandes que nous avons définies précédemment. Afin
de pouvoir fusionner des commandes, nous allons avoir besoin d'un identifiant. Pour ce faire, j'ai choisi ici de les (enfin, "le" dans
le cadre de ce tuto) stocker dans un en-tête sous la forme d'un enum:
#ifndef COMMANDS_H
#define COMMANDS_H
enum
CommandID
{
CID_MoveEntity
}
;
#endif
Et nous sommes enfin prêt à implémenter nos commandes !
II-2-2. Annulation du changement de couleur de fond▲
Les classes dérivant de QUndoCommand sont en général très simples puisqu'elles ne stockent que le nécessaire pour résoudre un changement d'état, voire des informations liées à la fusion des commandes de même type. Voici l'interface utilisée pour la commande de changement de couleur de fond:
class
ChangeBackgroundColor : public
QUndoCommand
{
public
:
ChangeBackgroundColor(OgreWidget *
ogreWidget, const
QColor &
previousColor, const
QColor &
nextColor);
virtual
void
undo();
virtual
void
redo();
private
:
OgreWidget *
target;
QColor prev;
QColor next;
}
;
La méthode id() nous servira afin de déterminer si 2 commandes peuvent être fusionnées. Dans ce contexte, elle renvoie simplement CID_ChangeBGColor. Les méthodes undo() et redo() sont celles permettant à la magie d'opérer. Voici le corps d'undo(), à titre d'exemple (redo() étant similaire à l'exception de la couleur appliquée):
void
ChangeBackgroundColor::
undo()
{
target->
setBackgroundColor(prev);
target->
update();
}
Afin de supporter l'annulation du changement de couleur, il nous faut une méthode pour obtenir la couleur actuellement utilisée. Nous ajoutons donc une méthode getBackgroundColor() à OgreWidget qui se chargera de nous retourner un QColor:
QColor OgreWidget::
getBackgroundColor() const
{
if
(ogreViewport)
{
Ogre::
ColourValue bgColour =
ogreViewport->
getBackgroundColour();
return
QColor::
fromRgbF(bgColour.r, bgColour.g, bgColour.b);
}
return
QColor();
}
La dernière étape avant de pouvoir naviguer à travers l'historique de la scène est de créer la commande puis l'ajouter à notre QUndoStack. Le meilleur endroit pour le faire est MainWindow::chooseBgColor(), qui devient donc:
void
MainWindow::
chooseBgColor()
{
QColor nextCol =
QColorDialog::
getColor();
QColor prevCol =
ogreWidget->
getBackgroundColor();
if
(!
nextCol.isValid())
return
;
ChangeBackgroundColor *
changeBgCommand =
new
ChangeBackgroundColor(ogreWidget, prevCol, nextCol);
undoStack.push(changeBgCommand);
}
Il est très important de noter que QUndoStack::push(QUndoCommand*) va exécuter l'action dès le push. Il est dès
lors inutile d'appeler OgreWidget::setBackgroundColor() par nous même. De même, attention aux récursions infinies si la méthode qui
instancie la commande est celle la réalisant ;)
Vous pourrez constater qu'après avoir annulé une action, les actions suivantes sont supprimées si la couleur de
fond est à nouveau changée. Plus que le fait de changer la couleur de fond, c'est l'acte de changer l'état de l'historique
qui entraîne cet effet.
Il ne vous reste plus qu'à compiler et constater que vous pouvez annuler/refaire cette modification, et ce par
notre QUndoView ou les actions ajoutées au menu:
II-2-3. Annulation du déplacement d'un objet▲
L'approche à adopter pour historiser les positions successives d'une entité est très similaire. La grosse différence
est le point d'instanciation de l'action, ainsi que la fusion des commandes. Rien dans MainWindow ne nous permet de savoir que la
modification de la position est effective. MySceneNode::setPosition est le point central de modification d'un noeud de la scène
(ici l'entité), c'est donc dans cette méthode que l'instanciation de la commande d'annulation/application se fera.
Vous pourriez être tenté de simplement créer et pousser la commande dans la stack à partir de cette méthode. Je dois insister sur un
point si vous n'avez pas vraiment lu la doc de QUndoStack: la méthode QUndoStack::push() applique la commande en l'ajoutant à la
pile! Nous allons devoir créer une méthode supplémentaire afin de régler la position d'un noeud sans le faire directement afin
d'éviter une récursion infinie. Si c'est encore un peu flou, tout devrait s'éclaircir avec le code peu à peu.
Commençons donc par regarder l'interface de la commande qui va nous permettre ceci:
class
SceneNodeMoved : public
QUndoCommand
{
public
:
SceneNodeMoved(MySceneNode *
target, const
Ogre::
Vector3 &
previousPosition, const
Ogre::
Vector3 &
nextPosition);
virtual
int
id() const
;
virtual
bool
mergeWith(const
QUndoCommand *
command);
virtual
void
undo();
virtual
void
redo();
private
:
MySceneNode *
node;
Ogre::
Vector3 prev;
Ogre::
Vector3 next;
QTime actionTime;
}
;
La méthode virtuelle id nous permettra de nous assurer qu'une fusion est possible entre 2 commandes. Elle se contente de retourner la valeur CID_MoveEntity. La dite fusion s'évalue au sein de la méthode mergeWith(). Si elle renvoie true, alors vous devez fusionner vous même les infos nécessaires; en voici le code:
bool
SceneNodeMoved::
mergeWith(const
QUndoCommand *
command)
{
if
(command->
id() !=
CID_MoveEntity)
return
false
;
const
SceneNodeMoved *
otherCommand =
dynamic_cast
<
const
SceneNodeMoved*>
(command);
if
(!
otherCommand ||
node !=
otherCommand->
node)
return
false
;
int
msElapsed =
qAbs(actionTime.msecsTo(otherCommand->
actionTime));
if
(msElapsed >
500
)
return
false
;
next =
otherCommand->
next;
actionTime =
otherCommand->
actionTime;
return
true
;
}
Comme précisé plus haut, la première chose que nous faisons est de nous assurer que la commande est bien du même type que la notre. Une fois ceci fait, les contraintes précisées en introduction de ce chapitre sont implémentées:
- le temps écoulé entre ces 2 commandes est de moins de 5 dixièmes de seconde,
- l'entité est la même que la précédente.
Si ces 2 conditions sont remplies, nous nous approprions les infos de la commande la plus récente (le paramètre).
Les méthodes undo() et redo() s'occupent de positionner l'élément de cette façon:
void
SceneNodeMoved::
undo()
{
node->
setPositionDirect(prev);
}
setPositionDirect() est la méthode dont je parlais plus haut que nous allons ajouter à MySceneNode afin d'éviter une
récursion infinie.
Passons maintenant à la création de cette commande. C'est OgreWidget qui s'occupe actuellement d'instancier les éléments de la scène,
nous allons donc lui fournir un pointeur vers undoStack à partir de MainWindow, qui elle même le transférera aux objets
nécessaires.
Une fois ceci fait, la dernière étape consiste à créer l'action et implémenter le déplacement de l'objet proprement dit:
void
MySceneNode::
setPositionDirect(const
Ogre::
Vector3 &
pos)
{
ogreSceneNode->
setPosition(pos);
emit positionChanged(pos);
}
void
MySceneNode::
setPosition(const
Ogre::
Vector3 &
pos)
{
if
(undoStack)
{
Ogre::
Vector3 currentPos =
ogreSceneNode->
getPosition();
SceneNodeMoved *
nodeMovedCommand =
new
SceneNodeMoved(this
, currentPos, pos);
undoStack->
push(nodeMovedCommand);
}
else
setPositionDirect(pos);
}
MySceneNode::setPosition() s'occupe maintenant de créer la commande et la pousser sur l'historique du document. Pour
garantir le bon fonctionnement même si une QUndoStack n'est pas fournie, nous transférons l'appel à la bonne méthode. Notez bien que
nous n'avons pas à toucher à la moindre des connections pour que le programme continue de tourner correctement (notre widget
CoordModifier3D reste toujours synchronisé).
Si tout va bien, vous devriez obtenir ce genre de résultats:
Vous trouverez les sources correspondant à ce chapitre à cette adresse (miroir http).