by Mark Summerfield |
This article shows how to maintain sets of "attributes" (QVariant values), and how to allow users to view and edit them using dialogs that are created dynamically based on the attributes and their types.
The Attributes class described in this article holds a set of QVariants, and can create a dialog to present the QVariants to the user in an appropriate way. For example, if a value is an integer, the dialog will represent that integer using a QSpinBox. For colors, fonts and file names, the current color, font or file name is shown, and an ellipsis button (...) is provided that invokes a suitable dialog through which the user can choose a value.
The dialog is not specified or laid out in code; instead we simply call an Attributes object's setAttribute() function for each attribute we want to hold, giving each one a name and an initial value. When we want the user to be able to edit the attributes, we just call dialog(), and if it returns true we know that the user clicked OK and that the attributes have been updated.
If you're an object-oriented purist, or performance obsessed, you may want to bail out now. We use explicit type checking and create dynamic arbitrary data structures using QMap and QVariant; this gives us great flexibility, but at the cost of some efficiency and a certain amount of type safety.
Here's how to create an Attributes object on the stack with some default values, and present a dialog based on the object to the user:
Attributes atts; atts.setAttribute("Text", "Qt Conference"); atts.setAttribute("Type", QStringList() << "One-Off" << "TODO"); atts.setAttribute("Start", QDate(2004, 5, 10)); if (atts.dialog(this, "Event")) { QString text = atts.value("Text").toString(); QString type = atts.value("Type").toString(); QDate start = atts.value("Start").toDate(); ... }
Each attribute has a name, a value, and some optional "extra" data. The name is used as the label in the dialog (with accelerators automatically added where possible inside the dialog() function). The value's type determines what sort of widget is used for presenting the value; we'll see what the extra data is in a moment. If the user clicks OK, the attribute data is updated from the dialog's values; if the user clicks Cancel the data is not changed.
Now lets look at a more sophisticated example. This time we'll suppose that we have a data structure populated with pointers to a number of Attributes objects. We'll also exercise more control over the dialog that's generated.
Attributes *atts = new Attributes; atts->setAttribute("Alias", "Administrator"); QStringList list; list << "Active" << "Completed" << "Suspended"; QMap<QString, QVariant> extra; extra["selected"] = 1; atts->setAttribute("Status", list, extra); extra.clear(); extra["minimum"] = 0; extra["maximum"] = 100; extra["suffix"] = "%"; atts->setAttribute("Rating", 50, extra); extra.clear(); extra["hidden"] = true; atts->setAttribute("Internal ID", "WX257C", extra); atts->setAttribute("Background", cyan); atts->setAttribute("Font", font()); extra.clear(); extra["fileFilter"] = QString("Executables (*;*.exe)"); const QString empty; atts->setAttribute("Email Client", empty, extra); atts->setAttribute("Web Browser", empty, extra);
The screenshots show the dialogs after some user interaction.
The extra parameter is a map of names to values. We've reused the same extra variable for the sake of convenience, which is why we clear() it before giving it fresh values. For a QStringList attribute, like "Status", there is one extra defined: "selected". The "selected" extra's integer value is the index of the selected item in the QComboBox that presents the QStringList's strings to the user. For integers like "Rating", we can set "minimum", "maximum", "prefix", and "suffix" extras if we wish.
Sometimes it is convenient to have attributes that we use within our code, but which we don't want to make available to users. This is achieved by setting "hidden" in the extra parameter to true. If we don't want a default value, we must provide an empty value of the right type so that Attributes knows what type of value it can accept. If we set "fileFilter" in the extra parameter, the value is taken to be a file name of type QString, and an ellipsis button that invokes a file dialog is provided. Similarly, a color value has an ellipsis button that invokes a color dialog, and a font value has one that invokes a font dialog.
Implementing Attributes |
The implementation of Attributes is easy except for the dialog() function. Here, we'll confine ourselves to reviewing some of the key features of the code.
Each attribute is stored in a PrivateAttribute object; this object is designed to be accessed from Attributes objects, and should never be directly used in your own code.
typedef QMap<QString, QVariant> StringVariantMap; class PrivateAttribute { public: PrivateAttribute() : name(""), index(-1) {} PrivateAttribute(const QString &name, int index, QVariant value, StringVariantMap extra = StringVariantMap()) : name(name), index(index), value(value), extra(extra) {} QString name; int index; QVariant value; StringVariantMap extra; QWidget *widget; };
The index member stores the position of the attribute. Attributes are displayed in the dialog() in index order, and this order is determined by the order in which attributes are created by calls to Attributes::setAttribute(). The widget pointer is used by the dialog() function.
The Attributes class definition follows:
class Attributes : public QObject { Q_OBJECT public: Attributes(); Attributes(const Attributes &other); Attributes &operator=(const Attributes &other); int count() const { return attributes.count(); } QStringList names(bool indexOrder = false) const; bool dialog(QWidget *parent, const QString &caption, bool autoMnemonics = true); QVariant value(const QString &name) const; int index(const QString &name) const; StringVariantMap extra(const QString &name) const; public slots: void setAttribute(const QString &name, QVariant value, StringVariantMap extra = StringVariantMap()); void removeAttribute(const QString &name) { attributes.remove(name); } void clear() { attributes.clear(); } private slots: void mapped(const QString &name); private: QString insertMnemonic(const QString &text, QString *allowed); int nextIndex; QString pixmapPath; QString filePath; QMap<QString, QFont> fonts; QDialog *dlg; QSignalMapper *mapper; QMap<QString, PrivateAttribute> attributes; };
We will omit the code for the copy constructor and the assignment function since they do little more than memberwise copying. The names() function could be implemented with the single statement
return attributes.keys();
but our implementation (not shown) provides the ability to order the names.
We'll also skip the index() and extra() functions, since the coverage of value() is sufficient to understand them.
Attributes::Attributes() : nextIndex(0), pixmapPath("."), filePath("."), dlg(0), mapper(0) { }
We use the pixmapPath and filePath strings, and the fonts map to record temporary transient data. This is useful if dialog() is invoked repeatedly for the same Attributes object; e.g., maintaining the last path used for a file dialog. nextIndex is an internal counter that ensures that each time an attribute is added (using setAttribute()), it is ordered after those that were added previously.
QVariant Attributes::value(const QString &name) const { if (!attributes.contains(name)) return QVariant(); const PrivateAttribute &attr = attributes[name]; if (attr.value.type() == QVariant::StringList) return attr.value.toStringList()[ attr.extra["selected"].toInt()]; return attr.value; }
We return an invalid QVariant if the attribute doesn't exist. If the attribute's value is a string list, we return the selected string.
void Attributes::setAttribute(const QString &name, QVariant value, StringVariantMap extra) { if (value.type() == QVariant::CString) value.cast(QVariant::String); if (!attributes.contains(name)) { if (value.type() == QVariant::StringList) { if (!extra.contains("selected")) extra["selected"] = 0; } else if (value.type() == QVariant::UInt) { if (!extra.contains("minimum")) extra["minimum"] = uint(0); } attributes[name] = PrivateAttribute(name, nextIndex++, value, extra); } else { PrivateAttribute &attr = attributes[name]; attr.value = value; if (extra.count()) { StringVariantMap::const_iterator i = extra.constBegin(); for (; i != extra.constEnd(); ++i) attr.extra[i.key()] = i.data(); } } }
The setAttribute() function has two modes of operation. If the attribute name doesn't exist, we create a new attribute with the given value and extra data, providing defaults for the extra data where necessary; otherwise we set the attribute's value to value, and update its extra data with the new extra data.
The dialog() function is quite long; so we'll just quote and explain some extracts from the code. There are blocks of similar code for each type we handle, so we only need to show snippets from a sample type to convey the key ideas.
bool Attributes::dialog(QWidget *parent, const QString &caption, bool autoMnemonics) { dlg = new QDialog(parent); dlg->setCaption(caption); mapper = new QSignalMapper(dlg); QVBoxLayout *vbox = new QVBoxLayout(dlg, 5, 5); QHBoxLayout *hbox = 0;
The dialog uses a signal mapper to capture ellipsis button clicks and respond appropriately to them. The whole dialog is laid out vertically, with each attribute occupying a successive horizontal layout within the vertical layout.
After the initial declarations we iterate over each attribute. This serves two purposes: firstly we need to know the widest label so that we can make all the labels the same width, and secondly we want to create an integer-to-string map that maps each attribute's index position to its name---this is so that we can lay out each attribute in index order.
QMap<int, QString> order; ... QMap<int, QString>::const_iterator j = order.constBegin(); for (; j != order.constEnd(); ++j) { PrivateAttribute &attr = attributes[j.data()]; if (attr.extra.contains("hidden") && attr.extra["hidden"].toBool()) continue; value = attr.value;
The function's main loop iterates over the names of the attributes; any that are hidden, or of a type that we cannot handle, are ignored.
For the rest, we copy their name and try to insert an ampersand ('&') to create a mnemonic if this is possible. We use a simple and imperfect algorithm (not shown) that works as follows. We hold a string containing the letters A to Z. For each name, we see if its first letter is in the string; if it is, we insert the ampersand before the letter in the name and delete that letter from the string. Otherwise, we do the same for any letter in the name that is preceded by a space; if that doesn't work, we do the same for any letter in the name. If no strategy works, we don't add a mnemonic.
QLabel *label = new QLabel(text, dlg); label->setFixedWidth(labelWidth); hbox = new QHBoxLayout(vbox); hbox->addWidget(label);
We create a QLabel for each attribute, using its name (possibly with an ampersand) for its text. We make it fixed width (based on the widest label) and add it to a new horizontal layout.
switch (type) { case QVariant::String: lineEdit = new QLineEdit(value.toString(), dlg); if (attr.extra.contains("maximum")) lineEdit->setMaxLength( attr.extra["maximum"].toInt()); attr.widget = lineEdit; label->setBuddy(lineEdit); hbox->addWidget(lineEdit, 2); if (attr.extra.contains("fileFilter")) { button = new QPushButton(tr("..."), dlg); button->setFixedWidth(ellipsisWidth); connect(button, SIGNAL(clicked()), mapper, SLOT(map())); mapper->setMapping(button, attr.name); hbox->addWidget(button); } break; ...
What we do next depends on the attribute's type. In the case of a string, we create a line edit with the string's value and set the maximum length if that's been given in the extra data. We remember the widget used (attr.widget = lineEdit), and add it to the horizontal layout. If the string is holding a file name (indicated by an extra "fileFilter" data item), we create an ellipsis button and connect it to the signal mapper. We also add the button to the horizontal layout.
Once all the widgets for the attributes have been added, we create another horizontal layout and add a stretch followed by an OK and a Cancel button, suitably connected.
bool result = false; if (dlg->exec()) { QMap<QString, PrivateAttribute>::iterator i = attributes.begin(); for (; i != attributes.end(); ++i) { if (i.data().extra.contains("hidden") && i.data().extra["hidden"].toBool()) continue;
Next we show the dialog to the user. If they click OK we iterate over the attributes, again skipping any that are hidden.
switch (type) { case QVariant::String: lineEdit = (QLineEdit *)i.data().widget; i.data().value = lineEdit->text(); break; ... }
We retrieve the data from the remembered widgets (which vary depending on the attributes' types), updating the Attributes object with the updated values. Finally, we delete the dialog.
If any of the attributes has an ellipsis button which the user clicked, the mapped() function is called. Here's an extract from it, to show what happens in the case of a file name string.
void Attributes::mapped(const QString &name) { PrivateAttribute &attr = attributes[name]; ... QString fileName; switch (attr.value.type()) { case QVariant::String: if (attr.extra.contains("fileFilter")) { fileName = QFileDialog::getSaveFileName( filePath, attr.extra["fileFilter"].toString(), dlg, "", tr("Choose a file")); if (!fileName.isEmpty()) { ((QLineEdit *)attr.widget)-> setText(fileName); filePath = QDir(fileName).absPath(); } } break; ... }
If a file name ellipsis button is pressed, we present the user with a file dialog. If the user chooses a file, we set the file name line edit in the dialog to show it. We also record the path so that the next time the dialog is invoked by this Attributes object, the path will be the one the user last used. Notice that we do not update the attributes object here. That is done in dialog() if the user closed the dialog by clicking OK.
Conclusion |
Attributes could be implemented in other ways, for example, using a QGridLayout. If there are lots of attributes, using a QTable, or a QScrollBox might be necessary; or each attribute could have a "group" name (defaulting to, say, "General"), and we could create a tabbed dialog, with groups corresponding to tabs.
Copyright © 2005 Trolltech | Trademarks |