Recent files for the TextEditor

Now that I have been using the TextEditor for some time I’ve noticed that I mostly keep editing just one or two files. Always opening the file browser and selecting the file has started to feel a bit cumbersome. So adding a recent files page to the TextEditor seemed like a good idea. The application could keep track of the most recent files and then display the list of files in one page for quick access.

The figure below shows the user interface. The main editor page shown on the left hand side has the recent files button in the toolbar (in the middle). When the recent files button is clicked a new page is opened (shown on the right hand side) that displays the most recent files (+ folder names). Clicking on the file name will open the file. Clicking the back button in the recent files page toolbar will go back to the editor page.

TextEditor-EditPage     TextEditor-RecentFiles

The flow diagram below shows how the recent files page is linked to the main editor page. The recent files list is kept in a file that is loaded when it is needed for the first time: either when the user clicks the recent files button in the EditPage toolbar (the list needs to be displayed) or when a file is opened or when a file a saved with a new name (the list needs to be updated).

When the application is closed we need to save the recent files list to a file (if the list has been modified).

RecentFiles-01

Creating the recent files page

The recent files page is defined in the file RecentPage.qml. The QML page is actually quite easy to construct by using the ListView component found in the com.nokia.extras library (for more information please see the ListView help page).

We will define one signal ‘recentCancelled’ that will be emitted if the ‘Back’ button is clicked in the toolbar. The signal handler onRecentCancelled will just pop the RecentPage from the page stack so that we will return to the EditPage.

The file RecentTools.qml defines the toolbar and the file Header.qml defines the page header. For the ListView component we just need to provide the model (a list of file and folder names). The model will be implemented in the C++ side. For the delegate we can use the ListDelegate component (also available in the com.nokia.extras library, please see ListDelegate).  ListDelegate requires that the model provides two roles: title and subtitle for displaying the model data.

The onClicked signal handler in the delegate will fetch the file name (title) and the folder name (subtitle) from the model.  These will be then passed to the C++ side to perform the file I/O operations. The signal ‘recentFileClicked’ is declared in the main.qml file and connected to the corresponding slot in the TextEditor class (texteditor.cpp).

import QtQuick 1.1
import com.nokia.meego 1.0
import com.nokia.extras 1.1     // set QML_IMPORT_PATH...
import "appDefaults.js" as AppDefaults

Page {

    signal recentCancelled

    // Toolbar>Back was clicked in the RecentPage
    onRecentCancelled: {
        pageStack.pop()
    }

    // Instantiate the RecentTools component (defined in RecentTools.qml)
    RecentTools{
        id: recentTools
        visible: true
    }

    // The page header
    Header {
        id: header
        anchors.top: parent.top
        singleLineHeader: true
        headerText: qsTr("Recent Files")
    }

    // Recent files list
    ListView {
        id: listView
        model: recentfiles  // from the root context
        delegate: ListDelegate {
            onClicked: {
                recentFileClicked(model.title,model.subtitle,editPage.content)
            }
        }
        anchors.top: header.bottom
        x: AppDefaults.DEFAULT_MARGIN
        height: parent.height - header.height - AppDefaults.DEFAULT_MARGIN - recentTools.height;
        width: parent.width;
        clip: true
    }
}

The RecentTools.qml file defines the toolbar contents. There is only one button (“Back”) to define in this case.

import QtQuick 1.1
import com.nokia.meego 1.0

// Toolbar for the recent files page
ToolBar {
    anchors.bottom: parent.bottom
    tools:
        ToolBarLayout {
        // Back to the EditPage
        ToolIcon {
            platformIconId: inPortrait?"toolbar-back-dimmed-white":
                             "toolbar-back-landscape-dimmed-white"
            onClicked: recentCancelled()
        }
    }
}

Creating a model for the ListView element

For a simple file name list we can subclass the QAbstractListModel class. According to the QAbstractListModel help page:

Simple models can be created by subclassing this class and implementing the minimum number of required functions. For example, we could implement a simple read-only QStringList-based model that provides a list of strings to a QListView widget. In such a case, we only need to implement the rowCount() function to return the number of items in the list, and the data() function to retrieve items from the list.

I will create a new class called RecentFiles that will inherit the QAbstractListModel. In addition to the mandatory functions (rowCount(), data()) I will add some helper functions to manipulate the recent files list:

  • readRecentFiles: to read the recent files list from file.
  • store: to store the data read from file to an internal variable (stringList).
  • addFile: to add a file specification to the recent files list.
  • removeFile: to remove a file from the recent files list.

The variable stringList will hold the file names. Each list element is formed with the folder name and the file name separated by the vertical bar character: <folder name>|<file name>. We can then use the QString split method to extract the parts, e.g. to get the folder name from list index k we can say stringList.at(k).split(“|”)[0].

