Lenses

Revision 21 as of 2011-09-12 19:02:14

Clear message

This is a testing page for the new Lenses API docs and is not considered complete until around Beta 2

Unity > Lenses

Overview

To minimise confusion and to get you used to the terms used in the rest of this page, it's worth having a quick overview of the Dash, and the terms used to describe it:

Introduction

One of Unity's main features is the Dash. The Dash allows the user to quickly search for information both locally (installed applications, recent files, bookmarks, etc) and remotely (Twitter, Google Docs, etc).

The Dash achieves this by having one or more Lenses that each are responsible for providing one category of search results for the Dash. The user may search the Lens either through the Dash home screen (called global search) or through the Lens' own page in the Dash by clicking on the Lens' icon on the Lens Bar.

On it's own, a Lens is not very useful. It can guarantee an icon on the Lens Bar and a page in a the Dash but nothing will happen when the user searches it. This is because a Lens does not actually perform any searches itself. Instead, a Lens will have one or more Scopes which are the actual engines that do the searching for it.

This means that it is possible for new Scopes to supplement results of existing Lenses i.e. a Google Docs Scope can supplement the results of the default Zeitgeist Scope in the Files Lens, with results appearing side-by-side. Also, this means it is now possible for one Lens to have many Scopes i.e. the Music Lens can have a Banshee, UPNP and Spotify Scopes providing results to it.

Architecture

If you consider a Lens with two Scopes (e.g. Music Lens, a Banshee Scope and a Spotify Scope) this will generally mean that there will be four processes involved in searching the category of content that the Lens provides:

  1. The Dash itself (i.e Unity)
  2. The Lens daemon
  3. The first Scope daemon (Banshee Scope)
  4. The second Scope daemon (Spotify Scope)

It is then the job of the Lens to keep all these processes synchronised with each other. Luckily this happens behind the scenes and, as a Lens or Scope author, you will not have to get involved. However, it is nevertheless worth noting the complexity:

Creating a Lens

Creating a Lens requires:

  1. A .lens file so Unity can find and load the Lens
  2. A daemon that uses a well-known name on D-Bus
  3. A D-Bus .service file that let's Unity auto-activate the Lens when it's ready to (this is optional if your Lens is long-running anyway, but is probably required for 99% of the Lenses that will be written).

Responsibilities of a Lens

A Lens is responsible for providing the following information to Unity:

  • Which Categories it supports
  • Which Filters it supports
  • What to display in the search entry as the search hint
  • Whether the Lens should show in the Lens Bar
  • Whether then Lens should participate in searches from the Dash Home view (global search).

Your Lens ID

Due to do the way Unity and libunity export Lenses and find Scopes, it is essential to choose a ID for your Lens. This ID is used in various places and should be specific enough to avoid conflicts with other lenses. The id can be made up of alphanumeric characters and, additionally, dashes.

Some examples:

Lens

Lens ID

Files

files

Applications

applications

Google Docs

gdocs

Through this document, we will refer to the Lens ID either as Lens ID or $lens_id, please substitute the real Lens ID for those.

A .lens file

The .lens file is critical for Unity as it allows Unity to load some information about the Lens before loading it (great for optimising startup) and, essentially, it tells Unity how to activate/find your daemon on D-Bus.

Here is an example of a .lens file:

   1 [Lens]
   2 DBusName=net.launchpad.Lens.MyLens
   3 DBusPath=/net/launchpad/lens/mylens
   4 Name=My Lens
   5 Icon=/path/to/mylens.svg
   6 Description=A Lens to search my stuff
   7 SearchHint=Search your stuff
   8 Shortcut=m
   9 
  10 [Desktop Entry]
  11 X-Ubuntu-Gettext-Domain=my-lens
  • DBusName: A well known D-Bus name for finding your Lens.

  • DBusPath: The D-Bus path that the Lens is exported on.

  • Name: The name of your Lens.

  • Icon: The icon that should show on the Lens Bar and/or the Launcher. SVG is preferable.

  • Description: A single-line description of your Lens. This isn't used visually but could be used for accessibility.

  • SearchHint: The search hint that shows in the Dash search entry when your Lens is focused.

  • Shortcut: The preferred shortcut to be used to call up your Lens (i.e. =m would translate to <Super>+m). This is not guarenteed if the user has other shortcuts already set for your key combination

  • X-Ubuntu-Gettext-Domain: This tells Unity which is the right translation domain for your Lens to find the translatable strings in this Lens file.

