Лекция 10. Проект "Текстовый редактор"

Содержание лекции

Назначение текстового редактора

Проект "Текстовый редактор" (TextEdit.zip) иллюстрирует разработку программы, имеющей практическое значение. Рассматриваемый простой текстовый редактор вполне можно использовать в работе, если дополнить его небольшим набором дополнительных команд (типа запоминания-вспоминания фрагментов текста), которые сознательно не были реализованы. Реализация этих дополнительных команд вынесена в качестве задач для экзамена.

Проект достаточно объемный (порядка полутора тысяч строк, если включить реализацию списка и тестовой строки, но не включать реализацию графического окна). Вместе с тем структура проекта не сложная, и реализация дополнительных команд не представляет особой трудности.

Текстовый редактор предназначен для просмотра и изменения обычных текстовых файлов. Мы рассматриваем текст как последовательность строк. Это отличается от модели текста, принятой в редакторах типа Microsoft Word, в которых основной единицей является абзац. Такая модель больше подходит для литературных текстов, в которых разбиение текста на строки не имеет значения. Но при редактировании компьютерных программ разбиение на строки крайне важно. К тому же большинство людей привыкло к восприятию текста как последовательности строк еще со времен пишущих машинок (да и книжный текст так выглядит).

Класс "Text"

Итак, текст -- это последовательность строк. Для хранения текста используется структура данных Л2-список, поскольку нормальной операцией является добавление и удаление строк в середине текста, а в списке такие операции реализованы максимально эффективно. Текст хранится в объекте класса "Text", который выводится из класса "L2List".

    class Text: public L2List { ... };
Класс "Text" расширяет возможности списка, в него добавлены такие методы, как сохранение и загрузка текста из файла, получение строки с индексом n и т.д.

Класс "TextLine"

Класс "Text" хранит элементы типа "Текстовая строка":

    class TextLine: public L2ListHeader { ... };
Класс "TextLine" выводится из класса "L2ListHeader", поскольку такова особенность реализации нашего списка "L2List": все объекты, содержащиеся в нем, должны быть представителями классов, выводимых из L2ListHeader. Объекты эти должны создаваться в динамической памяти перед включением в список с помощью оператора "new". Кроме того, у этих классов должен быть виртуальный деструктор (поскольку оператор "delete" для них вызывается из методов списка). Такая особенность вызвана тем, что мы решили не использовать механизм шаблонов "template" языка C++. Платой за отказ от шаблонов является необходимость наследования и виртуальных методов.

