在前两篇文章( 1 和 2 )中,我们研究了在 MQL 中构建界面标记系统的一般概念,并为表达类的层次结构的界面元素实现了基本类的初始化,对其进行缓存,样式化,为其设置属性,以及响应事件。 动态创建所需元素能够即时修改简单对话框布局,而已创建元素的单个存储的可用性通常能用建议的 MQL 语法来创建它,随后可以将其按原样插入到需要 GUI 的 MQL 程序之中。 故此,我们已着手创建窗体的图形编辑器。 我们会在本文中密切关注此任务。
问题陈述
编辑器必须确保在窗口中布置元素,并调整其基本属性。 以下是可支持属性的常规列表,但并非所有属性均适用于所有类型的元素。
- 类型,
- 名称,
- 宽度,
- 高度,
- 内部内容对齐样式,
- 文字或标题,
- 背景颜色,
- 在父容器里的对齐方式, 以及
- 距容器边界的偏移量/区域。
此处未包括许多其他属性,例如字体名称和大小,或各种类型“控件”的特定属性(尤其是“粘贴”按钮的属性)。 如此做意在简化这个主要针对概念验证(POC)的项目。 如有必要,稍后可以在编辑器中添加其他属性的支持。
通过偏移量可以间接完成绝对坐标定位,但是不建议这样做。 建议 CBox 容器采用本身的对齐设置自动执行定位。
编辑器是为标准函数库界面元素类而设计的。 若要为其他函数库创建类似的工具,您必须依据提议的标记系统为所有抽象实体编写特定实现。 同时,您应该遵照标准库的标记类实现作为指导。
应当注意,“标准组件库”定义实际上并不正确,因为关乎之前文章的上下文,我们必须对其进行大量修改,并将其放置在并行版本分支的 ControlsPlus 文件夹中。 于此,我们将继续使用和修改它。
我们列出编辑器支持的元素类型。
- 带有水平(CBoxH)和垂直(CBoxV)方向的容器 CBox,
- CButton,
- CEdit 输入框,
- CLabel,
- SpinEditResizable,
- 日历选择器 CDatePicker,
- 下拉菜单 ComboBoxResizable,
- 列表 ListViewResizable,
- CheckGroupResizable, 和
- RadioGroupResizable.
所有类都确保能够自适应调整大小(某些标准类型在一开始就可以做到,而我们必须对其他类型进行相当大的更改)。
该程序由两个窗口组成:“Inspector” 对话框,用户在其中选择要创建的控件所需属性;在 “Designer” 中,创建这些元素,从而按设计形成图形界面的外观。
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); }
这两个窗口都是由 MQL 标记技术形成的 AppDialogResizable(以下称为 CAppDialog)的子类。 因此,我们看到调用 CreateLayout,替代了 Create。
每个窗口都有自己的界面元素缓存。 然而,在 Inspector 当中,它从一开始就填充了“控件”,它们以相当复杂的布局(我们尝试以一般术语进行研究)进行描述,而在 Designer 里则是空的。 这很容易解释:几乎所有程序的业务逻辑都存储在 Inspector 之中,而 Designer 则是一个虚拟对象,Inspector 将根据用户的命令逐个实现新元素。
PropertySet
上面列出的每个属性都由特定类型的值表示。 例如,元素名称是一个字符串,而宽度和高度则是整数。 全部的数值集合完整定义了必须出现在 Designer 中的对象。 将集合存储在一个位置是合理的,为此引入了特殊类 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]; };
在 “Inspector” 对话框里,我们将引入该类的变量,从 Inspector 控件输入的当前设置会集中存储于此。
显然,在 Inspector 里每个属性都会用合适的控件来定义。 例如,为了选择要创建的“控件”类型,将用下拉列表 CComboBox,而将 CEdit 输入框则用于名称。 属性代表类型的单个值,例如列表中的行、数字或索引。 即使是复合属性,例如为 4 个边中的每一个分别定义的偏移量,也应独立考虑(左,上、等等),因为需要保留 4 个字段用于输入它们,因此,每个值都将连接到为其分配的控件。
因此,我们为 “Inspector” 对话框制定一条显而易见的规则 — 其中的每个控件都定义与之相关的属性,并且始终拥有给定类型的特定值。 这导致我们获得以下架构解决方案。
“控件”的特性
在之前的文章中,我们引入了一个特殊的接口 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-类型值。例如,对于类 CEdit,返回字符串类型值看起来很自然(在某些假设的类 ExtendedEdit 中)。
class ExtendedEdit: public PlainTypeNotifiable<CEdit, string> { public: virtual string value() override { return Text(); } };
对于每种“控件”,都有一个单一特征的数据类型,或有限范围(例如,对于整数,您可选的精度有 short、int 或 long)。 所有“控件”都有一个或另一个 “getter” 方法,可以在可重载的 “value” 方法中取值。
故此,我们进入了体系结构解决方案的要点 — 协调 Value 和 PlainTypeNotifiable 类。 它用后代类实现,PlainTypeNotifiable,该类将“控件”值从 Inspector 里移到与其链接的 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 能够将“值”的链接保留在“控件”内部,然后自动更改属性值(通过引用),从而响应用户对“控件”的操作。
例如,对于字符串类型的输入字段,引入了 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” 方法将链接传递给 Publisher。 不难猜测,对于我们而言,变化的来源将是属性,因此,假设类 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; } ... };
任何已注册的观察者都获得“依赖关系”,且如果数值发生变化,则调用其 “notify” 方法得到通知。
由于属性是我们引入的唯一与“控件”相关联的,我们将为标准库提供保存指向“控件”链接的最终属性类,即 StdValue(它用到了所有 CWind “控件”类型)。
template<typename V> class StdValue: public ValuePublisher<V> { protected: CWnd *provider; public: void bind(CWnd *ptr) { provider = ptr; } CWnd *backlink() const { return provider; } };
该链接在以后会很有用。
这些是填充 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 方法将决定是否应“阻塞”或“解锁”控件,但此处将其声明为抽象,并在其后代类中实现。
对于标准库类,已知一种启用/禁用“控件”的机制。 我们用它们来实现特定的类 EnableStateMonitor。
class EnableStateMonitor: public EnableStateMonitorBase<CWnd> { public: EnableStateMonitor() {} void notify(void *sender) override { if(control) { if(isEnabled()) { control.Enable(); } else { control.Disable(); } } } };
在实践中,该类经常在程序中用到,但我们只打算研究一个示例。 若要在 Designer 中创建新对象,或使用修改后的属性,在 “Inspector” 对话框中找到 “Apply” 按钮(为此定义了从 Notifiable<CButton> 派生出的 ApplyButton 类)。
class ApplyButton: public Notifiable<CButton> { public: virtual bool onEvent(const int event, void *parent) override { if(event == ON_CLICK) { ... } }; };
如果未定义对象名称,或未选择其类型,则该按钮必须被锁定。 因此,我们通过两个变化源(“publishers”)实现 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"; } };
类的构造函数采用两个指向相关属性的参数。 它们会被保存在 “sourcees” 数组之中。 方法 isEnabled 检查名称是否填写,以及是否选择了类型(是否不为 -1)。 如果满足条件,则按钮可以按下。 此外,还会检查名称中是否包含特殊字符串,Client,这是标准库对话框中为客户区域保留的字符串,因此不能在用户元素的名称里出现。
在 inspector 对话框类中,有一个 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());
我们已经知道 applyMonitor 对象中的 “attach” 方法,而 _layout 对象中的 “attach” 是新事物。 在我们的第二篇文章中已深入讨论了 _layout 类,与该版本相比,“attach” 方法是唯一的变化。 此中间方法仅对检查器对话框内 _layout 对象生成的控件调用 “bind”。
template<typename T> class _layout: public StdLayoutBase { ... template<typename V> void attach(StdValue<V> *v) { ((T *)object).bind(v); } ... };
应当注意,所有属性“控件”(包括本示例中的 EditProperty 和 ComboBoxProperty)都是 NotifiableProperty 类的后代,在该类中,存在一个 “bind” 方法,可将“控件”与存储该控件相关属性的 StdValue 变量绑定。 因此,inspector 窗口中的“控件”实际上已与相关属性绑定,而后者又由观察者 ApplyButtonStateMonitor 监视。 一旦用户更改了两个字段之一的数值,它就会显示在 PropertySet 中(记住 NotifiableProperty 中的 ON_CHANGE和ON_END_EDIT 事件应答程序 onEvent)并通知已注册的观察者,包括 ApplyButtonStateMonitor。 这样会自动更改当前按钮的状态。
在 inspector 对话框中,我们将需要若干个监视器,它们以类似的方式监视“控件”的状态。 我们将在用户手册的某个章节里讲述阻塞的特殊规则。
StateMonitor 类
好了,我们在检查器对话框中表示要创建的对象,和“控件”的所有属性的最终相关性。
- name — EditProperty, 字符串;
- type — ComboBoxProperty, 整数, 受支持元素列表中的类型编号;
- width — SpinEditPropertySize, 整数, 像素;
- height — SpinEditPropertySize, 整数, 像素;
- style — ComboBoxProperty, 等于枚举之一值(取决于元素类型)的整数:VERTICAL_ALIGN(CBoxV),HORIZONTAL_ALIGN(CBoxH)和 ENUM_ALIGN_MODE(CEdit);
- text — EditProperty, 字符串;
- background color — ComboBoxColorProperty, 列表中的颜色值;
- boundary alignment — AlignCheckGroupProperty,位掩码,独立标志组(ENUM_WND_ALIGN_FLAGS + WND_ALIGN_CONTENT); 和
- indents — 四个 SpinEditPropertyShort, 整数;
与“简单的” 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 里接收到的唯一编号,据其在缓存数组里搜索“控件”。 于此同时,基本缓存实现遍历数组执行线性搜索。
为了加快此过程,将 buildIndex 方法添加到 MyStdLayoutCache 类(StdLayoutCache 的后代)之中,在 Inspector 中存储和使用该类的一个实例。 其中实现的便捷索引功能基于标准库的特殊功能,可为所有元素分配唯一编号。 在方法 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 的内部结构来说已经足够了。
这是正在运行的程序里窗口的外观。
Inspector 对话框和 Form 设计器
除了属性,我们还可以在此处看到一些未知的元素。 它们都将在后面讲述。 现在我们来看一下按钮 Apply。 用户设置属性值之后,可按此按钮在 “Designer” 窗体中生成请求的对象。 派生自 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 拥有方法 getProperties,可提供当前的属性集合,PropertySet,如上所述:
PropertySet *getProperties(void) const { return (PropertySet *)&props; }
PropertySet 可将自身封包为一个平面(常规)结构,Properties,以便传递给 Designer 的方法,inject。 在此,我们去看看 “Designer” 窗口。
Designer(设计器)
除了进行额外的检查之外,方法 “inject” 的本质与第二篇文章的结尾类似:Form 将目标容器放入布局堆栈中(在第二篇文章中是静态设置的,即始终是相同的),并在其中依据所传递属性值生成元素。 在新的窗体里,可单击鼠标来选择所有元素,从而更换插入的上下文。 甚或,这种单击会启动将所选元素的属性转换至 Inspector。 因此,会出现编辑已创建对象的属性,并用相同的 “Apply” 按钮更新它们。 设计器通过比较元素的名称和类型来判断用户是要引入新元素,还是要编辑已有元素。 如果 Designer 缓存中已有此种组合,则意味着进行编辑。
如何添加一个新元素,通常就是这样的。
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 里定义,并包含一个从 StdLayoutCache 派生的 DefaultStdLayoutCache 类对象(在前面的文章中已将述)。 StdLayoutCache 能偶用方法 “get” 按名称查找对象。 如果不存在,则意味着这是一个新对象,且 Designer 尝试检测用户选择的当前容器。 为此目的,在新类 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 启用重载运算符 “<=” 来定义属性,实际上,这就是 CBoxH 如何填充 HORIZONTAL_ALIGN 样式,文本字段或轮转器范围的 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)
这会将所有缓存元素的鼠标点击发送到我们在派生类 DefaultStdLayoutCache 中定义的应答程序 onEvent。 指向通用窗口类型 CWnd 的 “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; } };
在窗体里选择的可见元素,会加上红色边框,作为简易的“高亮”方法(即 ColorBorder)。 处理器首先取消先前选择的元素(设置边框颜色 CONTROLS_BUTTON_COLOR_BORDER),然后找到与所单击图表对象对应的缓存元素,并将其指针保存在 “selected” 变量中。 最后,新选择的对象用红色边框标记,并发送 ON_LAYOUT_SELECTION 事件至图表。 它会通知 Inspector,在窗体里已选择了一个新元素,故此它应该在 Inspector 对话框中显示其属性。
在 Inspector 里,此事件在 OnRemoteSelection 应答程序中被拦截,该应答程序从 Designer 请求所选择对象的链接,并通过函数库的标准 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(); ... } }
从 Designer 缓存接收选定对象的 ptr 链接后,该算法查找其名称,并从窗口标识符中清除该名称(CAppDialog 类中的 m_instance_id 字段是所有名称的前缀,以避免不同窗口对象之间的冲突,因为我们会有 2 个),并将其写入与名称相关的“控件”之中。 您应当注意,在此我们用的是属性 StdValue<string> 名称到“控件”的反向链接(backlink())。 甚至,由于我们是从内部修改字段的,因此与其变化相关的事件(有些情况是由用户发起的更改)不会生成; 因此,它还需要将新值写入 PropertySet (props.name) 的相关属性。
从技术上讲,以 OOP 的角度来看,为属性“控件”的每种类型重写其虚拟的修改方法,并自动更新与其链接的 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,并自动更新属性。 然而,对于复合控件(例如 CCheckGroup),这样做不是很方便。 因此,我们倾向于一种更实际的实现。
与此类似,使用“控件”的反向链接,我们就能更新 Designer 里所选择对象的高度、宽度、类型和其他属性字段中的内容。
为了识别受支持的类型,我们有一个枚举,可以基于特殊变量 _rtti 来检测其元素,我们在之前的几篇文章里,将该变量添加到最底层的 CWnd 类之中,并在所有派生类中用特殊的类名填充它。
快速入门指南
“Inspector” 对话框包含各种类型的输入字段,其中包含当前对象(在 Designer 中选择),或要创建的对象属性。
强制填写的字段是名称(字符串)和类型(将在下拉列表中选择)。
宽度和高度字段允许以像素为单位定义对象大小。 只是,如果在下面指定了特定的拉伸模式,则不会考虑这些设置:例如,与左右边界绑定表示填满容器的宽度。 鼠标点击高度或宽度字段同时按住 Shift 键,可将属性重置为默认值(宽度 100,和高度 20)。
改进了所有 SpinEdit 类型的“控件”(不仅是大小属性),其方式是鼠标在“控件”内部移动(拖动但不放下)的情况下,按住鼠标键向左或向右移动能快速按比例更改 “spinner” 像素所覆盖的距离。 这样做是为了便于编辑,因为按小按钮并不是很方便。 任何程序均可更改来自 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 颜色列表中选择背景颜色。 它仅适用于 XBox、XBox、Button 和 CREdit。 其他类型的“控件”是复合控件,需要更复杂的技术来为其更新所有组件的颜色,故我们决定不支持为它们着色。 为了选择颜色,修改了 CListView 类。 在其内添加了特殊的 “color” 模式,在该模式下,列表项的值被解释为颜色代码,并且每项的背景均以相关颜色绘制。 此模式由 SetColorMode 方法启用,并在新类 ComboBoxWebColors(来自 Layouts 文件夹的 ComboBoxResizable 的特殊化)中调用。
目前无法选择函数库 GUI 的标准颜色,因为定义默认颜色存在问题。 对于我们来说,重要的是要知道每种“控件”类型的默认颜色,以便在用户未选择任何特定颜色的情况下,在列表中不要把它显示为已选颜色。 最简单的方法是创建一个特定类型的空“控件”,并在其中读取 ColorBackground 属性,但它只能在数量非常有限的“控件”时使用。 问题在于,通常不会在类构造函数中分配颜色,而是在 Create 方法中分配颜色,该方法会引发许多不必要的初始化,包括在图表中创建实际对象。 当然,我们不需要任何不必要的对象。 甚至,许多复合对象的背景色就是背景底色,而并非来自基准“控件”。由于考虑到这些细微差别的复杂性,我们决定考虑在标准库“控件”的任何类别中不选择默认颜色。这意味着它们不能包含在列表中,否则的话,用户可能会选择这种颜色,但在 Inspector 中看不到任何对其选择的确认。 Web 颜色和标准 GUI 颜色的列表在 LayoutColors.mqh 文件中提供。
若要将颜色重置为默认值(每种“控件”类型不同),应在列表中选择与 clrNONE 相关的第一个 “empty” 项。
一组独立切换器中的标志 Alignment 与枚举 ENUM_WND_ALIGN_FLAGS 两边侧的对齐方式相对应,加上特殊模式 WND_ALIGN_CONTENT,这在第二篇文章中已有过介绍,且它仅适用于容器。 如果您按下切换器时按住 Shift 键,程序将同步切换 ENUM_WND_ALIGN_FLAGS 的所有 4 个标志。 如果启用了该选项,则还应启用其他选项,反之亦然;如果禁用了该选项,则将重置其他选项。 一键切换整个组,WND_ALIGN_CONTENT 除外。
“Spinners” 边距定义元素的缩进,该缩进与该元素所在的容器矩形的边界有关。 字段顺序:左,上,右和下。 按住 Shift 键的同时单击任意字段,可将所有字段快速重置为零。 按住 Ctrl 键的同时单击含有所需值的字段,可以轻松地将所有字段设置为相等 — 这导致该值被复制到其它 3 个字段中。
我们已经知道 “Apply” 按钮 — 它应用所做的更改,这将导致在Designer中创建新的“控件”或修改现有控件。
新对象将被插入到所选容器对象,或包含所选“控件”的容器当中(如果已选择“控件”)。
若要在 Designer 中选择元素,应使用鼠标单击它。 所选元素以红色框高亮显示。 唯一的例外是 CLabel — 它不支持此功能。
插入后立即自动选择新元素。
只有容器 CBoxV 或 CBoxH 能插入到一个空对话框之中,且不必预先选择客户区。 默认情况下,第一个也是最大的容器会拉伸填满整个窗口。
重复单击已选择的元素则会删除请求。 删除仅在用户确认后才会实际发生。
两个位置的按钮 TestMode 在 Designer 的两个操作模式之间切换。 默认情况下,它是未按下的,测试模式是禁用的,Designer 界面编辑是可操作的 — 用户可以通过单击鼠标来选择元素,或将其删除。 按下时,将启用测试模式。 于此同时,对话框的操作方式与实际程序中的操作大致相同,而布局编辑和元素选择被禁用。
按钮 Export 允许将 Designer 界面的当前配置保存为 MQL 布局。 文件名以前缀 layout 开头,并包含当前的时间掩码和扩展名 txt。 如果您在按 “Export” 时按住 Shift 键,则窗体的配置将以二进制格式(而不是文本)保存到其自身格式的文件中,扩展名为 mql。 这很方便,因为您可以中断布局设计过程,然后过一会儿再恢复它。 若要加载二进制布局 mql 文件,使用同一个 “Export” 按钮,条件是元素的表单和缓存为空,这在启动程序后会立即执行。 当前版本始终尝试导入文件 layout.mql。 如果您愿意,可以在输入或 MQL 中实现文件选择。
在 “Inspector” 对话框的上部,有一个下拉列表,其中包含在 Designer 中创建的所有元素。 在列表中选择一个元素会导致在 Designer 中自动选择并高亮显示该元素。 反之亦然,选择表单中的元素会在列表中成为当前元素。
现在,在编辑中,可能会出现两种类别的错误:这些是可以通过分析 MQL 布局而更正的错误,以及更严重的。 前者包括设置组合,其中“控件”或容器超出了窗口或父容器的边界。 在这种情况下,通常会中断鼠标选择它们,且您只能用 Inspector 中的选择器启用它们。 您可以通过分析文本 MQL 标记来找出哪些属性完全是假的 — 只需按 Export 即可获取其当前状态。 在分析了标记之后,您应该更正 Inspector 中的属性,然后还原表单的正确视图。
程序的目前版本旨在验证概念,且未在源代码中检查所有参数组合,在重新计算自适应容器的大小时可能会发生这种情况。
第二类错误包括错误地将元素插入到错误容器的情况。 在此情况下,您只能删除该元素,然后将其再次添加到别的位置。
建议定期以二进制格式保存表单(按 “Export” 按钮,同时按住 Shift 键),如此即可在出现无法解决的问题时,您可以以最后的正确配置继续工作。
我们来研究一些使用该程序的示例。
示例
首先,我们尝试在 Designer 中重现 Inspector 结构。 在下面的动画图示里,您可从添加四个上部字符串和字段来设置名称、类型和宽度开始的全过程。 用到了不同类型的“控件”、对齐方式、配色方案。 包含字段名称的标签是用 CEdit 的输入字段形成的,因为 CLabel 只具有非常有限的功能(特别是它不支持文本对齐和背景颜色)。 然而,在 Inspector 中未有“只读”属性设置。 因此,将标签表示为不可编辑的唯一方法是为其分配灰色背景(这只是纯粹的视觉效果)。 在 MQL 代码里,此类 CEdit 对象必须确认要相应地调整,即,切换到“只读”模式。 这正是我们在 Inspector 本身中所做的。
编辑窗体
编辑窗体清晰地表明了标记技术的适应性,并作为一种外部表示形式,它唯与 MQL 标记绑定。 您可以随时按 Export 按钮,查看生成的 MQL 代码。
在最终版本里,我们会得到一个对话框,该对话框与 Inspector 窗口在理论上是完全对应的(某些细节除外)。
在 Designer 中还原 Inspector 对话框标记
不过,应当注意的是,在 Inspector 中,许多“控件”类都是非标准的,因为它们是从某个 x-属性继承而来的,且表示附加的算法工具。 不过,在我们的示例中,仅用到“控件”的标准类(ControlsPlus)。 换言之,结果布局始终只包含程序的外部表示形式,和“控件”的标准行为。 跟踪元素的状态,并针对其变化响应(包括可能的类自定义)编写代码是程序员的特权。 创建的系统能够像是在常规 MQL 中一样更改 MQL 标记中的工件。 也就是,您可以将 ComboBox 替换为 ComboBoxWebColors。 但是,无论如何,必须用 #include 指令将布局中提到的所有类都包含在项目中。
用 “Export” 命令将上面的对话框(Inspector 复本)保存到文本文件和二进制文件当中 — 两者都附带于此,分别名为 layout-inspector.txt 和 layout-inspector.mql。
分析完文本文件后,您无需绑定算法或数据即可感知 Inspector 标记。
基本上,将标记导出到文件后,其内容可以插入到任何项目之中,包括布局系统的头文件,和所有用到的 GUI 类。 结果就是,我们得到了一个可操作界面。 特别是,含有空 DummyForm 对话框的项目已附加到其中。 如果您愿意,可以在其中找到 CreateLayout,并将 MQL 标记插入其中,以便在 Designer 中进行初步准备。
对于 layout-inspector.txt,也可以轻松完成此操作。 我们将把这个文件的全部内容复制到剪贴板中,并插入到 CreateLayout 方法中的文件 DummyForm.mqh 当中,这里有注释 "// insert exported MQL-layout here"。
请注意,提及的对话框尺寸以布局文本表示(在本例中为 200*350),为此它之前已被创建。 因此,应在以 _layout<DummyForm> dialog(this...) 形式创建对象的代码之后,和已复制的布局之前,将以下代码插入源代码 CreateLayout 之中:
Width(200); Height(350); CSize sz = {200, 350}; SetSizeLimit(sz);
这将为所有“控件”提供足够的空间,并且不允许缩小对话框。
导出时,我们不会自动生成相关的片段,因为布局可能仅代表对话框的一部分,或者最终被其他类别的窗口和容器使用,而这些窗口和容器也许没有这些方法。
如果现在编译并运行示例,我们将获得非常类似的 Inspector 副本。 但仍存在差异。
恢复 Inspector 界面
首先,所有下拉列表都是空的,因此它们不起作用。 没有调整 “spinners”,因此它们也不起作用。 对齐标记组在视觉上是空的,因为我们尚未在布局中生成任何复选框,但是存在相关的“控件”,甚至还有 5 个隐藏的复选框,这些复选框是由标准组件库依据初始大小生成的“控件”(您可以在图表对象列表,命令“对象列表”中查看所有这些对象)。
其次,确实没有带有缩进值的 “spinners” 组:我们没有将其转移到表单中,因为它是由一个布局对象创建的,并在 Inspector 中作为数组。 我们的编辑器无法执行此类操作。 我们可以创建 4 个独立的元素,但随后我们必须在代码里如同其它一样做出调整。
按下任何“控件”,窗体都会将其名称、类和标识符输出到日志中。
我们还可以将二进制文件 layout-inspector.mql(已将其初步重命名为 layout.mql)加载到 Inspector,并继续进行编辑。 为此目的,运行主项目并按 Export 足矣,就如同早前窗体为空。
请注意,出于阐释目的,Designer 会为带有列表或分组的所有“控件”生成一定数量的数据,并设置 “spinners” 的范围。 因此,当切换到 TestMode 时,我们就可以使用元素。 伪数据的大小在 Designer 窗体中由宏 DUMMY_ITEM_NUMBER 定义,默认为 11。
现在,我们看看交易面板如何出现在 Designer 当中。
交易面板布局:彩色立方体交易面板
它并无虚伪的超级功能,但关键在于可以根据特定交易者的偏好轻松地在根本上进行修改。 此窗体如同前一种,使用着色容器来更容易地看到它们的布置。
我们应该再次有所保留,意思是仅在这里出现。 在 Designer 输出中,我们仅得到负责生成窗口和“控件”初始状态的 MQL 代码。像往常一样,所有计算算法,对用户操作的响应,防止输入错误数据,以及发送交易指令的操作都必须手动编程。
在该布局中,某些类型的“控件”应替换为更合适的内容。 故此,挂单的到期日由日历所示,且它不支持时间输入 。 所有下拉列表必须填充相关选项。 举例来说,止损价位可用不同单位输入,例如价格、以点数为单位的距离、盈/亏资金百分比、或绝对值,同时可以将交易量设置为固定、货币或可用保证金百分比,以及尾随等若干种算法之一。
此标记附带于此,分为两个 layout-color-cube-trade-panel 文件:文本文件和二进制文件。 可以将前一个插入到空窗体(例如 DummyForm)当中,并附带数据和事件处理。 后者可以加载到 Designer 当中,并进行编辑。 但是请记住,图形编辑器不是必需的。 标记也可在其文本表述中进行调整。 编辑器的唯一优点是我们可以进行设置,并即时查看变化。 然而,它仅支持最基本的功能。
结束语
在这篇论文中,我们研究了一个简单的编辑器程序,以交互方式开发基于 MQL 标记技术的图形界面。 呈现的实现仅包括基本功能,但这些功能仍足以展现该概念的可操作性,以及对其他类型“控件”的进一步扩展,对各种属性的更完整支持,GUI 组件的其他函数库,以及编辑机制。 特别是,编辑器仍然缺少取消操作、将元素插入容器中任何位置的功能(即,不光是将它们添加到已存在的“控件”列表的末尾)、分组操作、从剪贴板复制并粘贴的功能、等等。 然而,开源代码令您可以补充和调整该技术,从而满足您的需求。