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

Stanislav Korotky | 3 апреля, 2020

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

Кастомизация библиотеки стандартных элементов управления

В ходе проработки оконного интерфейса прошлых статей по OLAP, которые также основывались на стандартной библиотеке и контейнерах CBox, нам потребовалось вносить некоторые правки в компоненты стандартной библиотеки. Как выяснилось, для интеграции предлагаемой системы раскладки необходимо еще больше откорректировать библиотеку Controls — частично в плане расширения возможностей, частично в плане исправления ошибок. В связи с этим было принято решение сделать полную копию (версионное ответвление) всех классов, разместить их в папке ControlsPlus и далее работать только с ними.

Вот основные изменения.

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

В целях упрощения отладки проектов с GUI элементами, в класс CWnd было добавлено строковое поле _rtti, и в конструкторе каждого производного класса оно заполняется именем конкретного класса с помощью макроса RTTI.

  #define RTTI _rtti = StringFormat("%s %d", typename(this), &this);

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

Информация о полях и выравнивании элемента в классе CWnd сделана доступной с помощью двух новых перегруженных методов. Кроме того, стало возможным раздельно менять выравнивание и поля.

    ENUM_WND_ALIGN_FLAGS Alignment(void) const
    {
      return (ENUM_WND_ALIGN_FLAGS)m_align_flags;
    }
    CRect Margins(void) const
    {
      CRectCreator rect(m_align_left, m_align_top, m_align_right, m_align_bottom);
      return rect;
    }
    void Alignment(const int flags)
    {
      m_align_flags = flags;
    }
    void Margins(const int left, const int top, const int right, const int bottom)
    {
      m_align_left = left;
      m_align_top = top;
      m_align_right = right;
      m_align_bottom = bottom;
    }

Метод CWnd::Align переписан в соответствии с ожидаемым поведением всех режимов выравнивания. Стандартная реализация не обеспечивает сдвиг к границе заданного поля, если задано растяжение (этому подвержены оба измерения).

В класс CWndContainer добавлен метод DeleteAll для удаления всех дочерних элементов при удалении контейнера. Он вызывается из Delete(CWnd *control) в случае, если указатель на переданный "контрол" содержит объект-контейнер.

В класс CWndClient в разные места добавлены строки, регулирующие видимость полос прокрутки, которая может меняться из-за изменения размеров.

Класс CAppDialog теперь учитывает instance_id окна при назначении идентификаторов элементам интерфейса. Без этой правки элементы управления в разных окнах с одинаковыми именами конфликтовали (влияли друг на друга).

В группах "контролов" — CRadioGroup, CCheckGroup, CListView — метод Redraw сделан виртуальным, чтобы "резиновые" классы-наследники могли правильно реагировать на изменение размера. Кроме того, слегка откорректирован пересчет ширины их дочерних элементов.

В классы CDatePicker, CCheckBox и CRadioButton добавлен виртуальный метод OnResize для тех же целей. В классе CDatePicker исправлена ошибка с низким приоритетом всплывающего календаря (нажатия мыши проходили сквозь него).

Метод CEdit::OnClick не "съедает" нажатие мыши.

Кроме того, ранее уже было разработано несколько классов "контролов", поддерживающих изменение размера, и в рамках данного проекта число "резиновых" классов было расширено. Их файлы расположены в папке Layouts.

Напомню, что некоторые "контролы", такие как кнопка или поле ввода, поддерживают растяжение изначально.

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

Иерархия классов элементов управления

Иерархия классов элементов управления


Порождение элементов и их кэширование

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

  template<typename C>
  class LayoutCache
  {
    protected:
      C *cache[];   // autocreated controls and boxes
      
    public:
      virtual void save(C *control)
      {
        const int n = ArraySize(cache);
        ArrayResize(cache, n + 1);
        cache[n] = control;
      }
      
      virtual C *get(const long m)
      {
        if(m < 0 || m >= ArraySize(cache)) return NULL;
        return cache[(int)m];
      }
      
      virtual C *get(const string name) = 0;
      virtual bool find(C *control);
      virtual int indexOf(C *control);
      virtual C *findParent(C *control) = 0;
      virtual bool revoke(C *control) = 0;
      virtual int cacheSize();
  };

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