Класс "TextLine" представляет собой динамический массив символов:

    class TextLine: public L2ListHeader {
        int     capacity;
        int     len;
        char*   str;
        . . .
Переменная "len" содержит длину строки, не включая терминирующий нулевой символ. Переменная "str" содержит адрес массива символов в динамической памяти. Переменная "capacity" содержит размер массива, capacity не меньше чем len+1.

Класс "TextLine" реализует множество методов, типичных для работы с текстовыми строками (конкатенация, вставка и удаление символов и т.п.). Отметим следующие 2 важнейших метода:

Это позволяет использовать объект типа TextLine во всех функциях с параметрами в стиле C-строк благодаря неявному преобразованию типа. Например, можно сравнивать объект типа TextLine с C-строкой с помощью стандартной функции "strcmp":
    TextLine t;
    . . .
    if (strcmp(t, "abcd") == 0) { ... }

Класс "TextEdit"

Класс "TextEdit" реализует текстовый редактор в оконной системе X-Windows. Реализация не использует никаких серьезных библиотек типа Motif или QT. Вместо этого используется класс "GWindow", являющийся несложным C++ интерфейсом к функциям X11. Класс TextEdit наследуется из класса GWindow:

    class TextEdit: public GWindow { ...
Текст редактируемого файла хранится в переменной "text" класса "TextEdit", которая имеет тип "Text", т.е. Л2-список текстовых строк:
    Text text;
Для изображения текста используется шрифт с фиксированной шириной букв. Текст можно представлять как матрицу символов, потенциально бесконечную вниз и вправо. В окне текстового редактора изображается лишь часть этой матрицы. Окно можно представлять как прямоугольную рамку, перемещающуюся по тексту. Координаты окна и его размер хранятся в следующих переменных, которые являются членами класса TextEdit:
    int windowX;        // Window position in the text
    int windowY;
    int windowWidth;    // Window size in characters
    int windowHeight;
Здесь единицей измерения по горизонтали является символ, по вертикали -- строка. Переменная windowY содержит число строк над окном, т.е. когда окно находится в начале текста, то windowY = 0. Аналогично, переменная windowX содержит число колонок текста левее окна.

Позиция, в которой происходит редактирование текста, отмечается курсором. Мы изображаем курсор в виде немигающего прямоугольника размером в символ шрифта, в котором цвета текста и фона меняются местами, т.е. используем так называемый инверсный режим рисования. (Мигающий курсор, который применяется в большинстве стандартных редакторов, вреден для зрения и очень утомляет; эта одна из причин, почему хочется разработать редактор самостоятельно.)

Координаты курсора вычисляются относительно начала текста (не окна!) и измеряются в колонках и строках:

    int cursorX;        // Cursor position in the text
    int cursorY;
Переменная "cursorX" содержит число колонок левее курсора, переменная "cursorY" -- число строк выше курсора.

Используемый шрифт хранится в переменной textFont; также хранится его имя в стиле X11 и структура, описывающая свойства шрифта:

    Font textFont;          // Font used
    XFontStruct fontStruct; // Font properties
    TextLine fontName;      // Font name (in X11 form)
Имя шрифта берется из переменной среды "XMIMFONT" (т.е. используется тот же шрифт, что и у редактора "Микромир"). Если она не установлена, то используется по умолчанию шрифт с именем
static const char* const DEFAULT_TEXT_FONT =
    "-adobe-courier-medium-r-normal-*-14-*-*-*-*-*-koi8-r";
Наконец, если и его не удается загрузить, то программа пытается загрузить шрифт с именем "fixed", которое обязательно должно быть определено в любой X11-оконной системе как псевдоним (alias) какого-либо конкретного шрифта. При невозможности загрузить даже шрифт с именем "fixed" программа завершается с диагностикой фатальной ошибки. Загрузка шрифта и получение его параметров выполняются в методе
    void loadTextFont();

Метрические параметры шрифта извлекаются из структуры

    XFontStruct fontStruct; // Font properties
которая заполняется как результат системного вызова "XQueryFont". Мы используем следующие параметры:
    int dx;             // Maximal character advance (width)
    int ascent;         // Character ascent
    int descent;        // Character descent
    int leading;        // Interline skip
    int dy;             // ascent + descent + leading
Здесь dx -- расстояние между соседними символами, т.е. то расстояние, на которое сдвигается каретка пишущей машинки при печати; dy -- расстояние между соседними строками, т.е. то расстояние, на которое продергивается бумага в пишущей машинке при переходе к новой строке. У шрифта есть так называемая базовая линия; например, это горизонтальная линия вдоль нижнего края буквы "ш". Вертикальное расстояние от базовой линии до верхнего края объемлющего прямоугольника самой высокой буквы называется "ascent" (возвышение), до нижнего края -- "descent" (понижение). Например, понижение ненулевое у русской буквы "у". Наконец, вертикальный промежуток между строками называется "leading". Фрагмент кода, инициализирующий эти переменные, выглядит следующим образом:
    dx         = fontStruct.max_bounds.width;
    ascent     = fontStruct.max_bounds.ascent;
    descent    = fontStruct.max_bounds.descent;
    int height = ascent + descent;
    leading    = height / 8;
    if (leading <= 2) leading = 2;
    dy         = ascent + descent + leading;

Размеры отступов от краев окна, т.е. поля хранятся в переменных leftMargin, topMargin, rightMargin, bottomMargin, statusLineMargin. Верхнее поле topMargin включает в себя высоту статусной строки. Статусная строка изображается в верхней части окна редактора и содержит разную полезную информацию, например, текущие координаты курсора.

Обработка клавиатурных событий

В нашем редакторе работа с меню не реализована (это дополнительно усложнило бы проект). Поэтому все команды выполняются нажатиями клавиш, можно использовать аккорды (например, Ctrl+PageUp). Все методы, реализующие команды редактора, вызываются из виртуального метода

    virtual void onKeyPress(XEvent& event);
Этот метод вызывается при получении программой сообщения о нажатой клавише. Параметры события содержатся в переменной event, в которой важны следующие 2 поля:
    unsigned int modState = event.xkey.state;
    unsigned int keycode = event.xkey.keycode;
Число "event.xkey.state" представляет состояние модифицирующих клавиш, таких как Shift, Ctrl, Alt. Соответствующие биты в этом числе равны единице, когда клавиша нажата. Например, бит "ShiftMask" установлен в единицу, когда нажата клавиша Shift.

Число "event.xkey.keycode" представляет собой так называемый скан-код клавиши, он зависит только от клавиши и не зависит от состояния модификаторов, включения или выключения русификатора клавиатуры и т.п. Вместе с тем полагаться на значения скан-кодов опасно, поскольку они могут зависеть от конкретных X-терминалов, версии операционной системы и т.п. Вместо скан-кодов следует использовать клавиатурные символы (тип "KeySym"), которые определены независимо от версий терминалов и операционных систем. Определения клавиатурных символов содержатся в файле "/usr/include/X11/keysymdef.h". Например, символ клавиши "Стрелка вниз" равен константе "XK_Down", символ стрелки вниз на дополнительной правой клавиатуре равен константе "XK_KP_Down" ("XK" расшифровывается как "X11 Keyboard", "KP" -- как "Key Pad"). Клавиатурный символ, соответствующий скан-коду и модификаторам, вычисляется с помощью стандартной функции "XLookupString":

    KeySym keySymbol;
    char keyName[256];
    int keyNameLen;

    keyNameLen = XLookupString(
        &(event.xkey), keyName, 255, &keySymbol, 0
    );
На вход функции XLookupString подается поле xkey структуры event, описывающее клавиатурное событие (поле xkey в свою очередь является структурой типа XKeyEvent). Результатом является значение клавиатурного символа, которое помещается в переменную keySymbol, и название символа, которое помещается в массив keyName. В случае английских букв первый элемент массива keyName содержит код буквы, а длина keyNameLen названия символа равна единице. У специальных клавиш типа стрелок знаковый бит байта с номером 1 установлен в единицу (мы считаем, что младший байт имеет номер 0). Например, код стрелки вниз определен как
    #define XK_Down 0xFF54
Наконец, у русских букв байт с номером 1 равен 0x6, например, символ русской строчной буквы "а" определен как
    #define XK_Cyrillic_a 0x6c1
Младший байт клавиатурного символа русской буквы равен ее коду в кодировке КОИ-8 (в системе Unix применяется именно эта русская кодировка).

Если клавиша -- это обычный латинский символ или русская буква, то ее код помещается в переменную

    int lastChar;       // The character typed
и вызывается метод
    void onCharTyped(); // Insert a character typed
Этот метод редактора обрабатывает нажатие на клавишу. В противном случае клавиатурная команда ищется в специальной таблице, устанавливающей соответствие между командами клавиатуры и методами класса TextEdit, выполняющими эти команды. Таблица -- это статический массив, содержащий описатели команд редактора:
    static const struct CommandDsc editorCommands[];
Команда редактора описывается с помощью структуры CommandDsc:
    struct CommandDsc {
        KeySym keysym;          // X11 Key symbol
        unsigned int state;     // State of Shift, Control, Alt, etc.
        unsigned int stateMask; // Which bits in "state" are important
        bool change;            // The command changes a text
        void (TextEdit::*method)(); // Pointer to editor method
    };
Поле keysym содержит код клавиатурного символа. Поле state содержит значения модификаторов клавиатуры, которые соответствуют описываемой команде редактора. Поле stateMask определяет, какие из битов в слове state рассматриваются. Например, мы хотим определить две команды: 1) клавиша Home передвигает курсор в начало строки; 2) клавиша Ctrl+Home передвигает курсор в начало текста. При этом мы игнорируем все остальные модификаторы клавиатуры (Shift, Alt и т.д.). Поэтому маска содержит лишь бит, соответствующий модификатору Ctrl. Описатели этих двух команд выглядят следующим образом:
    {XK_Home, 0, ControlMask, false, &TextEdit::onHome},
    {XK_Home, ControlMask, ControlMask, false, &TextEdit::onTextBeg},
