Язык MQL как средство разметки графического интерфейса MQL-программ. Часть 1

Stanislav Korotky | 28 марта, 2020

Нужен ли графический оконный интерфейс в программах на MQL? Общего мнения на этот счет нет. С одной стороны, мечтой трейдера является самый простой способ общения с торговым роботом — кнопка разрешения торговли, которая начинает волшебным образом "рубить капусту". С другой стороны, на то она и мечта, что далека от реальности, поскольку обычно требуется долго и кропотливо подбирать кучу настроек, прежде чем система заработает, но и после этого её требуется контролировать и при необходимости корректировать вручную. Я уж не говорю про адептов полностью ручной торговли — в их случае подбор удобной интуитивной торговой панели — половина успеха. В целом, можно констатировать, что оконный интерфейс в том или ином виде скорее нужен, чем нет.

Введение в технологию разметки GUI

Для построения графического интерфейса MetaTrader предоставляет несколько наиболее востребованных управляющих элементов, как в виде самостоятельных объектов, размещаемых на чартах, так и в обёртке "контролов" стандартной библиотеки, которые можно организовать в единое интерактивное окно. Существует также несколько альтернативных решений по построению GUI. Однако во всех этих библиотеках очень редко затрагивается вопрос раскладки элементов, в некотором смысле автоматизации дизайна интерфейса.

Конечно, редко кто додумывается рисовать на чарте окно, не уступающее по сложности самому MetaTrader-у, но и простая на первый взгляд торговая панель может состоять из десятков "контролов", управление которыми из MQL превращается в настоящую рутину.

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

Давайте вспомним, как создается интерфейс в стандартных примерах MQL.

  bool CPanelDialog::Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2)
  {
    if(!CAppDialog::Create(chart, name, subwin, x1, y1, x2, y2)) return(false);
    // create dependent controls
    if(!CreateEdit()) return(false);
    if(!CreateButton1()) return(false);
    if(!CreateButton2()) return(false);
    if(!CreateButton3()) return(false);
    ...
    if(!CreateListView()) return(false);
    return(true);
  }
  
  bool CPanelDialog::CreateButton2(void)
  {
    // coordinates
    int x1 = ClientAreaWidth() - (INDENT_RIGHT + BUTTON_WIDTH);
    int y1 = INDENT_TOP + BUTTON_HEIGHT + CONTROLS_GAP_Y;
    int x2 = x1 + BUTTON_WIDTH;
    int y2 = y1 + BUTTON_HEIGHT;
  
    if(!m_button2.Create(m_chart_id, m_name + "Button2", m_subwin, x1, y1, x2, y2)) return(false);
    if(!m_button2.Text("Button2")) return(false);
    if(!Add(m_button2)) return(false);
    m_button2.Alignment(WND_ALIGN_RIGHT, 0, 0, INDENT_RIGHT, 0);
    return(true);
  }
  ...

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

А вот как обычно выглядит описание интерфейсных элементов в классе диалога.

  CEdit        m_edit;          // the display field object
  CButton      m_button1;       // the button object
  CButton      m_button2;       // the button object
  CButton      m_button3;       // the fixed button object
  CSpinEdit    m_spin_edit;     // the up-down object
  CDatePicker  m_date;          // the datepicker object
  CListView    m_list_view;     // the list object
  CComboBox    m_combo_box;     // the dropdown list object
  CRadioGroup  m_radio_group;   // the radio buttons group object
  CCheckGroup  m_check_group;   // the check box group object

Этот плоский список "контролов" может быть очень длинным и его трудно воспринимать и поддерживать без наглядных "подсказок", которые могла бы дать раскладка.

В других языках программирования дизайн интерфейса обычно отделяют от программирования. Для описания раскладки элементов используются декларативные языки, например, XML или JSON.

В частности, с базовыми принципами описания элементов интерфейса для проектов на Android можно познакомиться в документации или в учебных пособиях. Достаточно иметь базовое представление об XML, чтобы ухватить суть. В таких файлах явно просматривается иерархия, определены элементы-контейнеры (LinearLayout, RelativeLayout) и одиночные "контролы" (ImageView, TextView, CheckBox), в свойствах задана автоматическая подстройка размеров под содержимое (match_parent, wrap_content), ссылки на централизованное описание стилей, опционально указываются обработчики событий, хотя все элементы, разумеется, можно дополнительно настраивать и привязывать к ним другие обработчики событий из исполняемого кода.

Если вспомнить платформу .Net, то там тоже используется похожее декларативное описание интерфейсов с помощью языка XAML. Даже для тех, кто никогда не программировал на C# и других языках инфраструктуры управляемого кода (концепция которой, кстати говоря, очень похожа на платформу MetaTrader и её "управляемый" язык MQL), здесь также очевидны основные моменты — "контролы", контейнеры, свойства, реакция на действия пользователя — в одном флаконе.

Для чего раскладку отделяют от кода и описывают на специальном языке? Вот основные преимущества такого подхода.

Для среды MQL были предприняты немногочисленные попытки решить кое-какие из этих задач. В частности, визуальный конструктор диалогов представлен в статье Как проектировать и конструировать классы объектов, он работает на основе библиотеки MasterWindows. Но способы раскладки и перечень поддерживаемых типов элементов в нем сильно ограничены.

Более продвинутая система раскладки, но без визуального дизайнера предложена в статьях Применение контейнеров для компоновки графического интерфейса: класс CBox и класс CGrid. Она поддерживает все стандартные элементы управления и прочие, унаследованные от CWndObj или CWndContainer, однако по-прежнему оставляет на пользователе рутинное программирование по созданию и размещению компонентов.

Концептуально данный подход с контейнерами является очень технологичным (достаточно указать на его популярность практически по всех языках разметки), и потому мы примем его на вооружение. В одной из моих предыдущих статей (Применение OLAP в трейдинге (Часть 2): Визуализация результатов интерактивного анализа многомерных данных) была предложена модификация контейнеров CBox и CGrid, а также некоторых элементов управления для поддержки свойств "резиновости". Далее мы воспользуемся этими наработками и усовершенствуем их для решения задачи автоматического размещения элементов на примере объектов стандартной библиотеки.

Графический редактор интерфейса: за и против

