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

7 апреля 2020, 11:37
Stanislav Korotky
5
924

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

Постановка задачи

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

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

Здесь отсутствуют многие другие свойства, например, название и размер шрифта, специфические свойства различных типов "контролов" (в частности, свойство "залипания" кнопок). Это сделано намеренно для упрощения проекта, основная задача которого — проверка концепции (proof of concept, POC). При необходимости в редактор можно будет добавить поддержку дополнительных свойств потом.

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

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

Следует обратить внимание, что название "библиотека стандартных компонентов" не совсем соответствует действительности, так как в контексте предыдущих статей нам уже пришлось существенно её модифицировать и вывести в параллельную версионную ветку в папке ControlsPlus. В рамках данной статьи мы будем продолжать её использовать и модифицировать.

Перечислим типы элементов, которые будет поддерживать редактор.

  • контейнеры CBox с горизонтальной (CBoxH) и вертикальной ориентацией (CBoxV),
  • кнопка CButton,
  • поле ввода CEdit,
  • метка CLabel,
  • поле ввода с перебором значений SpinEditResizable,
  • календарь CDatePicker,
  • выпадающий список ComboBoxResizable,
  • список ListViewResizable,
  • группа независимых переключателей CheckGroupResizable,
  • группа зависимых переключателей RadioGroupResizable.

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

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

Набросок интерфейса программы-дизайнера GUI MQL

Набросок интерфейса программы-дизайнера GUI MQL

С точки зрения MQL в программе будет 2 основных классах — InspectorDialog и DesignerForm, описанных в одноименных заголовочных файлах.

  #include "InspectorDialog.mqh"
  #include "DesignerForm.mqh"
  
  InspectorDialog inspector;
  DesignerForm designer;
  
  int OnInit()
  {
      if(!inspector.CreateLayout(0, "Inspector", 0, 20, 20, 200, 400)) return (INIT_FAILED);
      if(!inspector.Run()) return (INIT_FAILED);
      if(!designer.CreateLayout(0, "Designer", 0, 300, 50, 500, 300)) return (INIT_FAILED);
      if(!designer.Run()) return (INIT_FAILED);
      return (INIT_SUCCEEDED);
  }

Оба окна являются наследниками AppDialogResizable (далее CAppDialog), формируемыми по технологии MQL-разметки. Поэтому мы видим вызов CreateLayout вместо Create.

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

Набор свойств PropertySet

Каждое свойство из перечисленных представляется значением конкретного типа. Например, имя элемента — это строка, а ширина и высота — целые числа. Весь набор значений полностью описывает объект, который должен появиться в дизайнере. Набор имеет смысл хранить в одном месте, для чего был заведен специальный класс PropertySet. Но какие в нем должны быть переменные-члены?

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

  template<typename V>
  class Value
  {
    protected:
      V value;
      
    public:
      V operator~(void) const // getter
      {
        return value;
      }
      
      void operator=(V v)     // setter
      {
        value = v;
      }
  };

Слово "примерно" вставлено неспроста. На самом деле, в класс добавится еще кое-какой функционал, о чем речь пойдет чуть ниже.

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

С учетом этого класса набор свойств нового интерфейсного объекта можно описать примерно так.

  class PropertySet
  {
    public:
      Value<string> name;
      Value<int> type;
      Value<int> width;
      Value<int> height;
      Value<int> style; // VERTICAL_ALIGN / HORIZONTAL_ALIGN / ENUM_ALIGN_MODE
      Value<string> text;
      Value<color> clr;
      Value<int> align; // ENUM_WND_ALIGN_FLAGS + WND_ALIGN_CONTENT
      Value<ushort> margins[4];
  };

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

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

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

Характерные свойства "контролов"

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

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

Здесь C — один из классов "контролов", например, CEdit, CSpinEdit и т.д. Обработчик onEvent вызывается кэшем раскладки автоматически для соответствующих элементов и типов событий. Размеется, происходит это только при условии внесения правильных строк в карту обработки событий. Например, в предыдущей части по этому принципу была настроена обработка нажатий кнопки "Inject" (она была описана как наследник Notifiable<CButton>).

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

  template<typename C, typename V>
  class PlainTypeNotifiable: public Notifiable<C>
  {
    public:
      virtual V value() = 0;
  };

Назначение метода value — вернуть из элемента типа C значение типа V, наиболее характерного для C. Например, для класса CEdit естественным образом выглядит возврат значения типа string (в некоем гипотетическом классе ExtendedEdit).

  class ExtendedEdit: public PlainTypeNotifiable<CEdit, string>
  {
    public:
      virtual string value() override
      {
        return Text();
      }
  };

Для каждого типа "контролов" существует единственный характерный тип данных или их ограниченный круг (например, для целых чисел можно выбирать точность short, int, long). И у всех "контролов" имеется тот или иной метод-"геттер", готовый предоставить значение в переопределяемом методе value.

Таким образом, мы подошли к сути архитектурного решения — взаимной увязке классов Value и PlainTypeNotifiable. Реализуется она с помощью класса-наследника PlainTypeNotifiable, который помещает значение "контрола" из инспектора в привязанное к нему свойство Value.

  template<typename C, typename V>
  class NotifiableProperty: public PlainTypeNotifiable<C,V>
  {
    protected:
      Value<V> *property;
      
    public:
      void bind(Value<V> *prop)
      {
        property = prop;     // pointer assignment
        property = value();  // overloaded operator assignment for value of type V
      }
      
      virtual bool onEvent(const int event, void *parent) override
      {
        if(event == ON_CHANGE || event == ON_END_EDIT)
        {
          property = value();
          return true;
        }
        return false;
      };
  };

