Операции пользователя

Часть функциональности Lexi доступна через WYSIWYG-представление документа. Вы вводите и удаляете текст, перемещаете точку вставки и выбираете участки текста, просто указывая и щелкая мышью или нажимая клавиши. Другая часть функциональности доступна через выпадающие меню, кнопки и клавиши-ускорители. К этой категории относятся такие операции:
  • создание нового документа;
  • открытие, сохранение и печать существующего документа;
  • вырезание выбранной части документа и вставка ее в другое место;
  • изменение шрифта и стиля выбранного текста;
  • изменение форматирования текста, например, установка режима выравнивания;
  • завершение приложения и др.

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

Далее. Операции реализованы в разных классах. Нам как разработчикам хотелось бы получать доступ к функциональности классов, не создавая зависимостей между классами, отвечающими за реализацию и за пользовательский интерфейс. В противном случае получится сильно связанный код, который будет трудно понять, модернизировать и сопровождать.

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

Ясно, что поддержка пользовательских операций распределена по всему приложению. Задача в том, чтобы найти простой и расширяемый механизм, удовлетворяющий всем вышеизложенным требованиям.

Инкапсуляция запроса

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

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

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

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

Нам не хватает механизма параметризации пунктов меню запросами, которые они должны выполнять. Таким способом удалось бы избежать разрастания числа подклассов и обеспечить большую гибкость во время выполнения. Параметризовать MenuItem можно вызываемой функцией, но это решение неполно по трем причинам:
  • в нем не учитывается проблема отмены/повтора;
  • с функцией трудно ассоциировать состояние. Например, функция, изменяющая шрифт, должна «знать», какой именно это шрифт;
  • функции с трудом поддаются расширению, а использовать их части тоже затруднительно.

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

Класс Command и его подклассы

Сначала определим абстрактный класс Command, который будет предоставлять интерфейс для выдачи запроса. Базовый интерфейс включает всего одну абстрактную операцию Execute. Подклассы Command по-разному реализуют эту операцию для выполнения запросов. Некоторые подклассы могут частично или полностью делегировать работу другим объектам, а остальные выполняют запрос сами (см. рис. 2.11).


Однако для запрашивающего объект Command — это всего лишь объект Command, все они рассматриваются одинаково.

Теперь в классе MenuItem может храниться объект, инкапсулирующий запрос (рис. 2.12).


Каждому объекту, представляющему пункт меню, мы передаем экземпляр того из подклассов Command, который соответствует этому пункту, точно так же, как мы задаем текст, отображаемый в пункте меню. Когда пользователь выбирает некоторый пункт меню, объект MenuItem просто вызывает операцию Execute для своего объекта Command, тем самым предлагая ему выполнить запрос. Заметим, что кнопки и другие виджеты могут пользоваться объектами Command точно так же, как и пункты меню.

Отмена операций

Для того чтобы отменить или повторить команду, нужна операция Unexecute в интерфейсе класса Command. Ее выполнение отменяет все, что было сделано предыдущей командой Execute. При этом используется информация, сохраненная операцией Execute. Так, при команде FontCommand операция Execute была бы должна сохранить координаты участка текста, шрифт которого изменялся, а равно и первоначальный шрифт (или шрифты). Операция Unexecute класса FontCommand должна была бы восстановить старый шрифт (или шрифты) для этого участка текста.

Иногда определять, можно ли осуществить отмену, необходимо во время выполнения. Скажем, запрос на изменение шрифта выделенного участка текста не производит никаких действий, если текст уже отображен требуемым шрифтом. Предположим, что пользователь выбрал некий текст и решил изменить его шрифт на случайно выбранный. Что произойдет в результате последующего запроса на отмену? Должно ли бессмысленное изменение приводить к столь же бессмысленной отмене? Наверное, нет. Если пользователь повторит случайное изменение шрифта несколько раз, то не следует заставлять его выполнять точно такое же число отмен, чтобы вернуться к последнему осмысленному состоянию. Если суммарный эффект выполнения последовательности команд нулевой, то нет необходимости вообще делать что-либо при запросе на отмену.

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

История команд

Последний шаг в направлении поддержки отмены и повтора с произвольным числом уровней - определение истории команд, то есть списка ранее выполненных или отмененных команд. Концептуально история команд выглядит так:


Каждый кружок представляет один объект Command. В данном случае пользователь выполнил четыре команды. Линия с пометкой «настоящее» показывает самую последнюю выполненную (или отмененную) команду. Для того чтобы отменить последнюю команду, мы просто вызываем операцию Unexecute для самой последней команды:


После отмены команды сдвигаем линию «настоящее» на одну команду влево. Если пользователь выполнит еще одну отмену, то произойдет откат на один шаг (см. рис. ниже).


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

Чтобы повторить только что отмененную команду, произведем обратные действия. Команды справа от линии «настоящее» - те, что могут быть повторены в будущем. Для повтора последней отмененной команды мы вызываем операцию Execute для последней команды справа от линии «настоящее»:


Затем мы сдвигаем линию «настоящее» так, чтобы следующий повтор вызвал операцию Execute для следующей команды «в области будущего».


Разумеется, если следующая операция - это не повтор, а отмена, то команда слева от линии «настоящее» будет отменена. Таким образом, пользователь может перемещаться в обоих направлениях, чтобы исправить ошибки.

Паттерн команда

Команды Lexi - это пример применения паттерна команда, описывающего, как инкапсулировать запрос. Этот паттерн предписывает единообразный интерфейс для выдачи запросов, с помощью которого можно сконфигурировать клиенты для обработки разных запросов. Интерфейс изолирует клиента от реализации запроса. Команда может полностью или частично делегировать реализацию запроса другим объектам либо выполнять данную операцию самостоятельно. Это идеальное решение для приложений типа Lexi, которые должны предоставлять централизованный доступ к функциональности, разбросанной по разным частям программы. Данный паттерн предлагает также механизмы отмены и повтора, надстроенные над базовым интерфейсом класса Command.