Основная функция редактора графического интерфейса — создавать и настраивать свойства элементов в окне на лету, по командам пользователя. Это подразумевает поля ввода для выбора свойств, а чтобы они работали требуется знать список свойств и их типы для каждого класса. Таким образом, у каждого "контрола" должно быть две связанных друг с другом версии: так называемые, run-time (для штатной работы) и design-time (для интерактивного проектирования интерфейса). Первая версия у "контролов" есть изначально — это тот класс, который работает в окнах. Вторая версия — это обертка "контрола", предназначенная для просмотра и изменения его доступных свойства. Писать такую обертку для каждого типа элементов было бы тяжело. Поэтому желательно этот процесс автоматизировать. Теоретически для этих целей можно воспользоваться анализатором синтаксиса MQL, описанным в статье Синтаксический анализ MQL средствами MQL. Во многих языках программирования понятие свойства (property) вынесено в синтаксис языка и объединяет в себе "сеттер" и "геттер" некоторого внутреннего поля объекта. В MQL такого пока нет, но в оконных классах стандартной библиотеки применен похожий принцип: для установки и чтения одного и того же поля используется пара "зеркальных" одноименных методов — один принимает значение конкретного типа, а другой его возвращает. Например, вот как определено свойство "только чтение" поля ввода CEdit:

    bool ReadOnly(void) const;
    bool ReadOnly(const bool flag);

А вот как обеспечивается работа с верхним пределом CSpinEdit:

    int  MaxValue(void) const;
    void MaxValue(const int value);

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

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

Выделим 2 основные цели дизайна: установление иерархической подчиненности элементов и их свойств. Если бы нашлись альтернативные способы их достижения, от визуального редактора можно было бы отказаться.

По здравому размышлению становится понятно, что основные свойства у всех элементов — стандартные: тип, размер, выравнивание, текст, стиль (цвет). Устанавливать специфические свойства можно и в коде MQL, благо это одиночные операции и они, как правило, связаны с бизнес-логикой. Что касается типа, размера и выравнивания, то они неявным образом задаются самой иерархией объектов.

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

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

    CBox m_main;                       // main client window
    
        CBox m_edit_row;                   // top level container/group
            CEdit m_edit;                      // control
      
        CBox m_button_row;                 // top level container/group
            CButton m_button1;                 // control
            CButton m_button2;                 // control
            CButton m_button3;                 // control
      
        CBox m_spin_date_row;              // top level container/group
            SpinEdit m_spin_edit;              // control
            CDatePicker m_date;                // control
      
        CBox m_lists_row;                  // top level container/group
      
            CBox m_lists_column1;              // nested container/group
                ComboBox m_combo_box;              // control
                CRadioGroup m_radio_group;         // control
                CCheckGroup m_check_group;         // control
        
            CBox m_lists_column2;              // nested container/group
                CListView m_list_view;             // control

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

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

Проектируем язык разметки

Итак, нам требуется разработать язык разметки, описывающий общую структуру оконного интерфейса и свойства его отдельных элементов. Здесь можно было бы опереться на широко распространенный формат XML и зарезервировать набор соответствующих тегов. Их даже можно было бы позаимствовать у какой-нибудь другой среды разработки, вроде тех, что упоминались выше. Но тогда нам понадобилось бы парсить XML и интерпретировать его затем на MQL, переводя в действия по созданию и настройке объектов. Кроме того, поскольку необходимость в визуальном редакторе отпала, "внешний" язык разметки как средство общения между редактором и средой исполнения также стал ненужным.

При таких условиях возникает идея — нельзя ли использовать сам MQL как язык разметки? Действительно, можно.

Иерархия заложена в языке MQL изначально. На ум здесь сразу приходят классы, наследуемые один от другого. Но классы описывают статическую иерархию, формируемую до выполнения кода. А нам нужна иерархия, которую можно было бы интерпретировать по мере выполнения кода MQL. В некоторых других языках программирования для этих целей (анализ иерархии и внутренней структуры классов из самой программы) имеется встроенное средство — так называемая информация о типах времени исполнения (run-time type information, RTTI, известная также как "отображения" (reflections)). Но в MQL таких средств нет.

Однако в MQL есть и другая иерархия (как в большинстве языков программирования) — иерархия контекстов исполнения фрагментов кода. Каждая пара фигурных скобок в функции/методе (то есть за исключением тех скобок, которые используются для описания классов и структур) формирует контекст — область жизни локальных переменных. Поскольку уровень вложенности блоков не ограничен, мы можем с помощью них описывать произвольные иерархии.