За счет наследования от шаблонного класса PlainTypeNotifiable, новый класс NotifiableProperty представляет собой одновременно и класс "контрола" C, и поставщика значений типа V.

Метод bind позволяет сохранить внутри "контрола" ссылку на Value и затем менять значение свойства по месту (по ссылке), автоматически в ответ на действия пользователя с "контролом".

Например, для полей ввода строкового типа введено свойство EditProperty аналогичное примеру ExtendedEdit, но унаследованное от NotifiableProperty:

  class EditProperty: public NotifiableProperty<CEdit,string>
  {
    public:
      virtual string value() override
      {
        return Text(); // Text() is a standard method of CEdit
      }
  };

Для выпадающего списка аналогичный класс описывает свойство с целочисленным значением.

  class ComboBoxProperty: public NotifiableProperty<ComboBoxResizable,int>
  {
    public:
      virtual int value() override
      {
        return (int)Value(); // Value() is a standard method of CComboBox
      }
  };

В программе описаны классы "контролов"-свойств для всех основных типов элементов.

Диаграмма классов "уведомляемых свойств"

Диаграмма классов "уведомляемых свойств"

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

StdValue — значение, наблюдение, зависимости

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

Для этих целей введен интерфейс StateMonitor (наблюдатель).

  class StateMonitor
  {
    public:
      virtual void notify(void *sender) = 0;
  };

Метод notify предназначен для вызова источником изменений, чтобы данный наблюдатель мог произвести ответную реакцию (если требуется). Источник изменений можно идентифицировать по параметру sender. Разумеется, источник изменений должен предварительно каким-то образом узнать, что конкретный наблюдатель заинтересован в получении уведомлений. Для этих целей источник должен реализовать интерфейс Publisher.

  class Publisher
  {
    public:
      virtual void subscribe(StateMonitor *ptr) = 0;
      virtual void unsubscribe(StateMonitor *ptr) = 0;
  };

С помощью метода subscribe наблюдатель может передать ссылку на себя "издателю". Как не трудно догадаться, источниками изменений у нас будут являться свойства, и потому гипотетический класс Value на самом деле унаследован от Publisher и имеет следующий вид.

  template<typename V>
  class ValuePublisher: public Publisher
  {
    protected:
      V value;
      StateMonitor *dependencies[];
      
    public:
      V operator~(void) const
      {
        return value;
      }
      
      void operator=(V v)
      {
        value = v;
        for(int i = 0; i < ArraySize(dependencies); i++)
        {
          dependencies[i].notify(&this);
        }
      }
      
      virtual void subscribe(StateMonitor *ptr) override
      {
        const int n = ArraySize(dependencies);
        ArrayResize(dependencies, n + 1);
        dependencies[n] = ptr;
      }
      ...
  };

Любой зарегистрировавшийся наблюдатель попадает в массив dependencies, и при изменении величины будет оповещен вызовом своего метода notify.

Поскольку свойства однозначно связаны с "контролами", с помощью которых они вводятся, предусмотрим сохранение ссылки на "контрол" в окончательном классе свойств для Стандартной библиотеки — StdValue (использует базовый тип всех "контролов" CWnd).

  template<typename V>
  class StdValue: public ValuePublisher<V>
  {
    protected:
      CWnd *provider;
      
    public:
      void bind(CWnd *ptr)
      {
        provider = ptr;
      }
      
      CWnd *backlink() const
      {
        return provider;
      }
  };

Эта ссылка пригодится в дальнейшем.

Именно экземпляры StdValue наполняют PropertySet.

Диаграмма связей StdValue

Диаграмма связей StdValue

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

  template<typename C, typename V>
  class NotifiableProperty: public PlainTypeNotifiable<C,V>
  {
    protected:
      StdValue<V> *property;
    public:
      void bind(StdValue<V> *prop)
      {
        property = prop;
        property.bind(&this);        // +
        property = value();
      }
      ...
  };

Автоматическое управление состоянием "контролов" — EnableStateMonitor

Самый востребованный способ реагирования на изменение некоторых настроек — блокирование или разблокирование других зависимых "контролов". Состояние каждого такого адаптивного "контрола" может зависеть от нескольких настроек (не обязательно от одного). Чтобы следить за ними разработан специальный абстрактный класс EnableStateMonitorBase.

  template<typename C>
  class EnableStateMonitorBase: public StateMonitor
  {
    protected:
      Publisher *sources[];
      C *control;
      
    public:
      EnableStateMonitorBase(): control(NULL) {}
      
      virtual void attach(C *c)
      {
        control = c;
        for(int i = 0; i < ArraySize(sources); i++)
        {
          if(control)
          {
            sources[i].subscribe(&this);
          }
          else
          {
            sources[i].unsubscribe(&this);
          }
        }
      }
      
      virtual bool isEnabled(void) = 0;
  };

"Контрол", за состоянием которого "следит" данный наблюдатель, помещается в поле control. Массив sources содержит источники изменений, которые влияют на состояние. Массив должен будет заполняться в классах-наследниках. Когда мы подключаем наблюдателя к конкретному "контролу" с помощью вызова attach, наблюдатель подписывает себя на все источники изменений. Далее он начнет оперативно получать уведомления об изменениях в источниках через вызовы свого метода notify.

Следует ли заблокировать или разблокировать "контрол" решает метод isEnabled, но здесь он объявлен абстрактным и будет реализован в классах-наследниках.

Для классов Стандартной библиотеки известен механизм блокировки "контролов" с помощью общих методов Enable и Disable. Воспользуемся ими для реализации конкретного класса EnableStateMonitor.

  class EnableStateMonitor: public EnableStateMonitorBase<CWnd>
  {
    public:
      EnableStateMonitor() {}
      
      void notify(void *sender) override
      {
        if(control)
        {
          if(isEnabled())
          {
            control.Enable();
          }
          else
          {
            control.Disable();
          }
        }
      }
  };

