Commonalities in design
Both KRunner and Sprinter share some design similarities under the hood, and we need to start there to understand how either works. Both are built around the idea of multiple "runners" that process a query string, with each runner returning a specific kind of matches. So we have runners for applications, shell commands, equations, unit conversions, listing windows, etc. etc. Each of these tasks has a single runner dedicated to it. This allows a few things: fine grained control of the feature set (this allows Kickoff to only use the application related runners, for instance), threading of the matching (by chunking them into these domain specific runners) and writing of small runners that do one thing and do it well (leading, one hopes, to better quality code and more of them).
Runners are told about the query by having a QueryContext object passed to it. (In KRunner, this were called RunnerContext.) This class not only holds information about the query (included pre-computed information such as "is this item a filepath?") but it serves as a sort of token: when the query changes all the copies of the old QueryContext are invalidated in a thread-safe manner without any locking. Runners are free to finish their matching in a thread and when they go to register their matches (or even before), the invalid QueryContext will prevent these now stale matches from getting mixed in.
Runners return matches in the form of QueryMatch objects which hold all the information about a match: its title, subtext, actions, icons, etc. This is data-only and can be freely (and cheaply) passed around between objects and threads without problems.
The runners are managed by a RunnerManager class. This class loads and manages the individual runners and also coordinates the query processing. It also starts execution of a QueryMatch: when a given match is selected by the user, it hands that QueryMatch to the correct runner which then "starts" it (whatever that might mean for the given runner).
In summary: runners, RunnerCotext, QueryMatch and RunnerManager. Sprinter has all of these concepts as well. They work well and it will make porting in future easier. From here, however, Sprinter diverges from KRunner.
Ready for QML
The main change here is that RunnerManager is a QAbstractItemModel. This means it can be plugged into a QML UI with ease (there is now a testing QML interface in the repository, even) or any QWidget based item view (there is also a testing app that uses a QTreeView in the repository). All QueryMatches returned by runners are now collected behind the scenes by RunnerManager and the application gets access via the model. This not only simplifies things a lot but it allows RunnerManager to more optimally handle the collection of queries.
Unblocking the user interface
Presenting a fluid user interface requires that the rendering is allowed to do its thing. With QML2, we have a render thread (yay!) but the QML itself still runs in the main application thread along with things like the main event loop. Given that many other things may be happening there, Sprinter needs to keep it free of unnecessary processing. To accomplish this, Sprinter has a thread that sits behind the RunnerManager that does most of the work that KRunner's RunnerManager did.
The only thing that now happens in the main UI thread is passing the query string, requesting execution of a match and synchronizing the model. The latter is the most interesting bit: runners (which are running in their own threads) may return new matches at any point in time. However, the model has to always be consistent with the data behind it. So when fresh matches arrive, they are put into a pool of unsychronized matches. This is quite fast thanks to the shared containers of Qt. The RunnerManager is then informed that new matches are available so it can request a synchronization run. All of this communication happens using signals and slots and so is both thread friendly and blocks minimally as events are queued automatically in the respective thread event loops.
When synchronization starts, each runner's collection of unsync'd matches are briefly locked and merged into a synchronized collection. There are a number of fast paths that cover common cases, so this is usually also very fast. Blocking is therefore kept to a minimum and latency is quite good, leaving the user interface to do its thing and remain fluid.
The other important thing that moves into Sprinter's RunnerManager thread is session setup. When the user begins a query the query is updated as they type. When the user dismisses the user interface, the query session is considered completed. Some runners may have some setup and teardown to do to get ready for a session. In KRunner this was done in the main application / UI thread and contributes significantly to slowdowns in the user interface being ready for you to start typing into. It also meant that every runner had to be ready before any runn could start.
Sprinter resolves this by threading the session setup. Each runner is asked to create a RunnerSesssionData object, and this is done in parallel using the thread pool. (By default a RunnerSesssionData* is created for the runner, so if the runner needs nothing special, no code need be written.) If one runner takes 1ms to complete this step and another takes a full second, the fast runner can start returning queries immediately. Since this all happens outside the user interface, the user can just keep on typing away and the interface can continue updating without pause.
Unlike in KRunner where all matches were batched together in RunnerManager, each runner's matches are held separately in their RunnerSessionData object. This allows synchronization to be done in chunks and keeps runners out of each other's way.
In KRunner if a runner had to launch a network job or take some other asynchronous action, then it had to set up an event loop and block in its thread. This was not a great solution since that meant that the thread was now occupied until it was done. Given that there are a limited number of threads in the pool, this can easily lead to thread starvation. To resolve that, some runners went to even greater
hacks lengths to prevent more than one match running.
Sprinter solves this with the RunnerSessionData object. This is handed in to the matching method of the runner and if the match is an asynchronous process the runner can start that process, store some state in its RunnerSessionData object and then return. This frees up the thread pool and, in many cases even more importantly, allows the RunnerSessionData object to cancel or merge old requests with new ones. It also means that a runner can return matches in batches rather than all at once. Of course, if the user has moved on to another query, these matches will be discarded.
As with the model synchronization, this all gets done using Qt signals/slots so that the requests are queued and run in the appropriate threads without having to figure this out manually.
Currently the RunnerSessionData objects live in the RunnerManager's helper thread. There is a note in the code to consider moving them to their own thread altogether, but if it turns out not to be necessary in practice then I'd like to prevent spawning more threads than needed. This change would be extremely trivial to accomplish (~3 lines of code) so I'm postponing making a decision there.
Another annoyance in KRunner is that a match could not simply be updated. The KRunner UI does a reasonable job of masking this by trying to match up new matches with existing entries in the UI but this is not a great answer. It also has severe limitations: if you type "time" into KRunner you will see the current time .. and there it sits, never updating. You have to backspace to "tim" and then type an "e" again to see it update.
Using of the RunnerSessionData object and its support for updating matches a runner can schedule updates of matches for the future. This is still very much a work-in-progress part of Sprinter, but it already works decently. Type "time" and you get the time alright, but it also updates second by second to show you the time right now. Best of all: no other runner is disturbed or woken up to accomplish this and only that one match is updated (rather than replaced) in the model causing minimal perturbations in the user interface.
I keep mentioning RunnerSessionData. It may sound like the Silver Bullet that fixes everything, and to some degree it is. Looking back at KRunner's code, proper lifecycle management of the query session was probably the largest oversight. Sprinter's runners can now choose to store data in the main runner instance, in session data objects or local to the match method itself. The number of issues resolved simply by having the session data object around has been really impressive.
It also makes management of session data really simple. When the user starts querying, the session data is set up. When they are done, the session data object is deleted and it can clean up in its destructor. This is a familiar pattern and should lead to better quality code.
It also means that if more than one query session is started on the same runner, we don't have to worry at all about incorrect or conflicting data. With KRunner, you had to use one RunnerManager per instance of the user interface. Sprinter is getting close to being able to have the same RunnerManager used in multiple places with multiple queries at once without problems. The missing bit right now is exposing query sessions themselves, which will require a bit of API in RunnerManager. Before implementing that, I want to see the actual use cases for it and get the "single query session" working perfectly first.
More expressive matches
Sprinter's QueryMatches are designed to tell more about the thing they represent than KRunner's do. To start with, they actually advertise what they are: application, books, desktop actions, etc. This will allow the user interface to sort them out visually from one another; best of all, this can be done with a simple QSortFilterProxyModel which basically for free gives Sprinter the ability to show multiple arrangements (sortings, filterings) of the same set of matches. The end result is that eBooks can be shown separately from applications; application and desktop interaction items (e.g. app menu actions, desktop switching, etc) can be rendered visualy different from informational matches; the user can say "no, just show me apps, pls".
I have not yet fully fleshed out all the properties that will end up in QueryMatch at the end of the day. I will be allowing the runners that appear to inform the code as to what is needed, so I expect this to grow organically now that the rough lines are drawn in.
In KRunner, the idea was to just return a bunch of matches. To be honest, it was a failure of imagination on my part to consider that we'd hook this up to the internet and return possibly 100s of matches. I figured that most runners would return a couple matches at best and for everything else a "top 10" would be enough. Bzzt, wrong, hello McFly!
Sprinter has the idea of paging results now. Each RunnerSessionObject has a page size for runners to use to know how many matches to return at the most. It also has a page property so that the RunnerManager (and therefore the user) can request more matches from the runner.
So if you type "video kde" and the video you were looking for isn't in the first 10 results from Youtube, there is hope with Sprinter. Of course, those Youtube results can be shown separately from the local files you have on disk that happen to be videos and named something containing the word "kde". Hallelujah!
Not done yet
Overall I'm quite pleased with the state of the new design and how well it works already. However, Sprinter is still very much a work in progress. You can't even execute a match yet! That, however, is a trivial feature to add; it's the matching and collating, without bothering the user interface thread, that is the real tussle and therefore what I approached first.
There is also zero user interface started. Yes, there are two test apps (one QWidget one QML) but that's all they are and all they ever will be. I'm completely delaying any start on user interface until the core is complete. There are a couple reasons for this: I want to start the UI with all the functionality already there so that the UI can be designed around the functionality rather than in spite of it, and I hold out hope for finding a UI design buddy to push boundaries with on this one. Honestly, I don't want it to end up looking anything like the current KRunner UI. I'm not condemning the KRunner UI, it's just an idea whose time has passed. It is essentially a souped-up version of
KDE2's KDE1's "run command" (thanks to Rich Moore to pointing out its roots go back even further) and by now we can do something better. Not different ... better.
Other things still on the TODO (besides all the TODOs and FIXMEs in the code) include:
- match execution
- a busy property for RunnerSessionData so we can know when a runner is, well, processing things
- a good method for syntax description introspection that can both be used for online user help but also to pre-screen queries and inform RunnerManager what kind of matches to expect
- "single runner" mode, such that a specific set of runners (e.g. "all video runners") is active and even with an empty query return a default set of results
- re-searching: pick a result and it actually spawns a new search for new results; trivial use case: the applications runner could return categories of applications as matches and selecting one of those matches would cause a new set of results containing all the applications in that category. Could also be used to allow the user to pick from a set of pre-set search types / queries; search bookmarking; ... I'd like to see this actually replace the match sub-actions from KRunner, which didn't realy work very well in practice and, due to being a relatively late addition, has a clumsy API
- search filtering: you've got your set of results and you're happy with them .. except you have more than you need. Filtering is trivially accomplished with a QSortFilterProxyModel, but it would be super clever if this could be tied into paging as well; I'm not 100% sure what this would look like yet in the code :)
- form factor awareness: with Plasma 2 we have the idea of the desktop shell form factor as a global property; allowing runners to be aware of this will allow runners to drop out of sight when appropriate or adjust their matching behavior on the fly
- lots and lots of API hole filling: things like adding additional (useful) properties to QueryMatches, more query pre-processing sharing in QueryContext, search session management, etc.
Once that is accomplished, then will come the task of porting KRunner runners as well as writing new ones. I'll be busy in my spare moments for a while to come. :)