Четвертое поле "change" структуры CommandDsc указывает, изменяет ли команда текст. Например, команды передвижения курсора текст не меняют, а команда удаления строки изменяет его. Рассмотренные две команды только перемещают курсор, поэтому поле "change" содержит значение false.

Последнее поле "method" структуры CommandDsc -- это указатель на метод класса TextEdit, который выполняет соответствующую команду. В нашем примере перемещение курсора в начало строки выполняется с помощью метода onHome(), перемещение курсора в начало текста -- с помощью метода onTextBeg().

Итак, суммируем все вышесказанное. При обработке клавиатурного события в методе onKeyPress

Действия, выполняемые перед любой командой и после нее

Очень удачным решением при проектировании текстового редактора является выделение общих действий, выполняемых перед любой командой и после нее. Объем текста, реализующего эти общие действия, для большинства команд превосходит объем текста, реализующего саму команду. Так что оформление общих действий в виде двух отдельных методов позволяет сократить объем исходного кода редактора в несколько раз.

Перед выполнением любой команды вызывается метод

    void preProcessCommand();
После выполнения вызывается метод
    void postProcessCommand();

Метод "preProcessCommand()"

Этот метод

Вот текст метода "preProcessCommand":
void TextEdit::preProcessCommand() {
    inputDisabled = true;                      // Disable any input
    drawCursor(cursorX, cursorY, false, true); // Remove cursor
}
В методе "drawCursor" третий параметр "false" означает, что курсор стирается (если "true", то, наоборот, рисуется). Четвертый параметр "true" означает, что при рисовании создается временный графический контекст, который используется только внутри этого метода и удаляется по окончании рисования; "false" означало бы, что нужно использовать стандартный графический контекст, связанный с окном типа GWindow. Делается это для того, чтобы можно было рисовать независимо от текущей установки региона клипирования для стандартного графического контекста окна (поскольку иногда перед перерисовкой части окна регион клипирования ограничивается этой частью). Более подробные объяснения будут даны ниже.