На практике этот класс будет задействован в программе во многих случаях, но рассмотрим лишь один пример. Для создания новых объектов или применения изменённых свойств в дизайнере, в диалоге инспектора имеется кнопка Apply (для неё определен класс ApplyButton, производный от Notifiable<CButton>).

  class ApplyButton: public Notifiable<CButton>
  {
    public:
      virtual bool onEvent(const int event, void *parent) override
      {
        if(event == ON_CLICK)
        {
          ...
        }
      };
  };

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

  class ApplyButtonStateMonitor: public EnableStateMonitor
  {
    // what's required to detect Apply button state
    const int NAME;
    const int TYPE;
    
    public:
      ApplyButtonStateMonitor(StdValue<string> *n, StdValue<int> *t): NAME(0), TYPE(1)
      {
        ArrayResize(sources, 2);
        sources[NAME] = n;
        sources[TYPE] = t;
      }
      
      virtual bool isEnabled(void) override
      {
        StdValue<string> *name = sources[NAME];
        StdValue<int> *type = sources[TYPE];
        return StringLen(~name) > 0 && ~type != -1 && ~name != "Client";
      }
  };

Конструктор класса принимает два параметра, указывающих на соответствующие свойства. Они сохраняются в массиве sources. В методе isEnabled осуществляется проверка, заполнено ли имя, и выбран ли тип (не равен -1). Если условия выполняются, кнопку разрешено нажимать. Дополнительно имя проверяется на особую строку "Client", которая зарезервирована в диалогах Стандартной библиотеки за клиентской областью и потому не может встречаться в имени пользовательских элементов.

В классе диалога инспектора имеется переменная типа ApplyButtonStateMonitor, которая инициализируется в конструкторе ссылками на объекты StdValue, хранящими имя и тип.

  class InspectorDialog: public AppDialogResizable
  {
    private:
      PropertySet props;
      ApplyButtonStateMonitor *applyMonitor;
    public:
      InspectorDialog::InspectorDialog(void)
      {
        ...
        applyMonitor = new ApplyButtonStateMonitor(&props.name, &props.type);
      }

В раскладке диалога свойства имени и типа привязаны к соответствующим "контролам", а наблюдатель — к кнопке Apply.

          ...
          _layout<EditProperty> edit("NameEdit", BUTTON_WIDTH, BUTTON_HEIGHT, "");
          edit.attach(&props.name);
          ...
          _layout<ComboBoxProperty> combo("TypeCombo", BUTTON_WIDTH, BUTTON_HEIGHT);
          combo.attach(&props.type);
          ...
          _layout<ApplyButton> button1("Apply", BUTTON_WIDTH, BUTTON_HEIGHT);
          button1["enable"] <= false;
          applyMonitor.attach(button1.get());

Метод attach в объекте applyMonitor нам уже знаком, а вот attach в объектах ракладки _layout — это что-то новенькое. Класс _layout подробно рассматривался во второй статье, и метод attach — единственное изменение по сравнению с той версией. Этот метод-посредник просто вызывает bind для элемента управления, порождаемого объектом _layout внутри диалога инспектора.

  template<typename T>
  class _layout: public StdLayoutBase
  {
      ...
      template<typename V>
      void attach(StdValue<V> *v)
      {
        ((T *)object).bind(v);
      }
      ...
  };

Напомним, что все "контролы"-свойства (включая EditProperty и ComboBoxProperty, как в данном примере) являются наследниками класса NotifiableProperty, в котором есть метод bind для привязки "контролов" к переменным StdValue, хранящим соответствующие свойства. Таким образом, "контролы" в окне инспектора оказываются связанными с соответствующими свойствами, а те, в свою очередь "мониторятся" наблюдателем ApplyButtonStateMonitor. Как только пользователь изменяет значение любого из двух полей, это отображается в наборе свойства PropertySet (вспоминаем обработчик onEvent для событий ON_CHANGE и ON_END_EDIT в NotifiableProperty) и оповещает зарегистрированных наблюдателей, включая и ApplyButtonStateMonitor. В результате этого состояние кнопки автоматически меняется на актуальное.

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

Классы StateMonitor

Классы StateMonitor

Итак, обозначим окончательное соответствие всех свойств создаваемого объекта и "контролов" в диалоге инспектора.

  • имя — EditProperty, строка;
  • тип — ComboBoxProperty, целое число, номер типа из списка поддерживаемых элементов;
  • ширина — SpinEditPropertySize, целое число, пискели;
  • высота — SpinEditPropertySize, целое число, пискели;
  • стиль — ComboBoxProperty, целое число, равное значению одного из перечислений (в зависмости от типа элемента): VERTICAL_ALIGN (CBoxV), HORIZONTAL_ALIGN (CBoxH), ENUM_ALIGN_MODE (CEdit);
  • текст — EditProperty, строка;
  • цвет фона — ComboBoxColorProperty, значение цвета из списка;
  • выравнивание по краям — AlignCheckGroupProperty, битовая маска, группа независимых флагов (ENUM_WND_ALIGN_FLAGS + WND_ALIGN_CONTENT);
  • отступы — четыре SpinEditPropertyShort, целые числа;

Название классов некоторых "Property" указывает на их специализацию, то есть расширение функционала по сравнению с базовой реализацией, какую предлагают "простые" SpinEditProperty, ComboBoxProperty, CheckGroupProperty и т.д. Для чего они нужны, станет ясно из руководства пользователя.

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

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

Обработка событий по всем "контролам" определена в карте событий:

  EVENT_MAP_BEGIN(InspectorDialog)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_END_EDIT, cache, EditProperty)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, SpinEditProperty)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CLICK, cache, SpinEditProperty)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, SpinEditPropertyShort)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, ComboBoxProperty)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, ComboBoxColorProperty)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CHANGE, cache, AlignCheckGroupProperty)
    ON_EVENT_LAYOUT_CTRL_DLG(ON_CLICK, cache, ApplyButton)
    ...
    ON_EVENT_LAYOUT_ARRAY(ON_CLICK, cache) // default (stub)
  EVENT_MAP_END(AppDialogResizable)