There is one signal: openFailed. It will be emitted if we cannot open the file containing the recent files list. There is also one slot: closing. It will be connected to the aboutToQuit signal (emitted when the application is closing) to save the recent files list before exit.

#ifndef RECENTFILES_H
#define RECENTFILES_H

#include <QAbstractListModel>
#include <QStringList>

class RecentFiles : public QAbstractListModel
{
    Q_OBJECT

public:
    RecentFiles(QObject *qml, QObject *parent = 0);

    enum RecentRoles {
        FolderRole = Qt::UserRole + 1,
        FileRole
    };

    // When subclassing QAbstractListModel, we must provide implementations of the
    // rowCount() and data() functions.
    int rowCount(const QModelIndex &parent = QModelIndex()) const;
    QVariant data(const QModelIndex &index, int role) const;

    // Methods for internal data processing
    bool readRecentFiles();
    void store(const QStringList newContent);
    void addFile(const QString fileSpec);
    void removeFile(const QString fileSpec);

private:
    static const char *CONFIG_FOLDER;
    static const char *RECENT_FILES;
    static const int MAX_RECENT_FILES = 8;

    QStringList stringList;

    // Load the recent files list when 1) opening the recent files page
    // 2) Save As succeeded 3) Open succeeded.
    bool recentFilesLoaded;
    bool recentFilesModified;

signals:
    void openFailed(QString fileName, QString errorString);

public slots:
    void closing();
};

#endif // RECENTFILES_H

The recentfiles.cpp contains the class constructor and implements the mandatory functions and the helper functions.

The class constructor defines the model roles in the QHash roles variable. The role names must be “subtitle” and “title” as those are the names that the ListDelegate QML element is expecting. I will use the subtitle role to display the folder name and the title role to display the file name. The constructor will also connect the openFailed signal to the corresponding openFailed signal defined in the QML side (in main.qml).

The rowCount function will just return the recent files list size. The data function will return either the folder name or the file name depending on the role input parameter. If the index or the role is invalid it will return an empty QVariant.

#include <QDir>
#include "recentfiles.h"

// Folder and filename for saving the recent files list
const char *RecentFiles::CONFIG_FOLDER = ".texteditor";
const char *RecentFiles::RECENT_FILES = "recentfiles.dat";

RecentFiles::RecentFiles(QObject *qml, QObject *parent) :
    QAbstractListModel(parent)
{
    QHash<int, QByteArray> roles;
    roles[FolderRole] = "subtitle";
    roles[FileRole] = "title";
    // expose the roles to the QML side so that the ListDelegate can refer
    // to role names "subtitle" and "title".
    setRoleNames(roles);

    recentFilesLoaded = false;
    recentFilesModified = false;

    connect(this, SIGNAL(openFailed(QString,QString)),
               qml, SLOT(openFailed(QString,QString)));

}

int RecentFiles::rowCount(const QModelIndex &parent) const
{
    return stringList.count();
}

QVariant RecentFiles::data(const QModelIndex &index, int role) const
{
    if (!index.isValid())
        return QVariant();

    if (index.row() >= stringList.size())
        return QVariant();

    if (role == FolderRole)
        return stringList.at(index.row()).split("|")[0];
    else if (role == FileRole)
        return stringList.at(index.row()).split("|")[1];
    else
        return QVariant();
}

// Try to read recent files list from the file $HOME/.texteditor/recentfiles.dat
bool RecentFiles::readRecentFiles()
{
    QDir dir;
    QStringList newContent;
    bool retval = true;

    // already tried to load the file names?
    if (recentFilesLoaded)
            return(retval);

    // read file names
    dir.setCurrent(dir.homePath());
    if (dir.exists(CONFIG_FOLDER))
    {
        dir.setCurrent(CONFIG_FOLDER);
        if (dir.exists(RECENT_FILES))
        {
            QFile file(RECENT_FILES);
            bool fileIsReadable = file.open(QIODevice::ReadOnly);
            if (fileIsReadable)
            {
                QDataStream stream( &file );
                stream >> newContent;
                file.close();

                // store the file contents to stringList
                store(newContent);
            } else {
                retval = false;
                emit openFailed(RECENT_FILES,file.errorString());
            }
        }
    } else {
        // create the CONFIG_FOLDER if it did not already exist
        dir.mkdir(CONFIG_FOLDER);
    }
    recentFilesLoaded = true;
    return(retval);
}

// Store list of files (replace current contents)
void RecentFiles::store(const QStringList newContent)
{
    stringList = newContent;
}