The .lens file should be installed into the same prefix as Unity itself in this format: /prefix/where/unity/was/installed/share/unity/lenses/$lens_id/$lens_id.lens

So, if Unity was installed in /usr and your Lens ID was mylens, then the .lens file would be installed in /usr/share/unity/lenses/mylens/mylens.lens

A D-Bus service file

   1 [D-BUS Service]
   2 Name=net.launchpad.Lens.MyLens
   3 Exec=/my/daemon/install/prefix/lib/mylens/my-lens-daemon
  • Name: A well known D-Bus name for finding your Lens (should be same as DBusName above).

  • Exec: The executable for your daemon that will load and register this name on D-Bus.

D-Bus .service files should be installed into /usr/share/dbus-1/services.

Using the Lens Object

Once you have registered your daemon on D-Bus and named the connection, the next thing to do is create and initialise your Lens object. This is done through the Lens object in https://launchpad.net/libunity.

Creating the Lens and setting default properties

   1 {
   2   // When creating a Lens object, you must tell it the DBusPath you
   3   // used in the .lens file, and the Lens ID you have chosen.
   4   this.lens = new Unity.Lens("/net/launchpad/lens/mylens", "mylens");
   5 
   6   // Translatable search hint.
   7   lens.search_hint = _("Search your stuff");
   8 
   9   // Does it appear in the Lens Bar?
  10   lens.visible = true;
  11 
  12   // Should Unity include the Lens in global search results?
  13   lens.search_in_global = true;
  14 
  15   // Add the default categories (see below)
  16   populate_categories();
  17 
  18   // Add the default filters (see below)
  19   populate_filters();
  20 
  21   // Now that all the default properties are set, export the Lens
  22   // so Unity can find it.
  23   lens.export();
  24 }

Adding Categories

You should add at least one Category to your Lens. Categories allow you to create distinct partitions between different types of data in your Lens (i.e. Music Lens has Songs, Albums and Available to Purchase). When Scopes add results, they indicate which Category the result belongs in by sending an integer referencing the order the Category was added to the Lens. Note: This will be made easier for Scopes to reference with a tool that is under development.

For example, a Scope for the Music Lens would add a song with the third column (the category column) to 0, a album would have 1 and a purchasable song would have 2.

When adding a Category, you can choose it's icon, it's name and, finally, how you would like Unity to render the results within that Category. For 11.10, Unity supports two renderers: Vertical Tile and Horizontal Tile:

   1 private void populate_categories ()
   2 {
   3   GLib.List<Unity.Category> categories = new GLib.List<Unity.Category> ();
   4   File icon_dir = File.new_for_path (ICON_PATH);
   5 
   6   var cat = new Unity.Category (_("Songs"),
   7                                 new FileIcon (icon_dir.get_child ("group-mostused.svg")),
   8                                 VERTICAL_TILE);
   9   categories.append (cat);
  10 
  11   cat = new Unity.Category (_("Albums"),
  12                             new FileIcon (icon_dir.get_child ("group-installed.svg")),
  13                             HORIZONTAL_TILE);
  14   categories.append (cat);
  15 
  16   cat = new Unity.Category (_("Available to Purchase"),
  17                             new FileIcon (icon_dir.get_child ("group-available.svg")),
  18                             VERTICAL_TILE);
  19   categories.append (cat);
  20 
  21   lens.categories = categories;
  22 }

Adding Filters

While a Lens is not required to have any Filters, it is thought that most Lenses will benefit from an easy way for the user to be able to refine their search criteria.

It is up to the Lens to create the Filters for Unity to renderer, and the Scopes to query their state while searching to effect the results.

In 11.10, there are four different types of Filters:

CheckOption