Для повышения эффективности обработки событий в кэше были предприняты некоторые специальные шаги. Введенные во второй статье макросы ON_EVENT_LAYOUT_CTRL_ANY и ON_EVENT_LAYOUT_CTRL_DLG строят свою работу на поиске "контрола" в массиве кэша по уникальному номеру, получаемому от системы в параметре lparam. При этом базовая реализация кэша выполняет линейный поиск по массиву.

Чтобы ускорить процесс в классе MyStdLayoutCache (наследнике StdLayoutCache), экземпляр которого хранится и используется в инспекторе, добавлен метод buildIndex. Реализованная в нем возможность удобной индексации опирается на особенность Стандартной библиотеки назначать уникальные номера всем элементам. В методе CAppDialog::Run выбирается случайное число — уже известный нам m_instance_id, — начиная с которого нумеруются все объекты чарта, созданные окном. Таким образом, мы можем узнать диапазон полученных значений. За вычетом m_instance_id каждое значение lparam, приходящее с событием, превращается в прямой номер объекта. Однако программа создает на чарте гораздо больше объектов, чем хранится к кэше, потому что многие "контролы" (да и само окно, как совокупность рамки, заголовка, кнопки минимизации и пр.) состоят из множества низкоуровневых объектов. Поэтому индекс в кэше никогда не совпадает с идентификатором объекта за вычетом m_instance_id. В связи с этим потребовалось выделить особый индексный массив (его размер равен числу объектов окна), и для тех "настоящих" "контролов", которые имеются к кэше, неким образом записать их порядковые номера в кэше. В результате доступ обеспечивается практически мгновенно, по принципу косвенной адресации.

Заполнять массив нужно только после того, как базовая реализация CAppDialog::Run назначит уникальные номера, но до того, как обработчик OnInit закончит свою работу. Лучше всего для этих целей сделать метод Run виртуальным (в Стандартной библиотеке он таковым не является) и переопределить в InspectorDialog, например, так.

  bool InspectorDialog::Run(void)
  {
    bool result = AppDialogResizable::Run();
    if(result)
    {
      cache.buildIndex();
    }
    return result;
  }

