Development/Tutorials/Plasma4/AbstractRunner: Difference between revisions
No edit summary |
|||
Line 223: | Line 223: | ||
By clearing the icons that we have cached, the Runner is not holding on to memory allocations unnecessarily between queries. Since there may be numerous Runner plugins instantiated, hours or even days between queries and the applications that use Runner plugins such as KRunner are often long-lived this is an important kind of optimization. | By clearing the icons that we have cached, the Runner is not holding on to memory allocations unnecessarily between queries. Since there may be numerous Runner plugins instantiated, hours or even days between queries and the applications that use Runner plugins such as KRunner are often long-lived this is an important kind of optimization. | ||
== | == The Main Event: Matching Queries == | ||
The primary purpose of a Runner plugin is to return potential matches for a given query string. These matches are then presented in some fashion to the user who may select one or more of the matches. | |||
In the KRunner applicatin, when it's main window is shown a query session is started (resulting in a prepare() signal) and whenever a letter is typed a query is started. This gives the impression of "as you type" searching to the user. Threads ensure the user interface remains fluid and old query matches can run to their completion even though ignored. | |||
Whenever a Runner is asked to perform a match, the match(Plasma::RunnerContext &context) method is called. This method <b>must</b> be thread safe as it can be called simultaneously in different threads. Ensure that all data used is read-only (and thread safe when reading), local to the match method or protected by a mutex. | |||
The {{class|Plasma::RunnerContext}} passed into match offers all the information we'll need about the query being made. Besides the query itself, we can check what (if any) type the query was determined to be during initial analysis and the mimetype (if any) of a file that the query may be referencing. The {{class|Plasma::RunnerContext}} will also take care of accepting matches our Runner generates and collating them with the matches produced by other Runners that may also be in use. | |||
Let's examine the match method in our example Runner, line by line: | |||
<code cpp> | |||
void HomeFilesRunner::match(Plasma::RunnerContext &context) | |||
{ | |||
QString query = context.query(); | |||
if (query == QChar('.') || query == "..") { | |||
return; | |||
} | |||
if (!m_triggerWord.isEmpty()) { | |||
if (!query.startsWith(m_triggerWord)) { | |||
return; | |||
} | |||
query.remove(0, m_triggerWord.length()); | |||
} | |||
if (query.length() > 2) { | |||
query.prepend('*').append('*'); | |||
} | |||
</code> | |||
So far it's quite straight forward but does show a few common techniques. Before doing any more complex processing, if the query does match certain criterion the match method returns quickly. This frees up that thread in the pool for use by another Runner or for another query. | |||
We can also see the use of a "trigger word". A trigger word is used to mark a specific query so that the user can, through the use of ad-hoc pidgins, better control the results. For example, the spell checker Runner uses "spell" (translated to the current user's language, of course) as its trigger word; this allows one to type "spell plasma" and have it checked in the dictionary. This concept does not map well to all Runners, but can be a very effective technique in the right circumstances. | |||
A third technique is to modify the query (or even accept it) based on a minimal size. In this case, we turn any query that has 3 or more letters in it into a glob with a * on each end. This isn't done for queries of only one or two letters since that would likely generate far more matches than desired on any query text. To preserve the efficiency and make the results more useful, we limit globbing to only queries of at least 2 characters. Skipping some or all processing of such small queries is a common practice. | |||
<code cppqt> | |||
QDir dir(m_path); | |||
</code> | |||
Next, a {{class|QDir}} object is created. According to the Qt documentation, QDir is reentrant but not thread safe. Because of this it is safe to use a QDir object in a thread, but not to share one between different threads. So we are forced to create a local object to use in the match method. If QDir was thread safe, we could create one in the slot connected to the prepare() signal, for instance, and potentially gain some additional efficiencies. | |||
<code cppqt> | |||
QList<Plasma::QueryMatch> matches; | |||
</code> | |||
Next, a list is defined to hold the matches the Runner creates. This will allow the matches to be queued up and then added all at once at the end. This is slightly more efficient than the alternative of adding matches one at a time as they are created. | |||
<code cppqt> | |||
foreach (const QString &file, dir.entryList(QStringList(query))) { | |||
const QString path = dir.absoluteFilePath(file); | |||
ne· | |||
if (!path.startsWith(m_path)) { | |||
// this file isn't in our directory; looks like we got a query with some | |||
// ..'s in it! | |||
continue; | |||
} | |||
</code> | |||
Now to the heart of the matter! We ask the QDir object for a list of files in our target directory that match the query. We do some basic sanity checking on the result before moving on and checking if the context itself is still valid: | |||
<code> | |||
if (!context.isValid()) { | |||
return; | |||
} | |||
</code> | |||
Since the query may change while the Runner is processing in another thread, the user may not longer care about the results the Runner in this thread is currently generating. In this case, the context object we received will be marked as invalid. By checking this, particularly before doing expensive processing or spinning in a potentially large loop, the Runner can avoid using more CPU and thread pool time than necessary. This makes the user interface feel more snappy. | |||
Next, we create a {{class|Plasma::QueryMatch}} object and add it to our list of matches: | |||
<code cppqt> | |||
Plasma::QueryMatch match(this); | |||
match.setText(i18n("Open %1", path)); | |||
match.setData(path); | |||
match.setId(path); | |||
if (m_iconCache.contains(path)) { | |||
match.setIcon(m_iconCache.value(path)); | |||
} else { | |||
KIcon icon(KMimeType::iconNameForUrl(path)); | |||
m_iconCache.insert(path, icon); | |||
match.setIcon(icon); | |||
} | |||
if (file.compare(query, Qt::CaseInsensitive)) { | |||
match.setRelevance(1.0); | |||
match.setType(Plasma::QueryMatch::ExactMatch); | |||
} else { | |||
match.setRelevance(0.8); | |||
} | |||
matches.append(match); | |||
} | |||
</code> | |||
{class|Plasma::QueryMatch}} objects are small data containers, little more than glorified structs really. As such, they are generally created on the stack, thread safe and can be copied and assigned without worry. | |||
We set several of the properties on the match, including the text and icon that will be shown in the user interface. The id that is set is specific to our Runner and can be used for later saving, ranking and even re-creation of the match. The data associated with the match is also specific to the runner; any QVariant may be associated with the match, making later execution of the match easier. | |||
Finally, a relevance between 0.0 and 1.0 is assigned according to how "close" a match is to the query according to the Runner. In the case the Home Files runner, if the query matches a file exactly the type of the match is set to "ExactMatch". Other possible match types of interest to Runner plugins include PossibleMatch (the default) and InformationalMatch. InformationMatch is an interesting variation: it is a match which offers information but no further action to be taken; an example might be the answer to a mathematical equation. Not only are InformationalMatch matches shown with higher ranking than PossibleMatch matches, but when selected the data value is copied to the clipboard and put into the query text edit. | |||
Finally, once the foreach loop is completed, we add any matches created to the context that was passed in: | |||
<code cppqt> | |||
context.addMatches(context.query(), matches); | |||
} | |||
</code> | |||
That's it! The runner does not need to worry if the matches are still valid for the current query and can create any number of matches as it goes. It can even offer them up in batches by either calling context.addMatch(match) for each match created or calling context.addMatches every so often. Generally Runners match quickly and so batch up their finds and submit them all at once. | |||
== Taking Action On Matches == | == Taking Action On Matches == |
Revision as of 23:27, 28 October 2009
Tutorial Series | Plasma |
Previous | None |
What's Next | n/a |
Further Reading | n/a |
Abstract
The Plasma workspace provides an application called KRunner which, among other things, allows one to type into a text area which causes various actions and information that match the text appear as the text is being typed.
This functionality is provided via plugins loaded at runtime called "Runners". These plugins can be used by any application using the Plasma library. This tutorial explores how to create a runner plugin.
Basic Anatomy of a Runner
Plasma::AbstractRunner is the base class of all Runners. It provides the basic structure for Runner plugins to:
- Perform one-time setup upon creation
- Perform setup and teardown before and after matching starts
- Create and register matches for a given search term
- Define secondary actions for a given match
- Take action on a given match registered by the runner
- Show configuration options
In addition to Plasma::AbstractRunner there are three other important classes that we will be using from the Plasma library:
- Plasma::RunnerContext which gives our plugin information about the current query
- Plasma::QueryMatch which represents a single match for a given query
- Plasma::RunnerSyntax which is used to advertise the query syntax understood by the plugin.
Each of these classes will be covered in more detail as we encounter them in the Runner plugin implementation.
Creating a Runner Plugin Project
In this tutorial we will be creating a Runner plugin that finds files in the user's home directory that match the query and offers to open them. We begin by setting up the basic project files including a CMakeLists.txt file for building the plugin, a .desktop file to register the plugin, a class definition in a header file and a source code file containing the class implementation.
The CMakeLists.txt File
CMake makes it very easy to set up the build system for our plugin:
- Project Needs a name, of course
project(runnerexmaple)
set(KDE_MIN_VERSION "4.3.60") # for the < 4.2 macro
find_package(KDE4 4.3.60 REQUIRED)
include (KDE4Defaults)
include (MacroLibrary)
add_definitions(${QT_DEFINITIONS} ${KDE4_DEFINITIONS})
include_directories(${CMAKE_SOURCE_DIR} ${CMAKE_BINARY_DIR} ${KDE4_INCLUDES})
- We add our source code here
set(example_SRCS homefilesrunner.cpp)
- Now make sure all files get to the right place
kde4_add_plugin(plasma_runner_example_homefiles ${example_SRCS})
target_link_libraries(plasma_runner_example_homefiles ${KDE4_PLASMA_LIBS} ${KDE4_KIO_LIBS})
- Install the library and .desktop file
install(TARGETS plasma_runner_example_homefiles DESTINATION ${PLUGIN_INSTALL_DIR})
install(FILES plasma-runner-example-homefiles.desktop DESTINATION ${SERVICES_INSTALL_DIR})
The first 10 lines are pure boilerplate common to any stand-alone KDE project that uses CMake. We add our source code to the project on line 13, define a plugin 16 and what libraries it needs to link to on line 17. Runner plugins always need to link against the Plasma libraries, but may also require other libraries such as the KIO libraries in this case (for usage of KRun to open matching files). Lines 20 and 21 install the library and .desktop file to the appropriate locations.
The .desktop Services File
To register our plugin with the system so that applications such as KRunner are aware of it, we need to create and install a .desktop file that describes the plugin.
By convention, all Runner plugin .desktop file names start with "plasma-runner-" followed by a unique name part (in this case "example-homefiles") and suffixed with ".desktop".
The contents of this file, as seen below, contain the name, description and technical details about the plugin.
[Desktop Entry]
Name=Home Files
Comment=Part of a tutorial demonstrating how to create Runner plugins
Type=Service
X-KDE-ServiceTypes=Plasma/Runner
X-KDE-Library=plasma_runner_example_homefiles
X-KDE-PluginInfo-Author=Aaron Seigo
[email protected]
X-KDE-PluginInfo-Name=example-homefiles
X-KDE-PluginInfo-Version=0.1
X-KDE-PluginInfo-Website=http://plasma.kde.org/
X-KDE-PluginInfo-Category=Examples
X-KDE-PluginInfo-Depends=
X-KDE-PluginInfo-License=GPL
X-KDE-PluginInfo-EnabledByDefault=true
There are four entries in the .desktop file in particular that are critical to the proper functioning of the plugin: Type, X-KDE-ServiceTypes, X-KDE-Library and X-KDE-PluginInfo-Name.
The Type entry tells the system that this is a plugin (or "Service") and the ServiceTypes entry defines this plugin as being a Runner. These two key-value pairs are the same in all runners. The X-KDE-PluginInfo-Name entry is used internally to map configuration data to this runner; its value must be unique among all other installed runners on the system. The X-KDE-Library entry must match the name of the library defined in the CMakeLists.txt file.
The other entries such as Name, Description, licensing and authorship are information and may be shown in the user interface but have no other technical importance. Try to avoid using jargon in the Name and Description entries, however, to make it easy for people to understand what your plugin does.
The Class Definition (Header file)
Our class definition for this project looks as follows:
- ifndef HOMEFILES_H
- define HOMEFILES_H
- include <Plasma/AbstractRunner>
- include <QHash>
- include <KIcon>
class HomeFilesRunner : public Plasma::AbstractRunner
{
Q_OBJECT
public:
HomeFilesRunner(QObject *parent, const QVariantList &args);
void match(Plasma::RunnerContext &context);
void run(const Plasma::RunnerContext &context, const Plasma::QueryMatch &match);
void createRunOptions(QWidget *widget);
void reloadConfiguration();
protected Q_SLOTS:
void init();
void prepareForMatchSession();
void matchSessionFinished();
private:
QHash<QString, KIcon> m_iconCache;
QString m_path;
QString m_triggerWord;
};
- endif
Even though it's a full featured Runner plugin it is just a handful of methods, each of which will be examined.
Initializing the Runner
In typical usage, a Runner plugin is instantiated once and then reused multiple times for different queries. Each time a Runner is launched with a query to match against, it is called from a new thread so some parts of our plugin will need to be thread safe.
Initialization of the Runner is done in four parts: the plugin declaration macro, the constructor, init() and in prepareForMatchSession(), none of which need to be thread safe as they are all guaranteed to be called from the main application thread prior to any query matching activity.
The Plugin Declaration Macro
At the end of our implementation (.cpp) file we have this:
K_EXPORT_PLASMA_RUNNER(example-homefiles, HomeFilesRunner)
- include "homefilesrunner.moc"
The moc file include looks familiar enough from other Qt code, but the macro right above it probably does not. This macro creates the factory functions needed to load the plugin at runtime. The first parameter, example-homefiles, is the same value as the X-KDE-PluginInfo-Name= entry in the .desktop file. The second parameter is the name of the AbstractRunner subclass.
The Constructor
The constructor look like this:
HomeFilesRunner::HomeFilesRunner(QObject *parent, const QVariantList &args)
: AbstractRunner(parent, args)
{
setIgnoredTypes(Plasma::RunnerContext::NetworkLocation |
Plasma::RunnerContext::Executable |
Plasma::RunnerContext::ShellCommand);
setSpeed(SlowSpeed);
setPriority(LowPriority);
setHasRunOptions(true);
}
The parent and args parameters are critical to the proper loading of the plugin and are passed into the AbstractRunner superclass in the initialization list. In the body of the constructor a number of properties are set, all of which are optional. Often the defaults are good enough, but in some cases it can make quite a difference performance wise to have the right properties set.
The firs property set, on lin 4, is the type of matches to ignore. Before a Runner is passed a query some analysis is performed to determine whether the query might refer to a Directory, File, NetworkLocation, Executable, ShellCommand, or Help request as define Plasma::RunnerContext in the Type enumeration. There are two special value in that enumeration: UnknownType which signifies a match which does not seem to fit any of the other categories and FileSystem which is a synonym for "Directory | File | Executable | ShellCommand". In our particular case, we only care for files and so skip those types which are probably not files at all. This will prevent our Runner from being launched unnecessarily.
Next we set the expected speed and priority of the runner. These properties affect the scheduling of the Runner when there is a pool of plugins to choose from during query matching. Slower runners are given limited access to the thread pool and lower priority runners are run after higher priority ones have been launched. Speed may be adjusted dynamically at runtime based on real performance, so marking a runner as "slow" will not create a penalty on machines that are faster and likewise setting a runner to "normal" speed may still result in it being demoted to "slow" if it performs poorly.
Finally, setHasRunOptions is called with the true value. This signifies that the matches generated by this Runner plugin can be configured. This is used, for instance, by the shell command runner to allow the user to define a different user to run the command as.
init()
The init() method is a protected slot. That it is a slot is critical due to an API oddity in AbstractRunner that will be addressed in a future release of the Plasma library (probably KDE5, since it requires a binary incompatible change). The init() method should contain any set up that needs to happen prior to matching queries that should be done exactly once during the lifespan of the plugin.
In the Home Files Runner the init() method is very simple:
void HomeFilesRunner::init()
{
reloadConfiguration();
connect(this, SIGNAL(prepare()), this, SLOT(prepareForMatchSession()));
connect(this, SIGNAL(teardown()), this, SLOT(matchSessionFinished()));
}
It loads the configuration for the runner (see below) and connects up two critical signals: prepare() and teardown().
init() should not load large amounts of data if unneeded or connect to external sources of information that may wake up the process. A common mistake is to connect to signals in the D-Bus interface of an external application. This results in the application using the Runner plugin to wake up whenever the application it is connected to also wakes up. A better way to do this is to use the prepare() signal.
prepare() and teardown()
The final place that initialization may occur is in a slot connected to the prepare() signal. This signal is emitted whenever matches for queries are going to commence. Zero, one or more query match requests may then be made after which the teardown() signal will be emitted. These are perfect places to connect to external signals or update data sets as these signals are emitted precisely when the Runner is about to be (or cease being) active.
In our example, we have connected to both signals for example purposes though only the matchSessionFinished() slot does anything actually useful in this case:
void HomeFilesRunner::matchSessionFinished()
{
m_iconCache.clear();
}
By clearing the icons that we have cached, the Runner is not holding on to memory allocations unnecessarily between queries. Since there may be numerous Runner plugins instantiated, hours or even days between queries and the applications that use Runner plugins such as KRunner are often long-lived this is an important kind of optimization.
The Main Event: Matching Queries
The primary purpose of a Runner plugin is to return potential matches for a given query string. These matches are then presented in some fashion to the user who may select one or more of the matches.
In the KRunner applicatin, when it's main window is shown a query session is started (resulting in a prepare() signal) and whenever a letter is typed a query is started. This gives the impression of "as you type" searching to the user. Threads ensure the user interface remains fluid and old query matches can run to their completion even though ignored.
Whenever a Runner is asked to perform a match, the match(Plasma::RunnerContext &context) method is called. This method must be thread safe as it can be called simultaneously in different threads. Ensure that all data used is read-only (and thread safe when reading), local to the match method or protected by a mutex.
The Plasma::RunnerContext passed into match offers all the information we'll need about the query being made. Besides the query itself, we can check what (if any) type the query was determined to be during initial analysis and the mimetype (if any) of a file that the query may be referencing. The Plasma::RunnerContext will also take care of accepting matches our Runner generates and collating them with the matches produced by other Runners that may also be in use.
Let's examine the match method in our example Runner, line by line:
void HomeFilesRunner::match(Plasma::RunnerContext &context)
{
QString query = context.query();
if (query == QChar('.') || query == "..") {
return;
}
if (!m_triggerWord.isEmpty()) {
if (!query.startsWith(m_triggerWord)) {
return;
}
query.remove(0, m_triggerWord.length());
}
if (query.length() > 2) {
query.prepend('*').append('*');
}
So far it's quite straight forward but does show a few common techniques. Before doing any more complex processing, if the query does match certain criterion the match method returns quickly. This frees up that thread in the pool for use by another Runner or for another query.
We can also see the use of a "trigger word". A trigger word is used to mark a specific query so that the user can, through the use of ad-hoc pidgins, better control the results. For example, the spell checker Runner uses "spell" (translated to the current user's language, of course) as its trigger word; this allows one to type "spell plasma" and have it checked in the dictionary. This concept does not map well to all Runners, but can be a very effective technique in the right circumstances.
A third technique is to modify the query (or even accept it) based on a minimal size. In this case, we turn any query that has 3 or more letters in it into a glob with a * on each end. This isn't done for queries of only one or two letters since that would likely generate far more matches than desired on any query text. To preserve the efficiency and make the results more useful, we limit globbing to only queries of at least 2 characters. Skipping some or all processing of such small queries is a common practice.
QDir dir(m_path);
Next, a QDir object is created. According to the Qt documentation, QDir is reentrant but not thread safe. Because of this it is safe to use a QDir object in a thread, but not to share one between different threads. So we are forced to create a local object to use in the match method. If QDir was thread safe, we could create one in the slot connected to the prepare() signal, for instance, and potentially gain some additional efficiencies.
QList<Plasma::QueryMatch> matches;
Next, a list is defined to hold the matches the Runner creates. This will allow the matches to be queued up and then added all at once at the end. This is slightly more efficient than the alternative of adding matches one at a time as they are created.
foreach (const QString &file, dir.entryList(QStringList(query))) {
const QString path = dir.absoluteFilePath(file);
ne·
if (!path.startsWith(m_path)) {
// this file isn't in our directory; looks like we got a query with some
// ..'s in it!
continue;
}
Now to the heart of the matter! We ask the QDir object for a list of files in our target directory that match the query. We do some basic sanity checking on the result before moving on and checking if the context itself is still valid:
if (!context.isValid()) {
return;
}
Since the query may change while the Runner is processing in another thread, the user may not longer care about the results the Runner in this thread is currently generating. In this case, the context object we received will be marked as invalid. By checking this, particularly before doing expensive processing or spinning in a potentially large loop, the Runner can avoid using more CPU and thread pool time than necessary. This makes the user interface feel more snappy.
Next, we create a Plasma::QueryMatch object and add it to our list of matches:
Plasma::QueryMatch match(this);
match.setText(i18n("Open %1", path));
match.setData(path);
match.setId(path);
if (m_iconCache.contains(path)) {
match.setIcon(m_iconCache.value(path));
} else {
KIcon icon(KMimeType::iconNameForUrl(path));
m_iconCache.insert(path, icon);
match.setIcon(icon);
}
if (file.compare(query, Qt::CaseInsensitive)) {
match.setRelevance(1.0);
match.setType(Plasma::QueryMatch::ExactMatch);
} else {
match.setRelevance(0.8);
}
matches.append(match);
}
{class|Plasma::QueryMatch}} objects are small data containers, little more than glorified structs really. As such, they are generally created on the stack, thread safe and can be copied and assigned without worry.
We set several of the properties on the match, including the text and icon that will be shown in the user interface. The id that is set is specific to our Runner and can be used for later saving, ranking and even re-creation of the match. The data associated with the match is also specific to the runner; any QVariant may be associated with the match, making later execution of the match easier.
Finally, a relevance between 0.0 and 1.0 is assigned according to how "close" a match is to the query according to the Runner. In the case the Home Files runner, if the query matches a file exactly the type of the match is set to "ExactMatch". Other possible match types of interest to Runner plugins include PossibleMatch (the default) and InformationalMatch. InformationMatch is an interesting variation: it is a match which offers information but no further action to be taken; an example might be the answer to a mathematical equation. Not only are InformationalMatch matches shown with higher ranking than PossibleMatch matches, but when selected the data value is copied to the clipboard and put into the query text edit.
Finally, once the foreach loop is completed, we add any matches created to the context that was passed in:
context.addMatches(context.query(), matches);
}
That's it! The runner does not need to worry if the matches are still valid for the current query and can create any number of matches as it goes. It can even offer them up in batches by either calling context.addMatch(match) for each match created or calling context.addMatches every so often. Generally Runners match quickly and so batch up their finds and submit them all at once.