A CheckOption Filter allows the user to select one or more options from the provided list. In practice, this means that they could search for Folders and PDFs in the example below.

   1 {
   2   // The first parameter should be a unique name for this filter
   3   // it is used by Scopes to lookup the filter and it's state
   4   // so don't have two filters with the same name!
   5   // The second argument is what the user will see in Unity
   6   var filter = new CheckOptionFilter("type", _("Type"));
   7 
   8   // CheckOption, RadioOption, MultiRange are all OptionFilter
   9   // subclasses. The OptionFilter subclass abstracts the concept
  10   // of "adding options" to a Filter. For example, every check
  11   // button or "option" in the CheckOptionFilter is added in the
  12   // same way as it is for the RadioOptionFilter or the Multi-
  13   // OptionFilter.
  14   //
  15   // The add_option takes an unique id for this option as its
  16   // first argument (which allows a Scope to check it's state
  17   // easily), and the user-visible name as it's second.
  18   // There is a third, optional, argument of a GIcon, but it
  19   // is unused in 11.10.
  20   filter.add_option ("document", _("Document"));
  21   filter.add_option ("folder", _("Folder"));
  22   filter.add_option ("pdf", _("PDF"));
  23 
  24   // etc
  25 }

RadioOption

The RadioOption Filter allows the user to select only one out of the given options, like a radio group behaves in a widget toolkit. So, with the example below, the user could choose Today or Last Week, but not both.

   1 {
   2   var filter = new RadioOptionsFilter ("last-modified", _("Type"));
   3   filter.add_option ("today", _("Today"));
   4   filter.add_option ("yesterday", _("yesterday"));
   5   filter.add_option ("last-week", _("Last Week"));
   6 }

MultiRange

The MultiRange Filter allows the user to choose a range from within the provided options. So, in the example below, if the user clicked on 1MB and 100 MB, 1MB, 10MB and 100MB would be active. If the user then clicked on 10MB, one 1MB and 10MB would be active.

   1 {
   2   var filter = new MultiRangeFilter ("size", _("Size"));
   3   filter.add_option ("<1MB", _("<1MB"));
   4   filter.add_option ("1MB", _("1MB"));
   5   filter.add_option ("10MB", _("10MB"));
   6   filter.add_option ("100MB", _("100MB"));
   7   filter.add_option ("1GB", _("1GB"));
   8 }

Ratings

The Ratings Filter is the simplest of the bunch, allowing the user to choose an up-to-five star rating to refine the search. This works well for content where rating information is available, like Applications in the software centre, or songs in Banshee.

   1 {
   2   var filter = new RatingsFilter("rating", _("Rating"));
   3 }

Complete Example

   1 {
   2   GLib.List<Unity.Filter> filters = new GLib.List<Unity.Filter> ();
   3 
   4   var filter = new CheckOptionFilter("type", _("Type"));
   5   filter.add_option ("document", _("Document"));
   6   filter.add_option ("folder", _("Folder"));
   7   filter.add_option ("pdf", _("PDF"));
   8   filters.append (filter);
   9 
  10   filter = new RadioOptionsFilter ("last-modified", _("Type"));
  11   filter.add_option ("today", _("Today"));
  12   filter.add_option ("yesterday", _("yesterday"));
  13   filter.add_option ("last-week", _("Last Week"));
  14   filters.append (filter);
  15 
  16   filter = new MultiRangeFilter ("size", _("Size"));
  17   filter.add_option ("<1MB", _("<1MB"));
  18   filter.add_option ("1MB", _("1MB"));
  19   filter.add_option ("10MB", _("10MB"));
  20   filter.add_option ("100MB", _("100MB"));
  21   filter.add_option ("1GB", _("1GB"));
  22   filters.append (filter);
  23 
  24   filter = new RatingsFilter("rating", _("Rating"));
  25   filters.append (filter);
  26 
  27   lens.filters = filters;
  28 }

What else?

Nothing much, really. A Lens, once it's been exported, just needs to hang-around for some Scopes to join in the fun and for Unity to start searching it. The synchronization between the processes is handled internally by libunity.

In the future we want to allow the Lens to have a greater say in the results it presents (i.e. being able to de-dupe results across different Scopes, etc), however for 11.10, as a Lens author, your job is done at this point!

Creating a Scope

Responsibilities of a Scope