Сам метод buildIndex довольно прост.

  class MyStdLayoutCache: public StdLayoutCache
  {
    protected:
      InspectorDialog *parent;
      // fast access
      int index[];
      int start;
      
    public:
      MyStdLayoutCache(InspectorDialog *owner): parent(owner) {}
      
      void buildIndex()
      {
        start = parent.GetInstanceId();
        int stop = 0;
        for(int i = 0; i < cacheSize(); i++)
        {
          int id = (int)get(i).Id();
          if(id > stop) stop = id;
        }
        
        ArrayResize(index, stop - start + 1);
        ArrayInitialize(index, -1);
        for(int i = 0; i < cacheSize(); i++)
        {
          CWnd *wnd = get(i);
          index[(int)(wnd.Id() - start)] = i;
        }
      ...
  };

Теперь можно написать быструю реализацию метода поиска "контролов" по номеру.

      virtual CWnd *get(const long m) override
      {
        if(m < 0 && ArraySize(index) > 0)
        {
          int offset = (int)(-m - start);
          if(offset >= 0 && offset < ArraySize(index))
          {
            return StdLayoutCache::get(index[offset]);
          }
        }
        
        return StdLayoutCache::get(m);
      }

Но довольно уже о внутреннем устройства инспектора.

Вот как выглядит его окно в запущенной программе.

Диалог Inspector и форма Designer

Диалог Inspector и форма Designer

Помимо свойств мы здесь видим некоторые незнакомые элементы. Все они будут описаны позднее. Пока обратим внимание на кнопку Apply. После того как пользователь задаст значения свойствам, он может сгенерировать запрошенный объект в форме дизайнера нажатием этой кнопки. Имея класс, производный от Notifiable, кнопка способна обрабатывать нажатия в собственном методе onEvent.

  class ApplyButton: public Notifiable<CButton>
  {
    public:
      virtual bool onEvent(const int event, void *parent) override
      {
        if(event == ON_CLICK)
        {
          Properties p = inspector.getProperties().flatten();
          designer.inject(p);
          ChartRedraw();
          return true;
        }
        return false;
      };
  };

Напомним, что переменные inspector и designer являются глобальными объектами с диалогом инспектора и формой дизайнера соответственно. Инспектор имеет в своем программном интерфейсе метод getProperties для предоставления текущего набора свойств PropertySet, описанного выше:

    PropertySet *getProperties(void) const
    {
      return (PropertySet *)&props;
    }

PropertySet умеет упаковывать себя в плоскую (обычную) структуру Properties для передачи в метод дизайнера inject. И тут мы плавно перемещаемся к окну дизайнера.

Дизайнер

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

Вот как в общих чертах выглядит добавление нового элемента.

    void inject(Properties &props)
    {
      CWnd *ptr = cache.get(props.name);
      if(ptr != NULL)
      {
        ...
      }
      else
      {
        CBox *box = dynamic_cast<CBox *>(cache.getSelected());
        
        if(box == NULL) box = cache.findParent(cache.getSelected());
        
        if(box)
        {
          CWnd *added;
          StdLayoutBase::setCache(cache);
          {
            _layout<CBox> injectionPanel(box, box.Name());
            
            {
              AutoPtr<StdLayoutBase> base(getPtr(props));
              added = (~base).get();
              added.Id(rand() + ((long)rand() << 32));
            }
          }
          box.Pack();
          cache.select(added);
        }
      }

Переменная cache описана в DesignerForm и содержит объект класса DefaultStdLayoutCache, производного от StdLayoutCache (представлен в предыдущих статьях). StdLayoutCache позволяет с помощью метода get найти объект по имени. Если его нет, речь идет о новом объекте, и дизайнер пытается определить текущий выделенный пользователем контейнер. Для этих целей в новом классе DefaultStdLayoutCache реализован метод getSelected. Как именно происходит выделение, мы увидим чуть позже. Здесь важно отменить, что местом внедрения нового элемента может быть только контейнер (в нашем случае используются контейнеры семейства CBox). Если в данный момент выделен не контейнер, то алгоритм вызывает findParent, чтобы определить родительский контейнер и использовать в качестве цели его. Когда место вставки определено, начинает работать привычная схема разметки с вложенными блоками. Во внешнем блоке создается объект _layout с целевым контейнером, и затем внутри генерируется объект, в строке:

  AutoPtr<StdLayoutBase> base(getPtr(props));

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

    StdLayoutBase *getPtr(const Properties &props)
    {
      switch(props.type)
      {
        case _BoxH:
          {
            _layout<CBoxH> *temp = applyProperties(new _layout<CBoxH>(props.name, props.width, props.height), props);
            temp <= (HORIZONTAL_ALIGN)props.style;
            return temp;
          }
        case _Button:
          return applyProperties(new _layout<CButton>(props.name, props.width, props.height), props);
        case _Edit:
          {
            _layout<CEdit> *temp = applyProperties(new _layout<CEdit>(props.name, props.width, props.height), props);
            temp <= (ENUM_ALIGN_MODE)LayoutConverters::style2textAlign(props.style);
            return temp;
          }
        case _SpinEdit:
          {
            _layout<SpinEditResizable> *temp = applyProperties(new _layout<SpinEditResizable>(props.name, props.width, props.height), props);
            temp["min"] <= 0;
            temp["max"] <= DUMMY_ITEM_NUMBER;
            temp["value"] <= 1 <= 0;
            return temp;
          }
        ...
      }
    }

Объекты _layout, шаблонизируемые заданным типом элемента GUI, создаются с помощью конструкторов, знакомым нам по статическим описаниям MQL-разметок. Объекты _layout предоставляют возможность применять перегруженные операторы <= для задания свойств, в частности так заполняется стиль HORIZONTAL_ALIGN для CBoxH, ENUM_ALIGN_MODE для текстового поля или диапазоны "спинера". Настройки некоторых других общих свойств (отступы, текст, цвет) делегируются вспомогательному методу applyProperties (подробно с ним ознакомиться можно в исходных кодах).

    template<typename T>
    T *applyProperties(T *ptr, const Properties &props)
    {
      static const string sides[4] = {"left", "top", "right", "bottom"};
      for(int i = 0; i < 4; i++)
      {
        ptr[sides[i]] <= (int)props.margins[i];
      }
      
      if(StringLen(props.text))
      {
        ptr <= props.text;
      }
      else
      {
        ptr <= props.name;
      }
      ...
      return ptr;
    }

В случае, если объект найден в кэше по имени, происходит следующее (в упрощенном виде):

    void inject(Properties &props)
    {
      CWnd *ptr = cache.get(props.name);
      if(ptr != NULL)
      {
        CWnd *sel = cache.getSelected();
        if(ptr == sel)
        {
          update(ptr, props);
          Rebound(Rect());
        }
      }
      ...
    }

Вспомогательный метод update переносит свойства из структуры props в найденный объект ptr.

    void update(CWnd *ptr, const Properties &props)
    {
      ptr.Width(props.width);
      ptr.Height(props.height);
      ptr.Alignment(convert(props.align));
      ptr.Margins(props.margins[0], props.margins[1], props.margins[2], props.margins[3]);
      CWndObj *obj = dynamic_cast<CWndObj *>(ptr);
      if(obj)
      {
        obj.Text(props.text);
      }
      
      CBoxH *boxh = dynamic_cast<CBoxH *>(ptr);
      if(boxh)
      {
        boxh.HorizontalAlign((HORIZONTAL_ALIGN)props.style);
        boxh.Pack();
        return;
      }
      CBoxV *boxv = dynamic_cast<CBoxV *>(ptr);
      if(boxv)
      {
        boxv.VerticalAlign((VERTICAL_ALIGN)props.style);
        boxv.Pack();
        return;
      }
      CEdit *edit = dynamic_cast<CEdit *>(ptr);
      if(edit)
      {
        edit.TextAlign(LayoutConverters::style2textAlign(props.style));
        return;
      }
    }

Теперь давайте вернемся к задаче выделения элементов GUI в форме. Её решением занимается объект кэша, за счет обработки событий, инициируемых пользователем. В классе StdLayoutCache зарезервирован обработчик onEvent, который подключается к событиям чарта в карте с помощью макроса ON_EVENT_LAYOUT_ARRAY:

  EVENT_MAP_BEGIN(DesignerForm)
    ON_EVENT_LAYOUT_ARRAY(ON_CLICK, cache)
    ...
  EVENT_MAP_END(AppDialogResizable)

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

  class DefaultStdLayoutCache: public StdLayoutCache
  {
    protected:
      CWnd *selected;
      
    public:
      CWnd *getSelected(void) const
      {
        return selected;
      }
      
      ...
      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(element); // get actual GUI element
          }
          ...
          
          selected = element;
          const bool b = highlight(selected, clrRed);
          Print(control.Name(), " -> ", element._rtti, " / ", element.Name(), " / ", element.Id());
          EventChartCustom(CONTROLS_SELF_MESSAGE, ON_LAYOUT_SELECTION, 0, 0.0, NULL);
          return true;
        }
        return false;
      }
  };

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