// Add file specification to the beginning of the list
void RecentFiles::addFile(const QString fileSpec)
{
    // file position in the list
    int index = stringList.indexOf(fileSpec);

    // file is already in the beginning of the list?
    if (index==0)
        return;

    // file is elsewhere in the list?
    if (index>0)
        stringList.removeAt(index);

    // put the new entry to the beginning of the list
    stringList.prepend(fileSpec);

    // limit the list size
    if (stringList.count() > MAX_RECENT_FILES)
        stringList.removeLast();

    recentFilesModified = true;
}

// Remove file definition from the list
void RecentFiles::removeFile(const QString fileSpec)
{
    int nElems = stringList.count();

    // list is empty?
    if (nElems==0)
        return;

    int index = stringList.indexOf(fileSpec);

    // not in the list?
    if (index==-1)
        return;

    // remove the file from the list
    stringList.removeAt(index);
}

// application is closing, save the recent files list
void RecentFiles::closing()
{
    // Did we modify the recent files list?
    if (!recentFilesModified)
        return;

    QDir dir;
    dir.setCurrent(dir.homePath());
    if (dir.exists(CONFIG_FOLDER))
    {
        dir.setCurrent(CONFIG_FOLDER);

        QFile file(RECENT_FILES);
        bool fileIsWritable = file.open(QIODevice::WriteOnly);

        if ( fileIsWritable ) {
            QDataStream stream( &file );
            stream<<stringList;
            file.close();
        }
    }
}

Making the model available to the QML ListView

I will instantiate the RecentFiles class in the main function and then put it into the root context. That will make it available to the QML components.

Note how the aboutToQuit signal is connected to the RecentFiles class closing slot. The aboutToQuit signal is emitted when the application is closing (either after selecting Quit from the TextEditor file menu or by swiping down). This allows us to save the recent files list before the application is closed.

#include <QtGui/QApplication>
#include <QtDeclarative/QDeclarativeContext>
#include <QDeclarativeItem>
#include <QTranslator>
#include "qmlapplicationviewer.h"
#include "texteditor.h"
#include "recentfiles.h"

Q_DECL_EXPORT int main(int argc, char *argv[])
{
    QScopedPointer<QApplication> app(createApplication(argc, argv));
    TextEditor *texteditor;
    RecentFiles *recentfiles;

    QmlApplicationViewer viewer;

    QString locale = QLocale::system().name();
    QTranslator translator;

    // fall back to using English translation, if one specific to the current
    // setting of the device is not available.
    if (!(translator.load("tr_"+locale, ":/")))
        translator.load("tr_en", ":/");

    app->installTranslator(&translator);

    viewer.setOrientation(QmlApplicationViewer::ScreenOrientationAuto);
    viewer.setMainQmlFile(QLatin1String("qml/texteditor/main.qml"));
    viewer.showExpanded();

    // get the QML root object for signal-slot connections
    QObject *qml = viewer.rootObject();

    // Create the recent files list model and put it to the root context
    // Make viewer the parent object.
    recentfiles = new RecentFiles(qml, &viewer);
    viewer.rootContext()->setContextProperty("recentfiles",recentfiles);

    // Create the back-end processor and pass in the root object and the recent files model.
    // Make viewer the parent object.
    texteditor = new TextEditor(qml, recentfiles, &viewer);

    // Catch application exit to save the recent files list
    QObject::connect(QCoreApplication::instance(),SIGNAL(aboutToQuit()),recentfiles,SLOT(closing()));

    // Start the event loop
    return app->exec();
}

Updating the EditPage toolbar

As the final step we need to update the toolbar in the EditPage. The toolbar is defined in the file EditTools.qml. One new ToolIcon element needs to be added to the ToolBarLayout.

        // Recent files button
        ToolIcon {
            platformIconId: "icon-m-toolbar-captured-dimmed-white"
            anchors.left: (parent === undefined) ? undefined : parent.center
            onClicked: {
                toolRecentClicked(editPage.content)
            }
        }

We will emit the signal toolRecentClicked (defined in main.qml) when the button is clicked. The signal is connected to a slot with the same name in the TextEditor class. The toolRecentClicked slot will try to load the recent files list from file and then emit the signal recentRequested that will be connected back to the QML signal recentRequested. The signal handler onRecentRequested will push the RecentPage.qml file into the page stack to finally display the recent files page.

    // TextEditor requested RecentPage to be opened for selecting a file.
    onRecentRequested: {
        pageStack.push(Qt.resolvedUrl("RecentPage.qml"))
    }

Installing the TextEditor application

The updated version v1.4 is available in OpenRepos and can be easily installed with the N9 Warehouse application.

The source code is available in github. You can open the project in Qt Creator IDE by selecting File>New File or Project…>Project from Version Control>Git Repository Clone. The repository can be found from https://github.com/n9dyfi/TextEditor. The current version (v1.4) is available in branch-v1.4.

TextEditor-RF-01