The Scope is responsible for the following:

  • Providing search results to it's parent Lens taking into account the state of the Filters at the time of the search.
  • Providing global search results that ignore the state of the filters.
  • Indicating to it's parent Lens that it has finished it's normal or global search.
  • Taking responsibility for activating any result it owns when the user clicks on it.

Registration

There are two types of Scope that only differ in how they are registered with their parent Lens (the Lens they provide search data for).

A Local Scope is a scope that is created inside the same process as the Lens. Normally this would be done to ensure the Lens is useful without installing additional software or, if the Lens was very specialised, then to just simplify writing it by only needing to create and install on daemon. An example would be someone writing a Google Docs Lens that also included the Scope that interfaces with the Google Docs API.

A Remote Scope is a scope that plugs into an existing Lens from a separate process. This type of Scope would run in it's own daemon. An example is if someone were to write a Last.fm Scope for the Music Lens.

Local Scope

As the Scope has been created and is running in the same process as the parent Lens, it does not need to go through the same registration as a Remote Scope, instead you simply need to do:

   1 {
   2   // lens was setup before this point
   3   lens.add_local_scope (the_local_scope);
   4 
   5   lens.export();
   6 }

This tells the Lens that you want to use a Scope that is running the same process as itself, and the Lens will do what is necessary to let that happen.

Remote Scope

For a Remote Scope to be found by it's Lens, it needs to install a .scope file to give the Lens enough details to launch/connect to it.

An example .scope file:

   1 [Scope]
   2 DBusName=net.launchpad.Scope.lastfm
   3 DBusPath=/net/launchpad/scope/lastfm

This is simply telling which well-know D-Bus name it can find the Scope on, and the path to the Scope object at that address.

The .scope file should be installed in the folder of the Scope's parent Lens. So, if this Scope was for the Music Lens, it would be installed into /usr/share/unity/lenses/music/lastfm.scope.

It is worth mentioning that the name of the scope file is not as important as a .lens file, but it is useful to use a specific enough name to avoid conflicts with other scopes.

Note: A Remote Scope should install a D-Bus .service file much like a Lens does. This allows auto-activation if it is not already running when the Lens starts.

Using the Scope Object

The Scope object is your conduit to the current state of Unity. The Scopes parent Lens will keep the Scope updated with the current state, and it is the owner of the Scope's job to keep both the results models (normal and global) updates to reflect this state.