Метод "postProcessCommand()"

В этом методе выполняются следующие действия:

Вот полный текст метода "postPocessCommand":
void TextEdit::postProcessCommand() {
    if (m_Window == 0) // Window is destroyed => return
        return;

    if (textChanged)
        textSaved = false;

    // Scroll the window to the cursor, if necessary
    if (
        cursorX < windowX || cursorX > windowX + windowWidth - 1 ||
        cursorY < windowY || cursorY > windowY + windowHeight - 1
    ) {
        // Cursor is removed at the moment!
        scrollToCursor();
    }

    // Set pointer in the text
    text.setPointer(cursorY);

    inputDisabled = false;

    drawCursor(cursorX, cursorY, true, true);
    drawStatusLine(true);
}

Реализация команд редактора

Каждой команде редактора соответствует метод класса "TextEdit", выполняющий эту команду. Так как мы вынесли все общие действия, выполняемые до и после любой команды, в отдельные методы "preProcessCоmmand" и "postProcessCommand", то реализация некоторых команд редактора становится до смешного простой. Например, метод, реализующий нажатие правой стрелки, выглядит следующим образом:

void TextEdit::onRight() {
    ++cursorX;
}
Чуть сложнее реализация левой стрелки:
void TextEdit::onLeft() {
    if (cursorX > 0)
        --cursorX;
}
Аналогично реализуются большинство команд перемещения курсора. Например, команда "переместить курсор в конец текущей строки" (клавиша End или Ctrl+[правая стрелка]) реализована так:
void TextEdit::onEnd() {
    if (cursorY >= text.size()) {
        cursorX = 0;
    } else {
        cursorX = text.getLine(cursorY).length();
    }
}
Здесь "text.size()" -- это количество строк в тексте. Если координата Y курсора совпадает с этим числом, то курсор находится за последней строкой текста. Конец текста в нашем редакторе отмечается фиктивной строкой
[* End of text *]
которую нельзя ни удалить, ни изменить (данная строка только изображается на экране, но не присутствует в объекте "text"). Курсор тем не менее может находиться в этой строке. В таком случае мы просто перемещаем курсор в начало строки (как бы строки нет, она пустая, значит, конец совпадает с началом).