В инспекторе данное событие перехватывается в обработчике OnRemoteSelection, который запрашивает от дизайнера ссылку на выделенный объект и считывает из него все атрибуты через стандартное API библиотеки.

  EVENT_MAP_BEGIN(InspectorDialog)
    ...
    ON_NO_ID_EVENT(ON_LAYOUT_SELECTION, OnRemoteSelection)
  EVENT_MAP_END(AppDialogResizable)

Вот начало метода OnRemoteSelection.

  bool InspectorDialog::OnRemoteSelection()
  {
    DefaultStdLayoutCache *remote = designer.getCache();
    CWnd *ptr = remote.getSelected();
    
    if(ptr)
    {
      string purename = StringSubstr(ptr.Name(), 5); // cut instance id prefix
      CWndObj *x = dynamic_cast<CWndObj *>(props.name.backlink());
      if(x) x.Text(purename);
      props.name = purename;
      
      int t = -1;
      ComboBoxResizable *types = dynamic_cast<ComboBoxResizable *>(props.type.backlink());
      if(types)
      {
        t = GetTypeByRTTI(ptr._rtti);
        types.Select(t);
        props.type = t;
      }
      
      // width and height
      SpinEditResizable *w = dynamic_cast<SpinEditResizable *>(props.width.backlink());
      w.Value(ptr.Width());
      props.width = ptr.Width();
      
      SpinEditResizable *h = dynamic_cast<SpinEditResizable *>(props.height.backlink());
      h.Value(ptr.Height());
      props.height = ptr.Height();
      ...
    }
  }

Получив из кэша дизайнера ссылку ptr на выделенный объект, алгоритм узнает его имя, очищает его от идентификатора окна (это поле m_instance_id в классе CAppDialog является приставкой во всех именах, чтобы не возникало конфликтов между объектами разных окон, а их у нас 2), и записывает в "контрол", связанный с именем. Обратите внимание, что именно здесь мы используем обратную ссылку на "контрол" (backlink()) из свойства StdValue<string> name. Кроме того, поскольку мы модифицируем поле изнутри, событие о его изменении не генерируется (как происходит, когда инициатором изменения выступает пользователь), а потому дополнительно требуется записать новое значение в соответствующее свойство PropertySet (props.name).

Теоретически, с позиций ООП было бы правильнее для каждого типа "контрола"-свойства переопределить его виртуальный метод изменения и обновлять привязанный к нему экземпляр StdValue автоматически. Вот, например, как это можно было бы сделать для CEdit.

  class EditProperty: public NotifiableProperty<CEdit,string>
  {
    public:
      ...
      virtual bool OnSetText(void) override
      {
        if(CEdit::OnSetText())
        {
          if(CheckPointer(property) != POINTER_INVALID) property = m_text;
          return true;
        }
        return false;
      }    
  };

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

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

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

Руководство пользователя

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

Обязательными для заполнения являются имя (строка) и тип (выбирается из выпадающего списка).

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

Все "контролы" типа SpinEdit (не только в свойствах размеров) были усовершенствованы таким образом, что перемещение мыши внутри "контрола" влево или вправо при нажатой кнопке мыши (drag, но не drop) производит быструю смену значений "спинера" пропорционально пройденной дистанции в пикселях. Это сделано для облегчения редактирования, которое не очень удобно выполнять нажатиями на мелкие кнопки-качалки. Изменения доступны для любых программ, которые станут использовать "контролы" из папки ControlsPlus.

Выпадающий список со стилем выравнивания содержимого (Style) доступен только для элементов CBoxV, CBoxH и CEdit (для прочих типов он блокируется). Для контейнеров CBox задействованы все режимы выравнивания ("center", "justify", "left/top", "right/bottom", "stack"). Для CEdit работают только те, что соответствуют ENUM_ALIGN_MODE ("center", "left", "right").

Поле Text позволяет задать заголовок кнопки CButton, метки CLabel или содержимое CEdit. Для других типов поле неактивно.

Выпадающий список Color предназначен для выбора цвета фона из списка Web-цветов. Он доступен только для CBoxH, CBoxV, CButton и CEdit. Другие типы "контролов", являясь составными, требуют более изощренной техники обновления цвета во всех своих компонентах, потому было решено пока не поддерживать их раскраску. Для выбора цветов был модифицирован класс CListView. В него добавлен специальный "цветовой" режим, в котором значения элементов списка трактуются как коды цветов, и фон каждого элемента отрисовывается соответствующим цветом. Данный режим включается методом SetColorMode, и используется в новом классе ComboBoxWebColors (специализация ComboBoxResizable из папки Layouts).