Creating & Initialising the Scope

   1 {
   2   // When creating a Scope object, only the dbus path that you
   3   // would like it exported at is required. This should match
   4   // the DBusPath property in the .scope file if this a remote
   5   // scope. Otherwise any, valid, D-Bus path will do.
   6   this.scope = new Unity.Scope( ("/net/launchpad/scope/lastfm");
   7 
   8   // You can choose at a per-scope level if you want to be
   9   // involved in global searches. This means you can opt-out
  10   // of global searches even if your parent Lens has opted-in.
  11   scope.search_in_global = true;
  12 
  13   // The key thing that you are doing with the Scope object is
  14   // listening for, and reacting to, any state changes.
  15   // Therefore it is essential to connect up to all the important
  16   // signals and property-change events.
  17   // NOTE: The handlers are explained in the specific sections below.
  18 
  19   // First up, we want to be notified whenever the user changes the
  20   // search string in our Lens in the Dash
  21   scope.notify["active-search"].connect(on_search_changed);
  22 
  23   // Next, we want to react to changes when the user searches from
  24   // the home screen in the Dash
  25   scope.notify["active-global-search"].connect(on_global_search_changed);
  26 
  27   // If the user twiddles any of the filters, we want to update our
  28   // search to reflect the change
  29   scope.filters_changed.connect(on_filters_changed);
  30 
  31   // Finally, we want the user to be able to actually click on a
  32   // result and have something happen, so we connect to the
  33   // activate-uri signal, allowing us to react to the click and
  34   // inform Unity of the result.
  35   scope.activate_uri.connect(on_uri_activated);
  36 
  37   // If this is a remote Scope, we'll export it, otherwise for a Local
  38   // Scope, this is where we would use the lens.add_local_scope to let
  39   // the Lens know it exists.
  40   scope.export();
  41 }

Reacting to the active-search changing

When the active-search changes, you can query the current Unity.LensSearch of the Scope via the active_search property. From that object, you can query the current search term and use it to refresh your search results:

   1 private void on_search_changed(Object obj, ParamSpec pspec)
   2 {
   3   Unity.LensSearch? search = scope.active_search;
   4 
   5   if (!search)
   6     return;
   7 
   8   debug("The current search term is: %s", search.search_string);
   9   update_search(search);
  10 }

Reacting to the active-global-search changing

This works much the same as reacting to active-search, but with change of property names:

   1 private void on_search_changed(Object obj, ParamSpec pspec)
   2 {
   3   Unity.LensSearch? search = scope.active_global_search;
   4 
   5   if (!search)
   6     return;
   7 
   8   debug("The current global search term is: %s", search.search_string);
   9   update_global_search(search);
  10 }

The magic "" search term (the Default View)

In the world of the Dash, receiving a search term that equals "" means that you should populate your results model with default results. These are the results that the user sees when they switch to a Lens in the Dash but do not search for anything.

For example, the Applications Lens, when given a search term of "", will populate it's three Categories with the most recently used applications, all installed applications, and a selection of available, highly rated, applications.

You should always strive to have something useful in the default view.

Reacting to the filters-changed signal

Depending on how the content you are searching takes into account Filters, which could be that you need to update a setting on a library in direct response to the filters changing or, in the opposite case, you just need to take them into account when you perform the search (i.e. add them to the end of a URL), how you react to the filters changing will differ.

For the latter, and more general, case, you would chain the filters-changed signal to the update_search callback, and let it just perform another search (as it will take into account the current filter state).

In the second case you might have an extra step in between to set the options on your datastore before updating the search.

NOTE: Although you might feel it's better to react to the filters-changed signal by analysing your current result set and just removing results that don't fit, unless the checking is very simple, it might just be better to do a new query.

Querying Filter State

A Scope is expected to respect the Filters of it's parent Lens. This means that the Scope should know the ids of the Filters and their options so it can understand the state (Note: There is no easy way to do this outside of reading the Lens' source currently, a tool is in the works to simplify the process).

Although you can iterate through the list of Filters the Scope has, it's often easier/makes more sense to query the filter directly and then query it's state:

All examples reference those used in the Lens "Adding Filters" section above

Querying CheckOption State

   1 {
   2   // Get the type CheckOption Filter from the Scope
   3   var filter = scope.get_filter("type") as CheckOptionFilter;
   4  
   5   // As we can have none, one or many options enabled, we need to iterate
   6   // through and see which ones are
   7   foreach (Unity.FilterOption option in filter.options)
   8   {
   9     if (option.active)
  10     {
  11       // We are pretending that our websource uses exactly the same naming as
  12       // the ids our Lens uses. Ahh, to write pretend software...
  13       if (url == "")
  14         url="&type=" + option.id;
  15       else
  16         url+="+" + option.id;
  17     }
  18   }
  19 }

Querying RadioOption State

   1 {
   2   // Get the last-modified RadioOption Filter from the Scope
   3   var filter = scope.get_filter("last-modified") as RadioOptionFilter;
   4 
   5   // Radio option is way simpler, we just need to query the active option
   6   // (if one exists) and do something for that
   7   if (filter.get_active_option())
   8     url += "&modified=" + filter.get_active_option().id;
   9 }

Querying MultiRange State

   1 {
   2   // Get the size MultiRange Filter from the Scope
   3   var filter = scope.get_filter("size") as MultiRangeFilter;
   4 
   5   // As MultiRange works by having an option at the first and last positions,
   6   // we just need to make sure it has a valid FilterOption for both the first
   7   //  and last positions and use that info to sent the range we want.
   8   if (filter.get_first_active() && filter.get_last_active())
   9   {
  10     url += "&min-size=" + filter.get_first_active().id + "&max-size=" + filter.get_last_active().id;
  11   }
  12 }

Querying Ratings State

   1 {
   2   // Get the ratings Ratings Filter from the Scope
   3   var filter = scope.get_filter("ratings") as RatingsFilter;
   4   url += "&min-rating=" + "%f".printf(filter.rating);
   5 }

Handling Activation

Full example