Добавим кэш как статический член в класс LayoutBase.

  template<typename P,typename C>
  class LayoutBase: public LayoutData
  {
    protected:
      ...
      static LayoutCache<C> *cacher;
      
    public:
      static void setCache(LayoutCache<C> *c)
      {
        cacher = c;
      }

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

      ~LayoutBase()
      {
        ...
        if(stack.size() == 0)
        {
          cacher = NULL;
        }
      }

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

Для заполнения кэша добавим новую разновидность метода init в LayoutBase — на этот раз без указателя или ссылки на "сторонний" элемент GUI в параметрах.

      // nonbound layout, control T is implicitly stored in internal cache
      template<typename T>
      T *init(const string name, const int m = 1, const int x1 = 0, const int y1 = 0, const int x2 = 0, const int y2 = 0)
      {
        T *temp = NULL;
        for(int i = 0; i < m; i++)
        {
          temp = new T();
          if(save(temp))
          {
            init(temp, name + (m > 1 ? (string)(i + 1) : ""), x1, y1, x2, y2);
          }
          else return NULL;
        }
        return temp;
      }
      
      virtual bool save(C *control)
      {
        if(cacher != NULL)
        {
          cacher.save(control);
          return true;
        }
        return false;
      }

За счет шаблона мы имеем возможность написать new T и порождать объекты в процессе раскладки (по умолчанию, 1 объект за раз, но опционально можем и несколько).

Для элементов стандартной библиотеки написана конкретная реализация кэша — StdLayoutCache (здесь приведен с сокращениями, полный код — в приложении).

  // CWnd implementation specific!
  class StdLayoutCache: public LayoutCache<CWnd>
  {
    public:
      ...
      virtual CWnd *get(const long m) override
      {
        if(m < 0)
        {
          for(int i = 0; i < ArraySize(cache); i++)
          {
            if(cache[i].Id() == -m) return cache[i];
            CWndContainer *container = dynamic_cast<CWndContainer *>(cache[i]);
            if(container != NULL)
            {
              for(int j = 0; j < container.ControlsTotal(); j++)
              {
                if(container.Control(j).Id() == -m) return container.Control(j);
              }
            }
          }
          return NULL;
        }
        else if(m >= ArraySize(cache)) return NULL;
        return cache[(int)m];
      }
      
      virtual CWnd *findParent(CWnd *control) override
      {
        for(int i = 0; i < ArraySize(cache); i++)
        {
          CWndContainer *container = dynamic_cast<CWndContainer *>(cache[i]);
          if(container != NULL)
          {
            for(int j = 0; j < container.ControlsTotal(); j++)
            {
              if(container.Control(j) == control)
              {
                return container;
              }
            }
          }
        }
        return NULL;
      }
      ...
  };

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

В прикладном классе окна мы можем использовать непосредственно этот класс StdLayoutCache или написать производный от него.

Как кэширование позволяет сократить описание класса окна, мы увидим в следующем примере. Но прежде чем к нему переходить, рассмотрим некоторые дополнительные возможности, которые открывает кэш. Мы также применим их в примерах.

Стилизатор

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

Для поддержки централизованных стилей в классе StdLayoutCache предусмотрен метод-заглушка:

    virtual LayoutStyleable<C> *getStyler() const
    {
      return NULL;
    }

Если Вы не хотите использовать стили, то ничего дополнительно кодировать не надо. Однако если Вы понимаете выгоды, которые дает централизация управления стилями, то можете реализовать класс-наследник LayoutStyleable. Интерфейс очень простой.

  enum STYLER_PHASE
  {
    STYLE_PHASE_BEFORE_INIT,
    STYLE_PHASE_AFTER_INIT
  };
  
  template<typename C>
  class LayoutStyleable
  {
    public:
      virtual void apply(C *control, const STYLER_PHASE phase) {};
  };

Метод apply будет вызываться для каждого "контрола" дважды: на стадии инициализации (STYLE_PHASE_BEFORE_INIT) и на стадии регистрации в контейнере (STYLE_PHASE_AFTER_INIT). Так, в методах LayoutBase::init добавляется вызов на первой стадии:

      if(cacher != NULL)
      {
        LayoutStyleable<C> *styler = cacher.getStyler();
        if(styler != NULL)
        {
          styler.apply(object, STYLE_PHASE_BEFORE_INIT);
        }
      }

а в деструктор — похожие строки, но с STYLE_PHASE_AFTER_INIT для второй стадии.

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

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

Обработка событий

Второй функцией, которую логично поручить кэшу, является обработка событий. Для них в классе LayoutCache добавлен метод-заглушка (C — параметр шаблона класса):

    virtual bool onEvent(const int event, C *control)
    {
      return false;
    }

Опять же, его можно реализовать в производном классе, но не обязательно. Коды событий определяются конкретной библиотекой.

Чтобы этот метод заработал, нам необходимы макроопределения перехвата событий, аналогичные тем, что имеются в стандартной библиотеке и пишутся в карте, например, так:

  EVENT_MAP_BEGIN(Dialog)
    ON_EVENT(ON_CLICK, m_button1, OnClickButton1)
    ...
  EVENT_MAP_END(AppDialog)

Новые макросы будут перенаправлять события в объект кэша. Один из них:

  #define ON_EVENT_LAYOUT_ARRAY(event, cache)  if(id == (event + CHARTEVENT_CUSTOM) && cache.onEvent(event, cache.get(-lparam))) { return true; }

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

Текущий размер кэша — это индекс, по которому только что был сохранен новый элемент. Мы можем сохранить индекс требуемых "контролов" в процессе раскладки.

          _layout<CButton> button1("Button");
          button1index = cache.cacheSize() - 1;

Здесь button1index — целочисленная переменная в классе окна. Её следует использовать в другом макросе, определенном для обработки элементов по индексу кэша:

  #define ON_EVENT_LAYOUT_INDEX(event, cache, controlIndex, handler)  if(id == (event + CHARTEVENT_CUSTOM) && lparam == cache.get(controlIndex).Id()) { handler(); return(true); }

Дополнительно мы можем направлять события не в кэш, а непосредственно в сами элементы. Для этого элемент должен реализовать в себе интерфейс Notifiable, шаблонизированный требуемым классом "контрола".

  template<typename C>
  class Notifiable: public C
  {
    public:
      virtual bool onEvent(const int event, void *parent) = 0;
  };

В параметре parent можно передавать любой объект, в том числе и окно диалога. На основе Notifiable, например, легко создать кнопку — наследницу CButton.

  class NotifiableButton: public Notifiable<CButton>
  {
    public:
      virtual bool onEvent(const int event, void *anything) override
      {
        this.StateFlagsReset(7);
        return true;
      }
  };

Имеется 2 макроса для работы с "уведомляемыми" элементами. Их отличие только в количестве параметров: ON_EVENT_LAYOUT_CTRL_ANY позволяет передать последним параметров произвольный объект, а ON_EVENT_LAYOUT_CTRL_DLG этого параметра не имеет, т.к. в качестве объекта всегда отсылает this диалога.

  #define ON_EVENT_LAYOUT_CTRL_ANY(event, cache, type, anything)  if(id == (event + CHARTEVENT_CUSTOM)) {type *ptr = dynamic_cast<type *>(cache.get(-lparam)); if(ptr != NULL && ptr.onEvent(event, anything)) { return true; }}
  #define ON_EVENT_LAYOUT_CTRL_DLG(event, cache, type)  if(id == (event + CHARTEVENT_CUSTOM)) {type *ptr = dynamic_cast<type *>(cache.get(-lparam)); if(ptr != NULL && ptr.onEvent(event, &this)) { return true; }}

Мы рассмотрим разные варианты обработки событий в контексте второго примера.

Пример 2. Диалог с элементами управления Controls

Демо-проект содержит класс CControlsDialog с основными типами "контролов" Стандартной библиотеки. По аналогии с первым примером удалим все методы по их созданию и заменим на единственный CreateLayout. Кстати говоря, методов этих было в старом проекте аж 17, и они вызывались друг из друга с помощью сложносочиненных условных операторов.

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

  class MyStdLayoutCache: public StdLayoutCache
  {
    protected:
      MyLayoutStyleable styler;
      CControlsDialog *parent;
      
    public:
      MyStdLayoutCache(CControlsDialog *owner): parent(owner) {}
      
      virtual StdLayoutStyleable *getStyler() const override
      {
        return (StdLayoutStyleable *)&styler;
      }
      
      virtual bool onEvent(const int event, CWnd *control) override
      {
        if(control != NULL)
        {
          parent.SetCallbackText(__FUNCTION__ + " " + control.Name());
          return true;
        }
        return false;
      }
  };

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

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

  class MyLayoutStyleable: public StdLayoutStyleable
  {
    public:
      virtual void apply(CWnd *control, const STYLER_PHASE phase) override
      {
        CButton *button = dynamic_cast<CButton *>(control);
        if(button != NULL)
        {
          if(phase == STYLE_PHASE_BEFORE_INIT)
          {
            button.Font("Arial Black");
          }
        }
        else
        {
          CEdit *edit = dynamic_cast<CEdit *>(control);
          if(edit != NULL && edit.ReadOnly())
          {
            if(phase == STYLE_PHASE_AFTER_INIT)
            {
              edit.ColorBackground(clrLightGray);
            }
          }
        }
        
        if(phase == STYLE_PHASE_BEFORE_INIT)
        {
          control.Margins(DEFAULT_MARGIN);
        }
      }
  };

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

  class CControlsDialog: public AppDialogResizable
  {
    private:
      ...
      MyStdLayoutCache *cache;
    public:
      CControlsDialog(void)
      {
        cache = new MyStdLayoutCache(&this);
      }

Теперь разберем по порядку метод CreateLayout. Из-за чтения подробных описаний может показаться, что метод очень длинный и сложный, но на самом деле это не так. Если убрать познавательные комментарии (которых не будет в реальном проекте), метод умещается на один экран и не содержит сложной логики.

В самом начале кэш активируется вызовом setCache. Затем в первом блоке описывается главный контейнер CControlsDialog. Его не будет в кэше, потому что мы передаем ссылку на уже созданный this.

  bool CControlsDialog::CreateLayout(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2)
  {
    StdLayoutBase::setCache(cache); // assign the cache object to store implicit objects
    
    {
      _layout<CControlsDialog> dialog(this, name, x1, y1, x2, y2);

Далее создается неявный экземпляр вложенного контейнера класса CBox для клиентской области окна. Его ориентация вертикальная, так что вложенные контейнеры будут заполнять пространство сверху вниз. Мы сохраняем ссылку на объект в переменной m_main, потому что нам потребуется вызывать её метод Pack после изменения размеров окна. Если ваш диалог не резиновый, то это делать не надо. Наконец для клиентской области устанавливаются нулевые поля и выравнивание по всем краям, чтобы панель заняла всё окно даже после изменения размера.

      {
        // example of implicit object in the cache
        _layout<CBox> clientArea("main", ClientAreaWidth(), ClientAreaHeight(), LAYOUT_STYLE_VERTICAL);
        m_main = clientArea.get(); // we can get the pointer to the object from cache (if required)
        clientArea <= WND_ALIGN_CLIENT <= 0.0; // double type is important

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

        {
          // another implicit container (we need no access it directly)
          _layout<CBox> editRow("editrow", ClientAreaWidth(), EDIT_HEIGHT * 1.5, (ENUM_WND_ALIGN_FLAGS)(WND_ALIGN_TOP|WND_ALIGN_WIDTH));

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

          {
            // for editboxes default boolean property is ReadOnly
            _layout<CEdit> edit(m_edit, "Edit", ClientAreaWidth(), EDIT_HEIGHT, true);
          }
        }

К этому моменту у нас проинициализировано 3 элемента. После закрытой скобки объект раскладки edit будет уничтожен, и в ходе выполнения его деструктора m_edit будет добавлен в контейнер "editrow". Но тут же следом идет еще одна закрывающая скобка. Она уничтожает контекст, в котором "жил" объект раскладки editRow, и потому этот контейнер в свою очередь добавляется в оставшийся на стеке контейнер клиентской области. Таким образом сформирован первый ряд вертикальной раскладки в m_main.

Далее у нас идет ряд с тремя кнопками. Сначала создается контейнер под него.

        {
          _layout<CBox> buttonRow("buttonrow", ClientAreaWidth(), BUTTON_HEIGHT * 1.5);
          buttonRow["align"] <= (WND_ALIGN_CONTENT|WND_ALIGN_WIDTH);

Здесь стоит обратить внимание на нестандартный способ выравнивания WND_ALIGN_CONTENT. Означает он следующее.

В класс CBox добавлен алгоритм масштабирования вложенных элементов под размер контейнера. Он выполняется в методе AdjustFlexControls и вступает в силу, только если во флагах выравнивания контейнера указано специальное значение WND_ALIGN_CONTENT. Оно не является частью стандартного перечисления ENUM_WND_ALIGN_FLAGS. Контейнер анализирует "контролы" на предмет того, какие из них имеют фиксированный размер, а какие — нет. "Контролы" с фиксированным размером это те, для которых не указано выравнивание по сторонам контейнера (в конкретном измерении). Для всех таких "контролов" контейнер подсчитывает сумму их размеров, вычитает её из общего размера контейнера и остаток пропорционально делит между всеми оставшимися "контролами". Например, если в контейнере два "контрола", и ни один из них не имеет привязки, то они будут пополам делить между собой всю область контейнера.

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

В случае нашего ряда с тремя кнопками, они все будут пропорционально менять свою длину при изменении ширина окна. Первая кнопка класса CButton создается неявно и хранится в кэше.

          { // 1
            _layout<CButton> button1("Button1");
            button1index = cache.cacheSize() - 1;
            button1["width"] <= BUTTON_WIDTH;
            button1["height"] <= BUTTON_HEIGHT;
          } // 1

Вторая кнопка имеет класс NotifiableButton (он уже описан выше). Кнопка будет обрабатывать сообщения сама.

          { // 2
            _layout<NotifiableButton> button2("Button2", BUTTON_WIDTH, BUTTON_HEIGHT);
          } // 2

Третья кнопка создается на основе явно определенной переменной окна m_button3 и обладает свойством "залипания".

          { // 3
            _layout<CButton> button3(m_button3, "Button3", BUTTON_WIDTH, BUTTON_HEIGHT, "Locked");
            button3 <= true; // for buttons default boolean property is Locking
          } // 3
        }

Обратите внимание, что все кнопки обрамлены в собственные блоки фигурных скобок. Из-за этого они добавляются в ряд в том порядке, в котором встречаются закрывающие скобки, помеченные 1, 2, 3, то есть в естественном. Мы могли бы не делать эти "персональные" блоки для каждой кнопки и ограничиться общим блоком контейнера. Но тогда кнопки добавились бы в обратном порядке, потому что деструкторы объектов всегда вызываются в обратном порядке от их создания. "Починить" ситуацию можно было бы, инверсировав порядок описания кнопок в раскладке.

В третьем ряду расположен контейнер с элементами управления "спиннер" и календарь. Контейнер создается "анонимно" и хранится в кэше.

        {
          _layout<CBox> spinDateRow("spindaterow", ClientAreaWidth(), BUTTON_HEIGHT * 1.5);
          spinDateRow["align"] <= (WND_ALIGN_CONTENT|WND_ALIGN_WIDTH);
          
          {
            _layout<SpinEditResizable> spin(m_spin_edit, "SpinEdit", GROUP_WIDTH, EDIT_HEIGHT);
            spin["min"] <= 10;
            spin["max"] <= 1000;
            spin["value"] <= 100; // can set value only after limits (this is how SpinEdits work)
          }
          
          {
            _layout<CDatePicker> date(m_date, "Date", GROUP_WIDTH, EDIT_HEIGHT, TimeCurrent());
          }
        }

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

        {
          _layout<CBox> listRow("listsrow", ClientAreaWidth(), LIST_HEIGHT);
          listRow["top"] <= (int)(EDIT_HEIGHT * 1.5 * 3);
          listRow["align"] <= (WND_ALIGN_CONTENT|WND_ALIGN_CLIENT);
          (listRow <= clrMagenta)["border"] <= clrBlue;
          
          createSubList(&m_lists_column1, LIST_OF_OPTIONS);
          createSubList(&m_lists_column2, LIST_LISTVIEW);
          // or vice versa (changed order gives swapped left/right side location)
          // createSubList(&m_lists_column1, LIST_LISTVIEW);
          // createSubList(&m_lists_column2, LIST_OF_OPTIONS);
        }

Здесь следует особо отметить, что две колонки m_lists_column1 и m_lists_column2 заполняются не в самом методе CreateLayout, а с помощью вспомогательного метода createSubList. Вызов функции с точки зрения раскладки ничем не отличается от входа в очередной блок со скобками. Это означает, что раскладка не обязана состоять из длинного статичного списка, а может включать фрагменты, изменяемые по условию. Или можно один и тот же фрагмент включить в разные диалоги.

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

      }
    }

После закрытия всех скобок все элементы GUI проинициализированы и подключены друг к другу. Вызываем метод Pack (напрямую или через SelfAdjustment, где он также вызывается в ответ на запрос "резинового" диалога).

    // m_main.Pack();
    SelfAdjustment();
    return true;
  }

Мы не будем подробно рассматривать метод createSubList. Внутри реализованы возможности сгенерировать набор 3 "контролов" (кобмобокс, группа опций и группа радиокнопок) или список (ListView), причем все в "резиновом" исполнении. Интерес представляет то, что заполнение "контролов" осуществляется с помощью еще одного класса генераторов ItemGenerator.

  template<typename T>
  class ItemGenerator
  {
    public:
      virtual bool addItemTo(T *object) = 0;
  };

Единственный метод этого класса вызывается из раскладки для "контрола" object до тех пор, пока метод не вернет false (признак конца данных).

По умолчанию предоставлено несколько простых генераторов для стандартной библиотеки (они используют метод "контролов" AddItem): StdItemGenerator, StdGroupItemGenerator, SymbolsItemGenerator, ArrayItemGenerator. В частности, SymbolsItemGenerator позволяет заполнить "контрол" символами из "Обзора рынка".

  template<typename T>
  class SymbolsItemGenerator: public ItemGenerator<T>
  {
    protected:
      long index;
      
    public:
      SymbolsItemGenerator(): index(0) {}
      
      virtual bool addItemTo(T *object) override
      {
        object.AddItem(SymbolName((int)index, true), index);
        index++;
        return index < SymbolsTotal(true);
      }
  };

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

        _layout<ListViewResizable> list(m_list_view, "ListView", GROUP_WIDTH, LIST_HEIGHT);
        list <= WND_ALIGN_CLIENT < new SymbolsItemGenerator<ListViewResizable>();

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

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

  EVENT_MAP_BEGIN(CControlsDialog)
    ...
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CLICK, cache, NotifiableButton)
    ON_EVENT_LAYOUT_INDEX(ON_CLICK, cache, button1index, OnClickButton1)
    ON_EVENT_LAYOUT_ARRAY(ON_CLICK, cache)
  EVENT_MAP_END(AppDialogResizable)

Макрос ON_EVENT_LAYOUT_CTRL_DLG подключает уведомления о нажатиях мышью для любых кнопок класса NotifiableButton (в нашем случае, она одна). Макрос ON_EVENT_LAYOUT_INDEX отправляет то же событие в кнопку с указанным индексом в кэше. Но этот макрос можно было и не писать, потому что последней строкой макрос ON_EVENT_LAYOUT_ARRAY перешлет нажатие мыши любому элементу в кэше при совпадении его идентификатора lparam.

В принципе можно было все элементы перенести в кэш и обрабатывать их события новым способом, но старый тоже работает и их можно комбинировать.

На следующем анимированном изображении демонстрируется реакция на события.

Диалог с элементами управления, сформированный с помощью языка разметки MQL

Диалог с элементами управления, сформированный с помощью языка разметки MQL

Обратите внимание, что способ трансляции события можно косвенно определить по сигнатуре функции, выводимой в информационном поле. Также видно, что события поступают не только в "контролы", но и в контейнеры. Красные рамки у контейнеров выведены для отладки, их можно отключить с помощью макроса LAYOUT_BOX_DEBUG.

Пример 3. Динамические раскладки DynamicForm

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

Как и в предыдущем примере, кэш будет поддерживать стилизацию элементов. Единственная настройка стиля — одинаковые заметные поля, которые позволят видеть вложенность контейнеров друг в друга и выделять их по желанию с помощью мыши.

Внутри метода CreateLayout описывается следующая простая структура интерфейса. Главный контейнер, как обычно, занимает всю клиентскую область окна. В верхней части расположен блок с двумя кнопками: Inject и Export. Всё место под ним занимает контейнер, разделенный на левую и правую колонки. Левая колонка, отмеченная серым цветом, изначально пуста. В правой колонке находится группа радио-кнопок, которая позволяет выбрать тип элемента управления.

      {
        // example of implicit object in the cache
        _layout<CBoxV> clientArea("main", ClientAreaWidth(), ClientAreaHeight());
        m_main = clientArea.get();
        clientArea <= WND_ALIGN_CLIENT <= PackedRect(10, 10, 10, 10);
        clientArea["background"] <= clrYellow <= VERTICAL_ALIGN_TOP;
        
        {
          _layout<CBoxH> buttonRow("buttonrow", ClientAreaWidth(), BUTTON_HEIGHT * 5);
          buttonRow <= 5.0 <= (ENUM_WND_ALIGN_FLAGS)(WND_ALIGN_TOP|WND_ALIGN_WIDTH);
          buttonRow["background"] <= clrCyan;
          
          {
            // these 2 buttons will be rendered in reverse order (destruction order)
            // NB: automatic variable m_button3
            _layout<CButton> button3(m_button3, "Export", BUTTON_WIDTH, BUTTON_HEIGHT);
            _layout<NotifiableButton> button2("Inject", BUTTON_WIDTH, BUTTON_HEIGHT);
          }
        }
        
        {
          _layout<CBoxH> buttonRow("buttonrow2", ClientAreaWidth(), ClientAreaHeight(),
            (ENUM_WND_ALIGN_FLAGS)(WND_ALIGN_CONTENT|WND_ALIGN_CLIENT));
          buttonRow["top"] <= BUTTON_HEIGHT * 5;
          
          {
            {
              _layout<CBoxV> column("column1", GROUP_WIDTH, 100, WND_ALIGN_HEIGHT);
              column <= clrGray;
              {
                // dynamically created controls will be injected here
              }
            }
            
            {
              _layout<CBoxH> column("column2", GROUP_WIDTH, 100, WND_ALIGN_HEIGHT);
            
              _layout<RadioGroupResizable> selector("selector", GROUP_WIDTH, CHECK_HEIGHT);
              selector <= WND_ALIGN_HEIGHT;
              string types[3] = {"Button", "CheckBox", "Edit"};
              ArrayItemGenerator<RadioGroupResizable,string> ctrls(types);
              selector <= ctrls;
            }
          }
        }
      }

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

  class NotifiableButton: public Notifiable<CButton>
  {
      static int count;
      
      StdLayoutBase *getPtr(const int value)
      {
        switch(value)
        {
          case 0:
            return new _layout<CButton>("More" + (string)count++, BUTTON_WIDTH, BUTTON_HEIGHT);
          case 1:
            return new _layout<CCheckBox>("More" + (string)count++, BUTTON_WIDTH, BUTTON_HEIGHT);
          case 2:
            return new _layout<CEdit>("More" + (string)count++, BUTTON_WIDTH, BUTTON_HEIGHT);
        }
        return NULL;
      }
      
    public:
      virtual bool onEvent(const int event, void *anything) override
      {
        DynamicForm *parent = dynamic_cast<DynamicForm *>(anything);
        MyStdLayoutCache *cache = parent.getCache();
        StdLayoutBase::setCache(cache);
        CBox *box = cache.get("column1");
        if(box != NULL)
        {
          // put target box to the stack by retrieving it from the cache
          _layout<CBox> injectionPanel(box, box.Name());
          
          {
            CRadioGroup *selector = cache.get("selector");
            if(selector != NULL)
            {
              const int value = (int)selector.Value();
              if(value != -1)
              {
                AutoPtr<StdLayoutBase> base(getPtr(value));
                (~base).get().Id(rand() + (rand() << 32));
              }
            }
          }
          box.Pack();
        }
        
        return true;
      }
  };

Контейнер, куда должны вставляться новые элементы, сперва ищется в кэше по имени "column1". Этот контейнер идет первым параметром при создании объекта injectionPanel. Тот факт, что передаваемый элемент уже находится в кэше, особым образом учтен в алгоритме раскладки — повторно он в кэш не добавляется, но как обычно кладется в стек контейнеров. Таким образом обеспечивается возможность добавлять элементы в "старые" контейнеры.

На основе выбора пользователя объект необходимого типа создается с помощью оператора new во вспомогательном методе getPtr. Для того, чтобы добавленные "контролы" нормально работали, для них случайным образом генерируются уникальные идентификаторы. Удаление указателя при выходе из блока кода обеспечивает специальный класс AutoPtr.

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

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

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

  class MyStdLayoutCache: public StdLayoutCache
  {
    protected:
      DynamicForm *parent;
      CWnd *selected;
      
      bool highlight(CWnd *control, const color clr)
      {
        CWndObj *obj = dynamic_cast<CWndObj *>(control);
        if(obj != NULL)
        {
          obj.ColorBorder(clr);
          return true;
        }
        else
        {
          CWndClient *client = dynamic_cast<CWndClient *>(control);
          if(client != NULL)
          {
            client.ColorBorder(clr);
            return true;
          }
        }
        return false;
      }
      
    public:
      MyStdLayoutCache(DynamicForm *owner): parent(owner) {}
      
      virtual bool onEvent(const int event, CWnd *control) override
      {
        if(control != NULL)
        {
          highlight(selected, CONTROLS_BUTTON_COLOR_BORDER);
          
          CWnd *element = control;
          if(!find(element)) // this is an auxiliary object, not a compound control
          {
            element = findParent(control); // get actual GUI element
          }
          
          if(element == NULL)
          {
            Print("Can't find GUI element for ", control._rtti + " / " + control.Name());
            return true;
          }
          
          if(selected == control)
          {
            if(MessageBox("Delete " + element._rtti + " / " + element.Name() + "?", "Confirm", MB_OKCANCEL) == IDOK)
            {
              CWndContainer *container;
              container = dynamic_cast<CWndContainer *>(findParent(element));
              if(container)
              {
                revoke(element); // deep remove of all references (with subtree) from cache
                container.Delete(element); // delete all subtree of wnd-objects
                
                CBox *box = dynamic_cast<CBox *>(container);
                if(box) box.Pack();
              }
              selected = NULL;
              return true;
            }
          }
          selected = control;
          
          const bool b = highlight(selected, clrRed);
          Print(control.Name(), " -> ", element._rtti, " / ", element.Name(), " / ", b);
          
          return true;
        }
        return false;
      }
  };

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

Редактируемая форма: добавление и удаление элементов

Редактируемая форма: добавление и удаление элементов

Происходит так потому, что это единственный элемент, намеренно описанный как автоматическая, а не динамическая переменная (в классе формы есть экземпляр CButton m_button3).

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

Напоследок обратимся ко второй кнопке диалога — Export. Как можно догадаться по её названию, она предназначена для сохранения текущего состояния диалога в виде текстового файла в рассмотренном синтаксисе MQL-раскладок. Конечно, форма позволяет настроить свой вид лишь в ограниченном объеме, в демонстрационных целях, но сама возможность выгрузить внешний вид в готовый MQL-код, который затем легко скопировать в программу и получить тот же интерфейс, потенциально представляет собой довольно ценную технологию. Разумеется, переносится только интерфейс, а код обработки событий или общие настройки стилизатора необходимо подключить самостоятельно.

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

Заключение

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

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