Стандартные цвета GUI библиотеки выбирать в данный момент нельзя, потому что существует проблема с определением цветов по-умолчанию. Нам важно знать цвет по-умолчанию для каждого типа "контролов", чтобы не показывать его выделенным в списке, когда никакого специального цвета пользователь не выбирал. Самый простой подход — создать пустой "контрол" конкретного типа и прочитать у него свойство ColorBackground, но он работает у очень ограниченного числа "контролов". Дело в том, что цвет, как правило, назначается не в конструкторе класса, а в методе Create, который тянет за собой много ненужной инициализации, включая создание реальных объектов на чарте. Разумеется, нам не нужны лишние неиспользуемые объекты. Кроме того, цвет фона многих составных объектов получается из фона подложки, а не основого "контрола". Из-за сложности в учете этих нюансов было решено все цвета, используемые по-умолчанию в любых классах "контролов" Стандартной библиотеки, считать не выбранными. А это означает, что их нельзя включать в список, т.к. в противном случае пользователь может выбрать такой цвет, но в результате не увидит подтверждения своего выбора в инспекторе. Перечни web-цветов и стандартных цветов GUI представлены в файле LayoutColors.mqh.

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

Флаги группы независимых переключателей Alignment соответствуют режимам выравнивания по сторонам из перечисления ENUM_WND_ALIGN_FLAGS, плюс к ним добавлен особый режим WND_ALIGN_CONTENT, описанный во второй статье и работающий только для контейнеров. Если при нажатии на какой-либо переключатель держать нажатой клавишу Shift, программа синхронно переключит все 4 флага ENUM_WND_ALIGN_FLAGS. Если опция включается, то остальные тоже будут включены, и наоборот, если опция отключается, сбросятся другие. Это позволяет одним щелчком переключать всю группу, за исключением WND_ALIGN_CONTENT.

"Спинеры" Margins задают отступы элемента относительно сторон прямоугольника контейнера, в котором этот элемент находится. Порядок полей: левое, верхнее, правое, нижнее. Быстро сбросить все поля в ноль можно, щелкнув по любому полю при нажатой клавише Shift. Все поля легко можно установить равными, щелкнув по полю с требуемым значением при нажатой клавише Ctrl — в результате значение будет скопировано в 3 остальных поля.

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

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

Для выделения элемента в дизайнере нужно щелкнуть по нему мышью. Выделенный элемент подсвечивается красной рамкой. Исключение составляет CLabel — у него эта возможность не поддерживается.

Новый элемент автоматически выделяется сразу после вставки.

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

Повторный щелчок на уже выделенном элементе вызывает запрос на удаление. Удаление происходит только после подтверждения пользователем.

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

Кнопка Export дает возможность сохранить текущую конфигурацию интерфейса дизайнера в виде MQL-раскладки. Имя файла начинается с префикса layout, содержит текущий слепок времени и расширение txt. Если при нажатии Export держать нажатой кнопку Shift, конфигурация формы будет сохранена не в текстовом, а в двоичном виде, в файл собственного формата с расширением mql. Это удобно тем, что можно в любой момент прервать процесс проектирования разметки и возобновить его через некоторое время. Для загрузки двоичного mql-файла раскладки используется та же кнопка Export при условии, что форма и кэш элементов пусты, что выполняется сразу после запуска программы. Текущая версия всегда пытается импортировать файл "layout.mql". Желающие могу реализовать выбор файла во входных параметрах или на MQL.

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

Сейчас в процессе редактирования могут возникать ошибки 2 категорий: те, что можно поправить за счет анализа MQL-разметки, и более серьезные. К первым относятся такие сочетания настроек, когда "контролы" или контейнеры выходят за рамки окна или родительского контейнера. В этом случае они, как правило, перестают выделяться мышью и сделать их активными можно только с помощью селектора в инспекторе. Какие именно свойства являются ошибочными может помочь анализ MQL-разметки в текстовом виде — для получения её текущего состояния достаточно нажать кнопку Export. После изучения разметки следует поправить свойства в инспекторе и тем самым восстановить правильный вид формы.

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

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

Рекомендуется периодически сохранять форму в двоичном формате (кнопка Export с нажатой клавишей Shift), чтобы в случае неразрешимых проблем можно было продолжить работу с последней хорошей конфигурации.

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

Примеры

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

Процесс редактирования формы

Процесс редактирования формы

Редактирование формы наглядно демонстрирует адаптивный характер технологии разметки и как внешнее представление однозначно связано MQL-разметкой. В любой момент можно нажать кнопку Export и увидеть получившийся MQL-код.

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

Воссозданная в дизайнере разметка диалога Инспектор

Воссозданная в дизайнере разметка диалога Инспектор

Однако следует напомнить, что внутри инспектора многие классы "контролов" являются нестандартными, поскольку унаследованы от того или иного свойства x-Property и предоставляют дополнительную алгоритмическую обвязку. В нашем же примере в дизайнере используются только стандартные классы "контролов" (ControlsPlus). Иными словами, полученная раскладка всегда содержит только внешнее представление программы и стандартное поведение "контролов". Отслеживание состояния элементов и кодирование реакции на их изменение, включая и возможную кастомизацию классов — прерогатива программиста. Созданная система позволяет менять сущности в MQL-разметке как в обычном MQL. То есть можно заменить, например, ComboBox на ComboBoxWebColors. Но в любом случае, все классы, которые упоминаются в раскладке, должны быть подключены к проекту с помощью директив #include.

Представленный выше диалог (дубликат инспектора) был сохранен с помощью команды Export в текстовый и двоичный файлы — оба приложены к статье под названиями layout-inspector.txt и layout-inspector.mql соответственно.

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

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

Это легко сделать и для layout-inspector.txt. Содержимое этого файла целиком скопируем в буфер обмена и вставим в файл DummyForm.mqh внутрь метода CreateLayout, где имеется комментарий "// insert exported MQL-layout here".