В противном случае мы получаем ссылку на текущую строку текста

    text.getLine(cursorY)
вычисляем ее длину, применяя к текущей строке метод "length()", и записываем новое значение координаты X курсора:
    cursorX = text.getLine(cursorY).length();

Наконец, рассмотрим команду, изменяющую текст, например, команду "удалить текущую строку", выполняемую по нажатию Shift+Delete или Ctrl+K:

void TextEdit::onDeleteLine() {
    if (cursorY >= text.size())
        return;
    text.setPointer(cursorY);
    text.removeAfter();
    redrawTextRectangle(0, cursorY, INT_MAX, INT_MAX);
}
Здесь сначала проверяется, находится ли курсор в конце текста. Если да, то ничего не делается. Иначе сначала устанавливается указатель в тексте в соответствии с координатой курсора (напомним, что текст -- это Л2-список строк, а позиция указателя в тексте -- это суммарное число элементов до указателя); затем удаляется строка за указателем; наконец, перерисовывается часть текста, расположенная ниже курсора. Для этого вызывается метод
    void redrawTextRectangle(int x, int y, int w, int h);
который перерисовывает прямоугольник в тексте (размеры и координаты прямоугольника задаются в символах). В этом методе автоматически вычисляется пересечение текстового прямоугольника с прямоугольником окна, и перерисовывается только это пересечение. В качестве ширины или высоты текстового прямоугольника можно задать значение "плюс бесконечность". В программировании в случае целых чисел для представления "плюс бесконечности" используется максимальное целое число INT_MAX (константа INT_MAX определена в стандартном заголовочном файле "limits.h"). Вызов
    redrawTextRectangle(0, cursorY, INT_MAX, INT_MAX);
означает, что перерисовывается текствый прямоугольник с левым верхним углом в начале текущей строки, имеющий бесконечную ширину и бесконечную высоту. В методе атоматически будет вычислено пересечение этого прямоугольника с прямоугольником окна, и часть окна, начиная с текущей строки и ниже нее, будет перерисована.

Аналогично реализуются и другие команды редактора.

Как добавить новую команду редактора

Для добавления новой команды нужно:

Работа с графическими контекстами

Графический контекст создается для рисования в окне. Все системные функции рисования имеют графический контекст в качестве одного из аргументов. Например, системная функция рисования линии имеет прототип

int XDrawLine(
    Display* display, Drawable window, GC graphicContex,
    int x1, int y1, int x2, int y2
);
Тип GC определен как указатель на структуру, содержащую системный индентификатор графического контекста (т.е. просто целое число) и указатель на дополнительную информацию. Устройство системного графического контекста скрыто от программиста. Можно считать, что графический контекст расположен в системной памяти, недоступной программе. На графический контекст можно повлиять лишь с помощью системных вызовов.

Зачем нужен графический контекст? Он хранит текущие установки, влияющие на рисование, например, текущий цвет, фон, текущий шрифт и т.п. Для нас наиболее важно, что графический контекст устанавливает область клипирования (clip region), т.е. ту область, которой ограничивается рисование. На окно как бы накладывается пленка, в которой прорезаны отверстия, соответствующие области клипирования. Мы можем пытаться рисовать во всем окне, но следы краски останутся только там, где нет пленки.