Похожий подход уже был использован на MQL, в частности для реализации собственного профайлера — измерителя скорости выполнения кода (статья в английском блоге — MQL's OOP notes: Self-made profiler on static and automatic objects). Суть его работы проста. Если в блоке кода, помимо операций, выполняющих прикладную задачу, задекларировать локальную переменную:

  {
    ProfilerObject obj;
    
    ... // code lines of your actual algorithm
  }

то она будет создана сразу после входа в блок и уничтожена перед выходом из него. Это относится к объектам любого класса, в том числе и такого, который может учитывать данное поведение. В частности, в конструкторе и деструкторе можно засечь время этих инструкций и тем самым посчитать длительность прикладного алгоритма. Разумеется, для аккумулирования этих измерений требуется другой, более старший объект — сам профайлер, но устройство обмена данными между ними не столько важно здесь (желающие могут найти подробности в блоге). Суть в том, чтобы применить тот же принцип для описания раскладки. Иными словами, она станет выглядеть примерно так:

  container<Dialog> dialog(&this);
  {
    container<classA> main; // create classA internal object 1
    
    {
      container<classB> top_level(name, property, ...); // create classB internal object 2
      
      {
        container<classC> next_level_1(name, property, ...); // create classC internal object 3
        
        {
          control<classX> ctrl1(object4, name, property, ...); // create classX object 4
          control<classX> ctrl2(object5, name, property, ...); // create classX object 5
        } // register objects 4&5 in object 3 (via ctrl1, ctrl2 in next_level_1) 
      } // register object 3 in object 2 (via next_level_1 in top_level)
      
      {
        container<classC> next_level2(name, property, ...); // create classC internal object 6
        
        {
          control<classY> ctrl3(object7, name, property, ...); // create classY object 7
          control<classY> ctrl4(object8, name, property, ...); // create classY object 8
        } // register objects 7&8 in object 6 (via ctrl3, ctrl4 in next_level_2)
      } // register object 6 in object 2 (via next_level_2 in top_level)
    } // register object 2 in object 1 (via top_level in main)
  } // register object 1 (main) in the dialog (this)

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

Всю эту "магию" должно обеспечить внутреннее устройство классов "container" и "control". На самом деле, это будет один и тот же класс "layout", но для большей наглядности в вышеприведенной схеме подчеркнуто различие между контейнерами и элементами управления. В реальности различие строится лишь на том, какие классы указаны параметрами шаблона. Так, классы Dialog, classA, classB, classC в примере выше должны быть оконными контейнерами, то есть поддерживать хранение в себе "контроллов".

Следует различать короткоживущие вспомогательные объекты раскладки (названы выше как main, top_level, next_level_1, ctrl1, ctrl2, next_level2, ctrl3, ctrl4) и управляемые ими объекты интерфейсных классов (object 1 ... object 8), которые останутся привязанными друг к другу и к окну. Весь этот код будет выполняться как метод диалога (аналог метода Create), и потому объект диалога доступен как this.

В некоторые объекты раскладки мы передаем объекты GUI как переменные класса (object 4, 5, 7, 8), а в некоторые — нет (указано имя и свойства). В любом случае объект GUI должен существовать, но в явном виде он нам нужен не всегда. Если "контрол" используется для последующего взаимодействия с алгоритмом, то удобно иметь на него ссылку. А контейнеры обычно не связаны с логикой программы и выполняют только функции размещения "контролов", поэтому их создание происходит неявно внутри системы раскладки.

Конкретный синтаксис записи свойств и их перечень мы разработаем чуть позже.

Классы для интерфейсной раскладки: абстрактный уровень

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

Центральное место займет класс LayoutData.

  class LayoutData
  {
    protected:
      static RubbArray<LayoutData *> stack;
      static string rootId;
      int _x1, _y1, _x2, _y2;
      string _id;
    
    public:
      LayoutData()
      {
        _x1 = _y1 = _x2 = _y2 = 0;
        _id = NULL;
      }
  };

В нем хранится минимальное количество информации, присущее любому элементу раскладки: уникальное имя _id и координаты. Уточним, что данное поле _id определяется на абстрактом уровне, и в каждой конкретной библиотеке GUI может "отображаться" на своё свойство "контролов". В частности, в стандартной библиотеке это поле называется m_name и доступно через публичный метод CWnd::Name. У двух объектов имя совпадать не может. В CWnd определено также и поле m_id типа long — оно используется для диспетчерезации сообщений. Когда мы дойдем до прикладной реализации, не следует его путать с нашим _id.

Кроме того класс LayoutData предоставляет статическое хранилище собственных экземпляров в виде стека (stack) и идентификатор экземпляра окна (rootId). Статичность двух последних членов не проблема, потому что каждая MQL программа выполняется в единственном потоке, и если даже в ней будет несколько окон, в каждый момент времени создаваться может только одно из них. Когда одно окно будет отрисовано, стек уже опустеет и готов работать с другим окном. Идентификатор окна rootId известен для стандартной библиотеки как поле m_instance_id в классе CAppDialog. Для других библиотек должно существовать нечто похожее (не обязательно строка, но нечто уникальное, приводимое к строке), потому что иначе окна могут конфликтовать между собой. Этой проблемы мы еще коснемся.

Наследником класса LayoutData будет типизированный LayoutBase. Это прообраз того самого класса раскладки, порождающего элементы интерфейса по MQL-коду с блоками из фигурных скобок в качестве инструкций.

  template<typename P,typename C>
  class LayoutBase: public LayoutData
  {
    ...

Два его шаблонных параметра P и C соответствуют классам элементов, которые служат контейнерами и "контролами".

Контейнеры по определению содержат в себе "контролы" и/или другие контейнеры, в то время как "контролы" воспринимаются как целое и не могут ничего в себе содержать. Здесь следует особо отметить, что под "контролом" понимается логически неделимая единица интерфейса, которая внутри может на самом деле состоять из множества вспомогательных объектов. В частности, классы CListView или CComboBox стандартной библиотеки являются "контролами", но внутри реализованы с помощью нескольких объектов. Это уже детали реализации, в других библиотеках похожие типы элементов управления могут реализовываться как единая канва, на которой отрисовываются кнопки и текст. В контексте абстрактных классов раскладки мы не должны в это углубляться, нарушая принципы инкапсуляции, но прикладная реализация, рассчитанная на конкретную библиотеку, разумеется, должна будет учитывать этот нюанс (и различать настоящие контейнеры от сложносоставных "контролов").

Для стандартной библиотеки лучшими кандидатами на параметры шаблона P и C являются CWndContainer и CWnd. Чуть забегая вперед отметим, что использовать CWndObj в качестве класса "контролов" нельзя, потому что многие "контролы" наследуются от CWndContainer. К их числу, например, относятся CComboBox, CListView, CSpinEdit, CDatePicker и другие. Однако в качестве параметра C должен выбираться ближайший общий класс всех "контролов", и таковым для стандартной библиотеки является CWnd. Как мы видим, класс контейнеров (такой как CWndContainer) может на практике "пересекаться" с простыми элементами, и потому нам в дальнейшем потребуется обеспечить более точную проверку на то, является ли конкретный экземпляр контейнером или нет. Аналогично, в качестве параметра P должен выбираться ближайший общий класс всех контейнеров. В стандартной библиотеке оконным классом является CDialog — наследник CWndContainer, но помимо него мы собираемся воспользоваться классами ветви CBox для группировки элементов внутри диалогов, а он происходит от CWndClient, который в свою очередь — от CWndContainer. Таким образом, ближайший общий предок — CWndContainer.

Поля класса LayoutBase будут хранить указатели на интерфейсный элемент, порождаемый объектом раскладки.

    protected:
      P *container; // not null if container (can be used as flag)
      C *object;
      C *array[];
    public:
      LayoutBase(): container(NULL), object(NULL) {}

Здесь container и object указывают на одно и то же, однако container не равен NULL, только если элемент действительно является контейнером.

Массив array позволяет с помощью одного объекта раскладки создать сразу группу однотипных элементов, например, кнопок. В этом случае указатели container и object будут равны NULL. Для всех членов имеются тривиальные методы-"геттеры", мы не станем их все приводить. Например, ссылку на object легко получить с помощью метода get().

Следующие три метода декларируют абстрактные операции над привязанным элементом, которые должен уметь выполнять объект раскладки.

    protected:
      virtual bool setContainer(C *control) = 0;
      virtual string create(C *object, const string id = NULL) = 0;
      virtual void add(C *object) = 0;

Метод setContainer позволяет отличить контейнер от обычного "контрола" в переданном параметре. Именно в этом методе предполагается заполнение поля container, и если оно не NULL, возвращается true.

Метод create инициализирует элемент (в стандартной библиотеке во всех классах есть похожий метод Create, но насколько я могу судить и в других библиотеках, например, EasyAndFastGUI, заложены аналогичные методы; правда в случае EasyAndFastGUI они зачем-то называются по-разному в разных классах, поэтому желающим подключить к ней описываемый механизм раскладок придется написать классы-адаптеры, унифицирующие программный интерфейс разнотипных "контролов"; но это еще не все — гораздо важнее написать для EasyAndFastGUI классы, аналогичные CBox и CGrid). В метод можно передать желаемый идентификатор элемента, но не факт, что исполнительный алгоритм учтет это пожелание полностью или частично (в частности, может быть добавлен instance_id), и потому реальный идентификатор можно узнать из возвращаемой строки.

Метод add добавляет элемент в родительский элемент-контейнер (в стандартной библиотеке эта операция выполняется методом Add, в EasyAndFastGUI — по-видимому, MainPointer).

Теперь посмотрим, как эти 3 метода задействованы на абстрактном уровне. Каждый элемент интерфейса у нас привязан к объекту раскладки и переживает 2 фазы: создание (в момент инициализации локальной переменной в блоке кода) и удаление (в момент выхода из блока кода и вызова деструктора локальной переменной). Для первой фазы напишем метод init, который будет вызываться из конструкторов классов-наследников.

      template<typename T>
      void init(T *ref, const string id = NULL, const int x1 = 0, const int y1 = 0, const int x2 = 0, const int y2 = 0)
      {
        object = ref;
        setContainer(ref);
        
        _x1 = x1;
        _y1 = y1;
        _x2 = x2;
        _y2 = y2;
        if(stack.size() > 0)
        {
          if(_x1 == 0 && _y1 == 0 && _x2 == 0 && _y2 == 0)
          {
            _x1 = stack.top()._x1;
            _y1 = stack.top()._y1;
            _x2 = stack.top()._x2;
            _y2 = stack.top()._y2;
          }
          
          _id = rootId + (id == NULL ? typename(T) + StringFormat("%d", object) : id);
        }
        else
        {
          _id = (id == NULL ? typename(T) + StringFormat("%d", object) : id);
        }
        
        string newId = create(object, _id);
        
        if(stack.size() == 0)
        {
          rootId = newId;
        }
        if(container)
        {
          stack << &this;
        }
      }

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

Метод сохраняет указатель на элемент в object, проверяет с помощью setContainer, является ли он контейнером (подразумевая, что если да, то поле container также будет заполнено), берет указанные координаты из входных параметров или, опционально, из родительского контейнера, если он уже есть в стеке. Вызов create инициализирует интерфейсный элемент. Если стек еще пуст, сохраняем идентификатор в rootId (в случае стандартной библиотеки это будет instance_id), так как первым элементом на стеке всегда будет самый главный контейнер — окно, отвечающее за все подчиненные элементы (в стандартной библиотеке — класс CDialog или производный). Наконец, если текущий элемент является контейнером, помещаем его в стек (stack << &this).

Метод init является шаблонным. Это позволяет автоматически генерировать имена "контролов" по типам, но кроме того мы скоро добавим другие аналогичные методы init. Один из них будет порождать элементы внутри, а не принимать готовыми снаружи, и в этом случае нужен конкретный тип. Другой вариант init рассчитан на регистрацию в раскладке сразу нескольких однотипных элементов (вспомним про член array[]), а массивы передаются по ссылкам, и ссылки не поддерживают конверсию типов ("parameter conversion not allowed", "no one of the overloads can be applied to the function call", в зависимости от структуры кода), в связи с чем опять требуется указание конкретного типа через параметр шаблона. Таким образом, все методы init будут иметь один и тот же "шаблонный" контракт (правила использования).

Самое интересное происходит в деструкторе LayoutBase.

      ~LayoutBase()
      {
        if(container)
        {
          stack.pop();
        }
        
        if(object)
        {
          LayoutBase *up = stack.size() > 0 ? stack.top() : NULL;
          if(up != NULL)
          {
            up.add(object);
          }
        }
      }
  };

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

Классы для интерфейсной раскладки: прикладной уровень для элементов стандартной библиотеки

Перейдем к более конкретным вещам — реализации классов для раскладки интерфейсных элементов стандартной библиотеки. Используя классы CWndContainer и CWnd в качестве параметров шаблона, определим промежуточный класс StdLayoutBase.

  class StdLayoutBase: public LayoutBase<CWndContainer,CWnd>
  {
    public:
      virtual bool setContainer(CWnd *control) override
      {
        CDialog *dialog = dynamic_cast<CDialog *>(control);
        CBox *box = dynamic_cast<CBox *>(control);
        if(dialog != NULL)
        {
          container = dialog;
        }
        else if(box != NULL)
        {
          container = box;
        }
        return true;
      }

Метод setContainer определяет с помощью динамического приведения типов, является ли элемент CWnd наследником CDialog или CBox, и если да — то это контейнер.

      virtual string create(CWnd *child, const string id = NULL) override
      {
        child.Create(ChartID(), id != NULL ? id : _id, 0, _x1, _y1, _x2, _y2);
        return child.Name();
      }

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

      virtual void add(CWnd *child) override
      {
        CDialog *dlg = dynamic_cast<CDialog *>(container);
        if(dlg != NULL)
        {
          dlg.Add(child);
        }
        else
        {
          CWndContainer *ptr = dynamic_cast<CWndContainer *>(container);
          if(ptr != NULL)
          {
            ptr.Add(child);
          }
          else
          {
            Print("Can't add ", child.Name(), " to ", container.Name());
          }
        }
      }
  };

Метод add добавляет дочерний элемент в родительский, предварительно делая максимально возможный "апкастинг", поскольку метод Add в стандартной библиотеке не является виртуальным (в принципе, можно было внести соответствующую правку с стандартную библиотеку, но о её изменении мы еще поговорим).

На основе класса StdLayoutBase создадим рабочий класс _layout, который и будет фигурировать в коде с описанием раскладки на MQL. Название начинается с подчеркивания, чтобы обратить внимание на нестандартное назначение объектов данного класса. Рассмотрим пока упрощенную версию класса. Позднее мы добавим в него дополнительный функционал. Всю работу фактически запускают конструкторы, внутри которых и вызывается тот или иной метод init из LayoutBase.

  template<typename T>
  class _layout: public StdLayoutBase
  {
    public:
      
      _layout(T &ref, const string id, const int dx, const int dy)
      {
        init(&ref, id, 0, 0, dx, dy);
      }
      
      _layout(T *ptr, const string id, const int dx, const int dy)
      {
        init(ptr, id, 0, 0, dx, dy);
      }
      
      _layout(T &ref, const string id, const int x1, const int y1, const int x2, const int y2)
      {
        init(&ref, id, x1, y1, x2, y2);
      }
      
      _layout(T *ptr, const string id, const int x1, const int y1, const int x2, const int y2)
      {
        init(ptr, id, x1, y1, x2, y2);
      }
      
      _layout(T &refs[], const string id, const int x1, const int y1, const int x2, const int y2)
      {
        init(refs, id, x1, y1, x2, y2);
      }
  };

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

Диаграмма классов раскладки GUI

Диаграмма классов раскладки GUI

Сейчас мы уже могли бы проверить на деле, как описание объекта, например, _layout<CButton> button(m_button, 100, 20) инициализирует и регистрирует объект m_button в диалоге, при условии, что тот описан во внешнем блоке примерно так: _layout<CAppDialog> dialog(this, name, x1, y1, x2, y2). Однако элементы имеют много других свойств помимо размеров. Некоторые свойства, такие как выравнивание по сторонам, не менее важны для раскладки, чем координаты. Действительно, если элемент имеет выравнивание (в терминах стандартной библиотеки alignment) по горизонтали, то он будет растянут на всю ширину области родительского контейнера, за вычетом заданных полей слева и справа. Таким образом, выравнивание имеет приоритет перед координатами. Более того, в контейнерах класса CBox важна ориентация (направление), в которой производится выкладка дочерних элементов — горизонтальная (по умолчанию) или вертикальная. Также было бы правильно поддержать и прочие свойства, влияющие на внешнее представление (вроде размера шрифта, цвета) и на режим работы (например, только чтение, "залипание" кнопки и т.д.).

В тех случаях, когда объект GUI описан в классе окна и передается в раскладку, мы могли бы использовать "родные" методы установки свойств (например, edit.Text("text")). Система раскладки поддерживает этот старый способ, но он не является единственным и оптимальным. Во многих случаях создание объектов удобно поручить системе раскладки, и тогда они не будут доступны напрямую из окна. Таким образом, необходимо каким-то образом расширить возможности класса _layout по настройке элементов.

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

Классы для настройки свойств элементов

На абстрактном уровне набор свойств подразделяется по типу значения. Мы поддержим основные встроенные типы языка MQL, а также некоторые другие, о которых речь пойдет позже. Синтаксически было бы удобно присваивать свойства через цепочку вызовов известного паттерна "строитель" (builder):

  _layout<CBox> column(...);
  column.style(LAYOUT_STYLE_VERTICAL).color(clrGray).margin(5);

Однако такой синтаксис подразумевает очень длинный набор методов в одном классе, причем этим классом должен быть класс раскладки, т.к. оператор разыменования (точка) нельзя переопределить. Можно было бы зарезервировать в классе _layout метод для возврата экземпляра вспомогательного объекта для свойств, примерно так:

  _layout<CBox> column(...);
  column.properties().style(LAYOUT_STYLE_VERTICAL).color(clrGray).margin(5);

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

Также следует отметить, что имена методов в "строительном" шаблоне в некотором смысле лишние, так как значения вроде LAYOUT_STYLE_VERTICAL или clrGray сами за себя "говорят", да и прочие типы часто не требуют детализации — так для "контрола" CEdit значение типа bool как правило означает флаг "только чтение", а для CButton — признак "залипания". В результате напрашивается решение просто присваивать значения с помощью некоторого перегруженного оператора. Но оператор присваивания, как ни странно, нам не подойдет, потому что он не позволяет нанизывать цепочку вызовов.

  _layout<CBox> column(...);
  column = LAYOUT_STYLE_VERTICAL = clrGray = 5; // 'clrGray' - l-value required ...

Однострочные операторы присваивания выполняются справа налево, то есть не от объекта, в котором введено перегруженное присваивание. Оно бы сработало вот так:

  ((column = LAYOUT_STYLE_VERTICAL) = clrGray) = 5; 

Но это выглядит не слишком красиво.

Вариант:

  column = LAYOUT_STYLE_VERTICAL; // orientation
  column = clrGray;               // color
  column = 5;                     // margin

тоже слишком длинный. Поэтому было принято решение перегрузить оператор <= и использовать примерно так:

  column <= LAYOUT_STYLE_VERTICAL <= clrGray <= 5.0;

Для этого в классе LayoutBase имеется заглушка:

    template<typename V>
    LayoutBase<P,C> *operator<=(const V value) // template function cannot be virtual
    {
      Print("Please, override " , __FUNCSIG__, " in your concrete Layout class");
      return &this;
    }

Её двойная цель — задекларировать намерение использовать перегрузку оператора и напомнить переопределить метод в производном классе. Там по идее должен применяться объект класса-посредника со следующим интерфейсом (показан не полностью).

  template<typename T>
  class ControlProperties
  {
    protected:
      T *object;
      string context;
      
    public:
      ControlProperties(): object(NULL), context(NULL) {}
      ControlProperties(T *ptr): object(ptr), context(NULL) {}
      void assign(T *ptr) { object = ptr; }
      T *get(void) { return object; }
      virtual ControlProperties<T> *operator[](const string property) { context = property; StringToLower(context); return &this; };
      virtual T *operator<=(const bool b) = 0;
      virtual T *operator<=(const ENUM_ALIGN_MODE align) = 0;
      virtual T *operator<=(const color c) = 0;
      virtual T *operator<=(const string s) = 0;
      virtual T *operator<=(const int i) = 0;
      virtual T *operator<=(const long l) = 0;
      virtual T *operator<=(const double d) = 0;
      virtual T *operator<=(const float f) = 0;
      virtual T *operator<=(const datetime d) = 0;
  };

Как мы видим, в классе-посреднике хранится ссылка на элемент (object), подлежащий настройке. Привязка осуществляется в конструкторе или с помощью метода assign. Если предположить, что у нас написан некий конкретный посредник класса MyControlProperties:

  template<typename T>
  class MyControlProperties: public ControlProperties<T>
  {
    ...
  };

то в классе _layout мы можем использовать его объект по такой схеме (добавленные строки и метод помечены комментариями):

  template<typename T>
  class _layout: public StdLayoutBase
  {
    protected:
      C *object;
      C *array[];
      
      MyControlProperties helper;                                          // +
      
    public:
      ...
      _layout(T *ptr, const string id, const int dx, const int dy)
      {
        init(ptr, id, 0, 0, dx, dy); // this will save ptr in the 'object'
        helper.assign(ptr);                                                // +
      }
      ...
      
      // non-virtual function override                                     // +
      template<typename V>                                                 // +
      _layout<T> *operator<=(const V value)                                // +
      {
        if(object != NULL)
        {
          helper <= value;
        }
        else
        {
          for(int i = 0; i < ArraySize(array); i++)
          {
            helper.assign(array[i]);
            helper <= value;
          }
        }
        return &this;
      }

За счет того, что оператор <= в _layout является шаблонным, он автоматически сгенерирует вызов для правильного типа параметра из интерфейса ControlProperties (разумеется, речь не об абстрактных методах интерфейса, а об их реализациях в производном классе MyControlProperties; мы такой скоро напишем для конкретной оконной библиотеки).

В некоторых случаях один и тот же тип данных используется для задания нескольких разных свойств. Например, тот же bool используется в CWnd при установке флагов видимости и активности элементов, помимо упомянутых выше режимов "только чтение" (для CEdit) и "залипание" (для CButton). Чтобы можно было явно указать имя свойства, в интерфейсе ControlProperties предусмотрен оператор[] с параметром типа строка. Он устанавливает поле context, на основе которого производный класс сможет изменить требуемую характеристику.

Для каждого сочетания типа входных данных и класса элемента одно из свойств (наиболее часто используемое) будет считаться свойством по-умолчанию (их примеры для CEdit и CButton приведены выше). Остальные свойства потребуют указания контекста.

Например, для кнопки CButton это будет выглядеть так:

  button1 <= true;
  button2["visible"] <= false;

В первой строке контекст не указан и потому подразумевается свойство "locking" (двухпозиционная кнопка). Во второй — кнопка создается изначально невидимой (что требуется довольно редко).

Рассмотрим основные нюансы реализации посредника StdControlProperties для библиотеки стандартных элементов. Полностью с кодом можно ознакомиться в прилагаемых файлах. В начале можно увидеть, как переопределен оператор <= для типа bool.

  template<typename T>
  class StdControlProperties: public ControlProperties<T>
  {
    public:
      StdControlProperties(): ControlProperties() {}
      StdControlProperties(T *ptr): ControlProperties(ptr) {}
      
      // we need dynamic_cast throughout below, because control classes
      // in the standard library does not provide a set of common virtual methods
      // to assign specific properties for all of them (for example, readonly
      // is available for edit field only)
      virtual T *operator<=(const bool b) override
      {
        if(StringFind(context, "enable") > -1)
        {
          if(b) object.Enable();
          else  object.Disable();
        }
        else
        if(StringFind(context, "visible") > -1)
        {
          object.Visible(b);
        }
        else
        {
          CEdit *edit = dynamic_cast<CEdit *>(object);
          if(edit != NULL) edit.ReadOnly(b);
          
          CButton *button = dynamic_cast<CButton *>(object);
          if(button != NULL) button.Locking(b);
        }
        
        return object;
      }

Для строк применяется следующее правило: любой текст попадает в заголовок "контрола", если только не задан контекст "font", что означает название шрифта:

      virtual T *operator<=(const string s) override
      {
        CWndObj *ctrl = dynamic_cast<CWndObj *>(object);
        if(ctrl != NULL)
        {
          if(StringFind(context, "font") > -1)
          {
            ctrl.Font(s);
          }
          else // default
          {
            ctrl.Text(s);
          }
        }
        return object;
      }

В классе StdControlProperties дополнительно введены переопределения <= для типов, свойственных только для стандартной библиотеки. В частности, он умеет принимать перечисление ENUM_WND_ALIGN_FLAGS, описывающее вариант выравнивания. Обратите внимание, что в данном перечислении помимо четырех сторон (лево, право, верх, низ) описаны не все, а лишь наиболее часто используемые сочетания, такие как выравнивание по ширине (WND_ALIGN_WIDTH = WND_ALIGN_LEFT|WND_ALIGN_RIGHT) или по всей клиентской области (WND_ALIGN_CLIENT = WND_ALIGN_WIDTH|WND_ALIGN_HEIGHT). Однако если требуется выровнять элемент по ширине и по верхнему краю, данное сочетание флагов уже не будет частью перечисления. Поэтому потребуется явно указывать привидение типа к нему ((ENUM_WND_ALIGN_FLAGS)(WND_ALIGN_WIDTH|WND_ALIGN_TOP)). В противном случае операция побитового "ИЛИ" даст тип int, и вызовется неправильная перегрузка установки целочисленных свойств. Альтернативой является указание контекста "align".

Наиболее "трудовым" вполне ожидаемо является переопределение для типа int. Здесь могут задаваться, в частности, ширина, высота, поля, размер шрифта и другие свойства. Для облегчения этой ситуации сделана возможность указывать размеры сразу в конструкторе объекта раскладки, а поля альтернативно можно задать с помощью чисел типа double или специального объединения PackedRect. Разумеется, для него тоже добавлена перегрузка оператора, его удобно использовать в случаях, когда нужны несимметричные поля:

  button <= PackedRect(5, 100, 5, 100); // left, top, right, bottom

потому что равные со всех сторон поля проще задать одним значением типа double:

  button <= 5.0;

Однако на выбор пользователя существует и альтернатива — контекст "margin", тогда double не нужен, и эквивалентная запись будет:

  button["margin"] <= 5;

Относительно полей и отступов следует обратить внимание на один нюанс. В стандартной библиотеке существует термин выравнивания (alignment), который включает в себя поля (margins), автоматически добавляемые вокруг "контрола". Вместе с тем, в классах семейства CBox реализован собственный механизм отступов (padding), которые представляют собой зазор внутри элемента-контейнера между его внешней границей и дочерними "контролами" (содержимым). Таким образом, поля с точки зрения "контролов" и отступы с точки зрения контейнеров по сути обозначают одно и то же. И поскольку два алгоритма позиционирования, к сожалению, не учитывают друг друга, одновременное использование и полей, и отступов может создавать проблемы (самая очевидная из них — не соответствующий ожиданиям сдвиг элементов). Общая рекомендация — оставлять отступы равными нулю и манипулировать полями. Однако, по обстоятельствам можно попробовать включить и отступы, особенно если речь о конкретном контейнере, а не общих настройках.

Данная статья является исследованием из разряда "проверки реализуемости концепции" (proof of concept, POC), и не предлагает готовое решение. Её задача — испытать предлагаемую технология на доступных на момент написания классах стандартной библиотеки и контейнеров, с минимальными правками всех этих компонентов. В идеале контейнеры (не обязательно CBox) должны быть написаны, как неотъемлемая часть библиотеки элементов GUI, и действовать с учетом всех возможных сочетаний режимов.

Ниже приведена таблица поддерживаемых свойств и элементов. Класс CWnd означает применимость свойств ко всем элементам, класс CWndObj — для простых "контролов" (два из них — CEdit и CButton, также указаны в таблице). Класс CWndClient обобщает составные "контролы" (CCheckGroup, CRadioGroup, CListView), а также является родительским для контейнеров CBox/CGrid.

Таблица поддерживаемых свойств по типам данных и классам элементов

type/control CWnd CWndObj CWndClient CEdit CButton CSpinEdit CDatePicker CBox/CGrid
bool visible
enable
visible
enable
visible
enable
(readonly)
visible
enable
(locking)
visible
enable
visible
enable
visible
enable
visible
enable
color (text)
background
border
(background)
border
(text)
background
border
(text)
background
border
(background)
border
string (text)
font
(text)
font
(text)
font
int width
height
margin
left
top
right
bottom
align
width
height
margin
left
top
right
bottom
align
fontsize
width
height
margin
left
top
right
bottom
align
width
height
margin
left
top
right
bottom
align
fontsize
width
height
margin
left
top
right
bottom
align
fontsize
(value)
width
height
margin
left
top
right
bottom
align
min
max
width
height
margin
left
top
right
bottom
align
width
height
margin
left
top
right
bottom
align
long (id) (id)
zorder
(id) (id)
zorder
(id)
zorder
(id) (id) (id)
double (margin) (margin) (margin) (margin) (margin) (margin) (margin) (margin)
float (padding)
left *
top *
right *
bottom *
datetime (value)
PackedRect (margin[4]) (margin[4]) (margin[4]) (margin[4]) (margin[4]) (margin[4]) (margin[4]) (margin[4])
ENUM_ALIGN_MODE (text align)
ENUM_WND_ALIGN_FLAGS (alignment) (alignment) (alignment) (alignment) (alignment) (alignment) (alignment) (alignment)
LAYOUT_STYLE (style)
VERTICAL_ALIGN (vertical align)
HORIZONTAL_ALIGN (horizonal align)


Полный исходный код класса StdControlProperties, обеспечивающий трансляцию свойств элементов раскладки в вызовы методов библиотеки стандартных компонентов, прилагается к статье.

Попробуем классы раскладки на практике. Мы наконец можем приступить к изучению примеров, продвигаясь от простого к сложному. По традиции, сложившейся с двух исходных статьей о компоновке GUI с помощью контейнеров, адаптируем под новую технологию игру в "пятнашки" (SlidingPuzzle4) и стандартное демо для работы с "контролами" (ControlsDialog4). Индексы соответствуют этапам обновления этих проектов. В той статье те же программы представлены с индексами 3, и желающие могут сравнить исходные коды. Примеры находятся в папке MQL5/Experts/Examples/Layouts/.

Пример 1. Пятнашки SlidingPuzzle

Единственное существенное изменение в публичном интерфейсе главной формы CSlidingPuzzleDialog — это появление нового метода CreateLayout. Его следует вызывать из обработчика OnInit вместо привычного Create. Список параметров у обоих методов одинаковый. Данная замена потребовалась потому, что диалог сам является объектом раскладки (самого внешнего уровня) и его метод Create будет автоматически вызван новым фреймворком (это делает метод StdLayoutBase::create, который мы рассмотрели ранее). А вся информация для фреймворка о форме и её содержимом как раз и задается в методе CreateLayout с помощью языка разметки на базе MQL. А вот и сам метод:

  bool CSlidingPuzzleDialog::CreateLayout(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2)
  {
    {
      _layout<CSlidingPuzzleDialog> dialog(this, name, x1, y1, x2, y2);
      {
        _layout<CGridTkEx> clientArea(m_main, NULL, 0, 0, ClientAreaWidth(), ClientAreaHeight());
        {
          SimpleSequenceGenerator<long> IDs;
          SimpleSequenceGenerator<string> Captions("0", 15);
          
          _layout<CButton> block(m_buttons, "block");
          block["background"] <= clrCyan <= IDs <= Captions;
          
          _layout<CButton> start(m_button_new, "New");
          start["background;font"] <= clrYellow <= "Arial Black";
          
          _layout<CEdit> label(m_label);
          label <= "click new" <= true <= ALIGN_CENTER;
        }
        m_main.Init(5, 4, 2, 2);
        m_main.SetGridConstraints(m_button_new, 4, 0, 1, 2);
        m_main.SetGridConstraints(m_label, 4, 2, 1, 2);
        m_main.Pack();
      }
    }
    m_empty_cell = &m_buttons[15];
    
    SelfAdjustment();
    return true;
  }

Здесь формируется последовательно два вложенных контейнера, каждый управляется своим объектом раскладки:

Затем в клиентской части инициализируется набор кнопок CButton m_buttons[16] с привязкой к единому объекту раскладки block, кнопка для начала игры (CButton m_button_new в объекте start) и информационная метка (CEdit m_label, объект label). Все локальные переменные (dialog, clientArea, block, start, label) обеспечивают автоматический вызов Create для элементов интерфейса в порядке выполнения кода, устанавливают им заданные дополнительные параметры (о параметрах подробнее чуть ниже), и в момент уничтожения, то есть выхода из области видимости очередного блока фигурных скобок, регистрируют связанные с ними элементы интерфейса в вышележащем контейнере. Таким образом, клиентская область m_main будет включена в окно this, а все "контролы" — в клиентскую область. Правда порядок выполнения в данном случае обратный, т.к. блоки закрываются начиная с самого вложенного. Но это не суть. В привычном способе создания диалогов происходит примерно то же самое: более крупные группы интерфейса создают более мелкие, те, в свою очередь, создают еще более мелкие, вплоть до уровня отдельных "контролов", и начинают добавлять проинициализированные элементы в обратном (восходящем) порядке: сперва "контролы" добавляются в средние блоки, потом средние блоки добавляются в крупные.

Для диалога и клиентской области все параметры передаются через параметры конструкторов (это похоже на стандартный метод Create). В "контролы" передавать размеры нам не надо, так как их автоматически правильно размещает класс GridTkEx, а другие параметры передаются с помощью оператора <=.

Блок из 16 кнопок инициализируется без видимого цикла (он теперь спрятан в объекте раскладки). Цвет фона всех кнопок задан строкой block["background"] <= clrCyan. Далее в тот же объект раскладки передаются пока не известные нам вспомогательные объекты (SimpleSequenceGenerator).

При формировании интерфейса пользователя часто возникает необходимость сгенерировать несколько элементов одного типа и заполнить их в пакетном режиме некоторыми известными данными. Для этой цели удобно применить так называемый генератор.

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

  template<typename T>
  class Generator
  {
    public:
      virtual T operator++() = 0;
  };

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

  template<typename T>
  class SimpleSequenceGenerator: public Generator<T>
  {
    protected:
      T current;
      int max;
      int count;
      
    public:
      SimpleSequenceGenerator(const T start = NULL, const int _max = 0): current(start), max(_max), count(0) {}
      
      virtual T operator++() override
      {
        ulong ul = (ulong)current;
        ul++;
        count++;
        if(count > max) return NULL;
        current = (T)ul;
        return current;
      }
  };

Генераторы добавлены для удобства пакетных операций (файл Generators.mqh), а в классе раскладки имеется переопределение оператора <= для генераторов. За счет этого мы можем в одну строку заполнить 16 кнопок идентификаторами и заголовками.

В следующих строках метода CreateLayout создается кнопка m_button_new.

        _layout<CButton> start(m_button_new, "New");
        start["background;font"] <= clrYellow <= "Arial Black";

Строка "New" является и идентификатором, и заголовком. Если бы потребовалось назначить другой заголовок, это можно было бы сделать так: start <= "Caption". В принципе, задавать идентификатор тоже не обязательно (если он нам не нужен), система сгенерирует его сама.

Во второй строке устанавливается контекст, содержащий сразу две подсказки — background и font. Первая нужна для правильной интерпретации цвета clrYellow. Поскольку кнопка является наследником CWndObj, для неё "анонимный" цвет означает цвет текста. Вторая подсказка гарантирует, что строка "Arial Black" изменит используемый шрифт (без контекста, строка изменила бы заголовок). Желающие могут писать подробно:

        start["background"] <= clrYellow;
        start["font"] <= "Arial Black";

Разумеется, для кнопки остались доступны её методы, то есть можно написать как прежде:

        m_button_new.ColorBackground(clrYellow);
        m_button_new.Font("Arial Black");

Но для этого нужно иметь объект кнопки, а так будет не всегда — далее мы прийдем к схеме, когда система раскладки станет отвечать за всё, включая конструирование и хранение элементов.

Для настройки метки используются такие строки:

        _layout<CEdit> label(m_label);
        label <= "click new" <= true <= ALIGN_CENTER;

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

Далее следуют строки настройки объекта m_main класса CGridTKEx:

      m_main.Init(5, 4, 2, 2);
      m_main.SetGridConstraints(m_button_new, 4, 0, 1, 2);
      m_main.SetGridConstraints(m_label, 4, 2, 1, 2);
      m_main.Pack();

CGridTKEx — это слегка усовершенствованный CGridTk (знакомый по предыдущим статьям). В CGridTkEx реализован способ задавать ограничения для дочерних "контролов" с помощью нового метода SetGridConstraints. В GridTk это возможно сделать только одновременно с добавлением элемента, внутри метода Grid. Это плохо само по себе, так как смешивает в одном методе две разных по сути операции — установление отношений между объектами и настройка свойств. Но кроме того получается, что добавлять элементы в сетку нужно не с помощью Add, а только этим методом (поскольку это единственный способ задать ограничения, без которых GridTk не может работать). Это противоречит общему подходу библиотеки, где для этих целей всегда используется Add. И на это, в свою очередь, завязана работа системы автоматической разметки. В классе CGridTkEx мы разъединили 2 операции — для каждой теперь есть свой метод.

Напомним, что для главных контейнеров (включающих все окно) классов CBox/CGridTk важно вызвать метод Pack — именно он производит раскладку, вызывая при необходимости Pack у вложенных контейнеров.

Если сравнить исходные коды SlidingPuzzle3.mqh и SlidingPuzzle4.mqh, легко заметить, что исходный код стал заметно компактнее. Из класса "ушли" методы Create, CreateMain, CreateButton, CreateButtonNew, CreateLabel. Вместо них теперь работает единственный CreateLayout.

Запустив программу, можем убедиться, что элементы создаются и работают, как задумано.

Правда мы по-прежнему имеем в классе список с декларацией всех "контролов" и контейнеров. А по мере усложнения программ и увеличения количества компонентов станет не очень удобно дублировать их описание в классе окна и в раскладке. Нельзя ли всё делать с помощью раскладки? Не трудно догадаться, что можно. Но об этом — уже во второй части публикации.

Заключение

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