Проект "Текстовый редактор" (TextEdit.zip) иллюстрирует разработку программы, имеющей практическое значение. Рассматриваемый простой текстовый редактор вполне можно использовать в работе, если дополнить его небольшим набором дополнительных команд (типа запоминания-вспоминания фрагментов текста), которые сознательно не были реализованы. Реализация этих дополнительных команд вынесена в качестве задач для экзамена.
Проект достаточно объемный (порядка полутора тысяч строк, если включить реализацию списка и тестовой строки, но не включать реализацию графического окна). Вместе с тем структура проекта не сложная, и реализация дополнительных команд не представляет особой трудности.
Текстовый редактор предназначен для просмотра и изменения обычных текстовых файлов. Мы рассматриваем текст как последовательность строк. Это отличается от модели текста, принятой в редакторах типа Microsoft Word, в которых основной единицей является абзац. Такая модель больше подходит для литературных текстов, в которых разбиение текста на строки не имеет значения. Но при редактировании компьютерных программ разбиение на строки крайне важно. К тому же большинство людей привыкло к восприятию текста как последовательности строк еще со времен пишущих машинок (да и книжный текст так выглядит).
Итак, текст -- это последовательность строк. Для хранения текста используется структура данных Л2-список, поскольку нормальной операцией является добавление и удаление строк в середине текста, а в списке такие операции реализованы максимально эффективно. Текст хранится в объекте класса "Text", который выводится из класса "L2List".
class Text: public L2List { ... };Класс "Text" расширяет возможности списка, в него добавлены такие методы, как сохранение и загрузка текста из файла, получение строки с индексом n и т.д.
Класс "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(const char* line);
operator const char*() const;
TextLine t; . . . if (strcmp(t, "abcd") == 0) { ... }
Класс "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();
Этот метод
void TextEdit::preProcessCommand() { inputDisabled = true; // Disable any input drawCursor(cursorX, cursorY, false, true); // Remove cursor }В методе "drawCursor" третий параметр "false" означает, что курсор стирается (если "true", то, наоборот, рисуется). Четвертый параметр "true" означает, что при рисовании создается временный графический контекст, который используется только внутри этого метода и удаляется по окончании рисования; "false" означало бы, что нужно использовать стандартный графический контекст, связанный с окном типа GWindow. Делается это для того, чтобы можно было рисовать независимо от текущей установки региона клипирования для стандартного графического контекста окна (поскольку иногда перед перерисовкой части окна регион клипирования ограничивается этой частью). Более подробные объяснения будут даны ниже.
В этом методе выполняются следующие действия:
void scrollToCursor();Окно как бы гонится за курсором, пытаясь вновь поймать его в свою рамку. При перемещении окна по тексту изображение в окне "роллируется", т.е. как бы прокручивается по вертикали или горизонтали. Можно было бы просто изменить положение окна в тексте и перерисовать окно, однако это привело бы к неприятному мерцанию при полной перерисовке. Поэтому вместо полной перерисовки используются возможности графической системы X-Windows, позволяющей копировать прямоугольные области окна из одной позиции в другую (или даже между различными окнами!). При этом неприятного для глаз мерцания не происходит. Перерисовывается только та часть текста в окне, которая заново появляется, как бы "выезжает" на экран из-за пределов окна. Метод "scrollToCursor" реализован с помощью методов
void scrollLeft(int n); void scrollRight(int n); void scrollUp(int n); void scrollDown(int n);которые роллируют окно в соответствующем направлении на n позиций;
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);означает, что перерисовывается текствый прямоугольник с левым верхним углом в начале текущей строки, имеющий бесконечную ширину и бесконечную высоту. В методе атоматически будет вычислено пересечение этого прямоугольника с прямоугольником окна, и часть окна, начиная с текущей строки и ниже нее, будет перерисована.
Аналогично реализуются и другие команды редактора.
Для добавления новой команды нужно:
void onPageDown();
const struct TextEdit::CommandDsc TextEdit::editorCommands[] = { ...В нашем примере в список инициализации этого массива добавляется строка
{XK_Page_Down, 0, 0, false, &TextEdit::onPageDown}Нули в полях state и stateMask структуры CommandDsc означают, что команда будет выполняться независимо от состояния модификаторов клавиатуры (Shift, Ctrl, Alt и т.д.). Если мы хотим зарезервировать нажатие Shift+PageDown для какой-либо другой команды, то мы должны вместо этого написать строку
{XK_Page_Down, 0, ShiftMask, false, &TextEdit::onPageDown}Константа ShiftMask в третьем поле структуры при побитовом логическом умножении "вырезает" из слова состояния модификаторов клавиатуры лишь бит, соответствующий модификатору Shift. Ноль во втором поле означает, что команда будет выполняться только тогда, когда бит Shift установлен в ноль, т.е. когда клавиша Shift отпущена.
Константа "false" в четвертом поле структуры означает, что данная команда не изменяет текст;
void TextEdit::onPageDown() { if (windowY < text.size() - windowHeight) { // If not the last page windowY += windowHeight; // then move the window down, cursorY += windowHeight; // move the cursor down, redraw(); // redraw all window } else { cursorY = text.size(); // else move cursor to the end of text } }
Графический контекст создается для рисования в окне. Все системные функции рисования имеют графический контекст в качестве одного из аргументов. Например, системная функция рисования линии имеет прототип
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 окну, то мы должны создать временный графический контекст, нарисовать что-то с его помощью, а затем удалить его.
В случае нашего текстового редактора мы не выделяем отдельно класс "Документ" и класс "Отображение документа", поскольку мы не предусматриваем возможности открытия двух разнах окон для одного и того же текстового файла. Роль документа выполняет у нас объект "text" типа "Text" (Л2-список строк), который в нашем случае является членом класса "TextEdit". Объект "View" -- это сам класс "TextEdit", который наследует все свойства графического окна ("class GWindow") и который предназначен для изображения и изменения текста.
Хотя в нашем случае документ и его отображение не разделены в явном виде, следует хотя бы мысленно различать эти две аспекта реализации приложения: работа с данными в памяти и их отображение в графическом окне. Графическое окно -- это всегда отображение данных, содержащихся в документе. Поэтому, например, бессмысленно что-то рисовать в окне, не меняя параллельно данных в памяти. Аналогично, при изменении данных в памяти следует перерисовать ту часть окна (или окон), которая изображает эти данные.
Конец лекции