Обратите внимание, что в текстовом представлении раскладки имеется упоминание размера диалога (в этом случае 200*350), под который он был создан. Поэтому в исходный код CreateLayout после строки создания объекта с формой _layout<DummyForm> dialog(this...) и перед скопированной раскладкой следует вставить строки:

  Width(200);
  Height(350);
  CSize sz = {200, 350};
  SetSizeLimit(sz);

Это обеспечит достаточное место всем "контролам" и не позволит сделать диалог меньше.

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

Если теперь скомпилировать и запустить пример, получим очень похожую копию инспектора. Но отличия все же есть.

Воссозданный интерфейс инспектора

Воссозданный интерфейс инспектора

Во-первых, все выпадающие списки пусты и потому не работают. Все "спинеры" не настроены и тоже не работают. Группа флагов выравнивания визуально пуста, потому что мы в раскладке не сгенерировали ни одного "чекбокса", но соответствующий "контрол" существует и в нем даже есть 5 скрытых "чекбоксов", которые библиотека стандартных компонентов генерирует, исходя из начального размера "контрола" (все эти объекты можно увидеть в списке объектов чарта, команда Object List).

Во-вторых, группа "спинеров" со значениями отступов реально отсутствует, мы её не переносили в форму, потому что в инспекторе она создается одним объектом раскладки как массив. Наш редактор такого не умеет. Можно было бы создать 4 независимых элемента, но тогда их всех пришлось бы настраивать в коде аналогично друг другу.

При нажатии на любой "контрол" форма выводит в лог его название, класс и идентификатор.

Мы также можем загрузить двоичный файл layout-inspector.mql (предварительно переименовав в layout.mql) обратно в инспектор и продолжить его редактировать. Для этого достаточно запустить основной проект и нажать Export при еще пустой форме.

Обратите внимание, что дизайнер генерирует для наглядности некоторое количество данных для всех "контролов" со списками или группами, а также устанавливает диапазон у спинеров. Поэтому при переключении в TestMode можно "поиграться" с элементами. Этот размер псевдо-данных задается в форме дизайнера макросом DUMMY_ITEM_NUMBER и равен по умолчанию 11.

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

Макет торговой панели Color-Cube-Trade-Panel

Макет торговой панели Color-Cube-Trade-Panel

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

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

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

Данная разметка приложена к статье в виде двух файлов layout-color-cube-trade-panel: текстового и двоичного. Первый можно вставить в пустую форму (вроде DummyForm) и дополнить данными и обработкой событий. Вторую — загрузить в дизайнер и отредактировать. Но не забывайте, что графический редактор не обязателен. Исправить разметку можно и в текстовом представлении. Преимущество редактора лишь в том, что можно поиграться настройками и на лету увидеть изменения. Но он поддерживает лишь самые основные свойства.

Заключение

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

Прикрепленные файлы |
MQL5GUI3.zip (112.66 KB)
fxsaber
fxsaber | 7 апр 2020 в 13:21
Мощно получилось!
Sergey Pavlov
Sergey Pavlov | 7 апр 2020 в 14:45
Поздравляю! Отличная работа и статьи.
Реter Konow
Реter Konow | 7 апр 2020 в 16:35
Наконец то, достойная конкуренция (имею ввиду результат). Теперь, держитесь!))) 
Реter Konow
Реter Konow | 8 апр 2020 в 22:02

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

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

Список требований к будущему редактору:

1. Мануальная настройка элементов.

2. Копирование элементов.

3. Одновременное управление свойством множества элементов.

4. Вспомогательная раметка.

5. Многооконный режим.

6. Создание окон разных типов.

7. Поддержка свойств окон: "всегда сверху", блокируещее остальные, с/без сворачивания.

8. Параллельное редактирование окон.

9. Удаление окон.

Пока все. 


ЗЫ. Насколько я знаю, контроллы СБ состоят из МТ-объектов, что с одной стороны - упрощает систему взаимодействия с элементами и мануальную настройку (давным давно у меня был редактор на основе МТ-объектов и его несложно было сделать), с другой (если принять это упрощение) - отнимает все преимущества канваса (среди которых - скорость, визуальные эффекты и многое другое). То есть - "допинг-эффект" от использования СБ при создании редактора, его жестко ограничивает в развитии. Но, посмотрим...

Реter Konow
Реter Konow | 9 апр 2020 в 00:40
Интересно, что даже без канваса, автор может сделать вспомогательную разметку, если решит вопрос ручной установки элементов. Я так делал. Конечно, разметка на канвасе визуально круче, но в редакторе шарпа например, разметка хоть и скудная, но практичная. Лишь пара линий помогают позиционировать контроллы. Я был удивлен когда заглянул в него после того, как сделал свою разметку. 

Панель задачь тоже нужно сделать. Желательно.
Создаем кроссплатформенный советник-сеточник: тестируем мультивалютный советник Создаем кроссплатформенный советник-сеточник: тестируем мультивалютный советник

За месяц рынки упали более чем на 30%. Это ли не лучшее время для тестирования советников на основе сеток и мартингейл? Данная статья является продолжением серии статей "Создаем кроссплатформенный советник-сеточник", выход которого не планировался. Но раз сам рынок предоставляет возможность устроить советнику-сеточнику стресс-тестирование, почему бы этим не воспользоваться. Так давайте займемся этим.

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

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

Работа с таймсериями в библиотеке DoEasy (Часть 41): Пример мультисимвольного мультипериодного индикатора Работа с таймсериями в библиотеке DoEasy (Часть 41): Пример мультисимвольного мультипериодного индикатора

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

Непрерывная скользящая оптимизация (Часть 7): Стыковка логической части автооптимизатора с графикой и управление графикой из программы Непрерывная скользящая оптимизация (Часть 7): Стыковка логической части автооптимизатора с графикой и управление графикой из программы

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