Делается это для того, чтобы при перерисовке окна реально перерисовывались лишь необходимая область. Например, пусть в программе мы изменили некоторые данные в памяти. Окно обычно графически изображает состояние данных. Можно было бы перерисовать лишь ту часть окна, изображение которой должно измениться в результате изменения данных. Проще, однако, сначала ограничить область клипирования этой частью окна, а затем перерисовать все окно. Другой пример: пусть часть вашего окна была закрыта другим окном, затем это другое окно оттащили в сторону. Система автоматически должна ограничить область клипирования той частью окна, которая до этого была скрыта, и послать сообщение о необходимости перерисовки (Expose event). В ответ на него программа может перерисовать все окно, но рисование будет ограничено областью клипирования, поэтому неприятного для глаз мерцания не будет.

В нашем классе GWindow графический контекст жестко связан с окном (он является членом класса GWindow), поэтомы в методах рисования его не нужно передавать как параметр. Например, рисование линии выполняется с помощью метода

    void drawLine(int x1, int y1, int x2, int y2);
при этом реально используется графический контекст, связанный с окном. Область клипирования устанавливается перед вызовом метода "onExpose", в котором сосредоточено все рисование в окне.

Если, однако, мы хотим нарисовать часть окна, не перерисовывая все окно (чтобы не было неприятного мерцания), то иногда нельзя использовать графический контекст, связанный с окном, поскольку в нем текущая область клипирования может быть ограничена. Поэтому можно временно создать новый графический контекст, установить его в качестве используемого графического контекста окна (сохранив при этом предыдущий контекст), выполнить необходимое рисование, восстановить предыдущий графический контекст окна и затем удалить временный графический контекст. Именно такие действия выполняются при рисовании курсора или статусной строки редактора в случае, когда последний аргумент соответствующего метода рисования равен "true" (по умолчанию этот параметр равен "false"):

    void drawCursor(
        int cx, int cy, bool on, bool createGC = false
    );
    void drawStatusLine(bool createGC = false);
Рисование статусной строки выглядит следующим образом:
void TextEdit::drawStatusLine(bool createGC /*= false*/) {
    GC savedGC;
    if (createGC) {
        // Save the previous graphic contex, create a temporary GC
        savedGC = m_GC;
        m_GC = XCreateGC(m_Display, m_Window, 0, 0);
        setFont(textFont);
    }

    // ... рисование статусной строки ...

    if (createGC) {
        // Release the temporary GC, restore the previous GC
        XFreeGC(m_Display, m_GC);
        m_GC = savedGC;
    }
}
Здесь сами функции рисования опущены, внимание сосредоточено лишь на работе с графическим контекстом.

Подобный прием применяется при программировании в большинстве графических систем (Unix X-Windows, MS Windows и т.п.). Нормальное рисование сосредоточено в методе, обрабатывающем сообщение типа Expose (или WM_PAINT в системе MS Windows). Но если мы хотим что-то быстро нарисовать, не посылая сообщение Expose окну, то мы должны создать временный графический контекст, нарисовать что-то с его помощью, а затем удалить его.

Концепция Document -- View

Во многих системах разработки приложений (например, в библиотеке классов Microsoft Foundation Classes, являющейся основным инструментом для разработки приложений под MS Windows) используется следующий подход:

В случае нашего текстового редактора мы не выделяем отдельно класс "Документ" и класс "Отображение документа", поскольку мы не предусматриваем возможности открытия двух разнах окон для одного и того же текстового файла. Роль документа выполняет у нас объект "text" типа "Text" (Л2-список строк), который в нашем случае является членом класса "TextEdit". Объект "View" -- это сам класс "TextEdit", который наследует все свойства графического окна ("class GWindow") и который предназначен для изображения и изменения текста.

Хотя в нашем случае документ и его отображение не разделены в явном виде, следует хотя бы мысленно различать эти две аспекта реализации приложения: работа с данными в памяти и их отображение в графическом окне. Графическое окно -- это всегда отображение данных, содержащихся в документе. Поэтому, например, бессмысленно что-то рисовать в окне, не меняя параллельно данных в памяти. Аналогично, при изменении данных в памяти следует перерисовать ту часть окна (или окон), которая изображает эти данные.


Задачи по проекту "Текстовый Редактор"


Конец лекции