跳转至

结构化对象(Schema Object)及其编辑器

一、什么是结构化对象(schema object)

我们这里将结构化对象称为“schema object”,因为它指的是这样一类对象:本质上它是一个dict,但与一般的dict不同的是,它具有一组固定的“键”,且每个键所对应的 值具有确定的类型,用于描述一个对象应当具有哪些键以及这些键可以被赋予什么类型的值的东西就被称为"schema"。简单来说,所谓结构化对象,就是一个使用schema 来限制其键的范围和值的类型的普通dict。

例如,以下对象:

{
    "name": "Alice",
    "age": 25,
    "scores": 90.5,
    "grade": "A",
    "photo": "images/alice.jpg"
}

可以使用以下schema来描述:

{
    "name": StringValue(),
    "age": IntValue(),
    "password": StringValue(),
    "scores": FloatValue(),
    "grade": ChoiceValue("A", ["A", "B", "C", "D", "E"]),
    "photo": FileValue()
}

可以看到,我们使用StrValueIntValueFloatValueChoiceValueFileValue来描述每个键所对应值的类型( 这些类都继承自pyguiadapter.itemseditor.schema.ValueType类),而不是使用intstrfloat等内置的类型, 主要是是因为ValueType可以提供更多的信息。

比如,对于IntValue类型,可以设置它的上下限:

{   
    "age": IntValue(min_value=0, max_value=20)
}

又比如,可以通过FileValue设置文件名过滤器:

{
    "photo": FileValue(file_filters="JPG Files (*.jpg);;PNG Files (*.png)")
}

同时,有些ValueType还定义了一些可以影响其编辑控件的外观和行为的属性,比如,可以通过StrValue设置文本输入框的占位文本、回显模式、是否显示清除按钮等:

{
    "password": StringValue(
        default_value="123456",
        display_name="Password",
        placeholder="Enter Password",
        echo_mode=PasswordEchoOnEditMode,
        clear_button=True,
    ),
}

对于常用的值类型,pyguiadapter已经实现了相应的ValueType,包括:

  • IntValue
  • FloatValue
  • StringValue
  • BoolValue
  • ChoiceValue
  • ColorValue
  • DateTimeValue
  • DateValue
  • DateTimeValue
  • TimeValue
  • FileValue
  • DirectoryValue
  • GenericPathValue
  • VariantValue
  • ListValue
  • TupleValue
  • DictValue

这些类型均在pyguiadapter.itemseditor.valuetypes包中定义和实现,可以通过如下方式导入:

from pyguiadapter.itemseditor.valuetypes import <Value-Type-Name>

例如:

from pyguiadapter.itemseditor.valuetypes import (
    StringValue,
    PasswordEchoOnEditMode,
    BoolValue,
    ChoiceValue,
)

如果内置的ValueType无法满足需求,也可以通过继承ValueType类来实现自定义的类型。

所有的ValueType均具有以下属性:

  • default_value:该类型的默认值。
  • display_name:用于指定显示在界面上的名称,如果不指定(None),则使用键名作为显示名称。

除了以上公共属性,不同类型的ValueType还定义一些专属的属性,这些属性或许用于值的校验,或者用于配置界面的外观和行为。可以参考相应的ValueType类的 构造函数来了解该类型支持的属性。

二、结构化对象的编辑器

目前,我们实现了两种可视化工具,用于浏览和编辑结构化对象,开发者可以在菜单或者工具栏按钮的回调函数中调出这些工具。

(一)show_schema_object_editor()

函数show_schema_object_editor()用于调出一个TableView风格的结构化对象编辑器:

它的函数签名如下:

show_schema_object_editor(parent: Optional[QWidget], schema: Dict[str, ValueType], obj: Dict[str, Any], config: SchemaObjectEditorConfig = SchemaObjectEditorConfig(), *, copy: bool = True, normalize_object: bool = True, validate: bool = True, accept_hook: Optional[Callable[[ObjectEditor, Dict[str, Any]], bool]] = None, reject_hook: Optional[Callable[[ObjectEditor], bool]] = None) -> Tuple[Dict[str, Any], bool]

弹出一个结构化对象编辑器

Parameters:

Name Type Description Default
parent Optional[QWidget]

父窗口

required
schema Dict[str, ValueType]

对象的Schema

required
obj Dict[str, Any]

要编辑的对象

required
config SchemaObjectEditorConfig

编辑器配置

SchemaObjectEditorConfig()
copy bool

是否复制对象

True
normalize_object bool

是否规范化对象,规范化对象是指:删除obj中不在schema未定义的键,并且使用默认值填充缺失的键

True
validate bool

是否验证对象,即检查obj是否符合schema定义的格式,如果不符合则引发异常

True
accept_hook Optional[Callable[[ObjectEditor, Dict[str, Any]], bool]]

点击编辑器Ok按钮后执行的钩子函数,函数签名为(editor: ObjectEditor, obj: Dict[str, Any]) -> bool,返回True则关闭编辑器,返回False则不关闭编辑器。

None
reject_hook Optional[Callable[[ObjectEditor], bool]]

点击编辑器Cancel按钮或关闭编辑器后执行的钩子函数,函数签名为(editor: ObjectEditor) -> bool,返回True则关闭编辑器,返回False则不关闭编辑器。

None

Returns:

Type Description
Tuple[Dict[str, Any], bool]

返回一个元组,该元组的第一个元素是编辑后的对象,第二个元素编辑器是否accepted(一般是点击Ok按钮)。

下面是一个相对完整的示例,展示了如何使用show_schema_object_editor()来编辑一个结构化对象:

from typing import Any, Dict

from qtpy.QtWidgets import QWidget

from pyguiadapter.action import Action
from pyguiadapter.adapter import GUIAdapter
from pyguiadapter.adapter.uoutput import uprint
from pyguiadapter.itemseditor.valuetypes import (
    StringValue,
    IntValue,
    ChoiceValue,
    PasswordEchoOnEditMode,
    FloatValue,
    BoolValue,
    FileValue,
)
from pyguiadapter.toolbar import ToolBar
from pyguiadapter.utils import (
    messagebox,
    show_schema_object_editor,
    SchemaObjectEditorConfig,
)
from pyguiadapter.windows.fnexec import FnExecuteWindow

student_profile_schema = {
    "name": StringValue(default_value="Undefined", display_name="Name"),
    "age": IntValue(default_value=16, display_name="Age", min_value=16, max_value=20),
    "grade": ChoiceValue(
        default_value="A",
        choices=["A", "B", "C", "D", "E"],
    ),
    "password": StringValue(
        default_value="Admin123",
        display_name="ID Password",
        echo_mode=PasswordEchoOnEditMode,
        clear_button=True,
        password_symbol="*",
        max_password_symbols=10,
    ),
    "scores": FloatValue(
        default_value=0.0,
        display_name="Average Scores",
        min_value=0.0,
        max_value=100.0,
        step=0.5,
        suffix=" (avg)",
        display_affix=True,
    ),
    "is_active": BoolValue(
        default_value=True, display_name="Active", true_text="Yes", false_text="No"
    ),
    "photo": FileValue(
        default_value="Not Provided",
        display_name="Photo",
        title="Select a photo",
        file_filters="JPG Files (*.jpg);;PNG Files (*.png);;All Files (*.*)",
    ),
}

student_profile = {
    "name": "John Doe",
    "age": 18,
    "grade": "B",
    "password": "MyPassword123",
    "scores": 99.5,
    "is_active": True,
    "photo": "/path/to/john_doe.jpg",
}


def _accept_hook(editor: QWidget, obj: Dict[str, Any]) -> bool:
    password = obj.get("password")
    if len(password) < 6:
        messagebox.show_warning_message(
            editor,
            message="Password should be at least 6 characters long.",
            title="Warning",
        )
        return False
    return True


def on_action_schema_object_editor(window: FnExecuteWindow, action: Action):
    _ = action  # unused
    new_settings, ok = show_schema_object_editor(
        window,
        schema=student_profile_schema,
        obj=student_profile,
        config=SchemaObjectEditorConfig(
            title="Student Profile",
            center_container_title="Personal Information",
            icon="ei.adult",
            # show_horizontal_header=False,
        ),
        accept_hook=_accept_hook,
    )
    if not ok:
        return
    student_profile.update(new_settings)


action_profile = Action(
    text="Profile",
    icon="ei.adult",
    on_triggered=on_action_schema_object_editor,
    shortcut="Ctrl+O",
)

toolbar = ToolBar(actions=[action_profile], moveable=False, floatable=False)


def foo():
    uprint("current profile:")
    uprint(student_profile)


if __name__ == "__main__":
    adapter = GUIAdapter()
    adapter.add(foo, window_toolbar=toolbar)
    adapter.run()

(二)show_schema_object_panel()

函数show_schema_object_panel()用于调出一个Panel风格的结构化对象编辑器:

其函数签名如下:

show_schema_object_panel(parent: Optional[QWidget], schema: Dict[str, ValueType], obj: Dict[str, Any], config: SchemaObjectPanelConfig = SchemaObjectPanelConfig(), *, copy: bool = True, normalize_object: bool = True, validate: bool = True, accept_hook: Optional[Callable[[ObjectEditor, Dict[str, Any]], bool]] = None, reject_hook: Optional[Callable[[ObjectEditor], bool]] = None) -> Tuple[Dict[str, Any], bool]

弹出一个结构化对象编辑面板

Parameters:

Name Type Description Default
parent Optional[QWidget]

父窗口

required
schema Dict[str, ValueType]

对象的Schema

required
obj Dict[str, Any]

要编辑的对象

required
config SchemaObjectPanelConfig

编辑器配置

SchemaObjectPanelConfig()
copy bool

是否复制对象

True
normalize_object bool

是否规范化对象,规范化对象是指:删除obj中不在schema未定义的键,并且使用默认值填充缺失的键

True
validate bool

是否验证对象,即检查obj是否符合schema定义的格式,如果不符合则引发异常

True
accept_hook Optional[Callable[[ObjectEditor, Dict[str, Any]], bool]]

点击编辑器Ok按钮后执行的钩子函数,函数签名为(editor: ObjectEditor, obj: Dict[str, Any]) -> bool,返回True则关闭编辑器,返回False则不关闭编辑器。

None
reject_hook Optional[Callable[[ObjectEditor], bool]]

点击编辑器Cancel按钮或关闭编辑器后执行的钩子函数,函数签名为(editor: ObjectEditor) -> bool,返回True则关闭编辑器,返回False则不关闭编辑器。

None

Returns:

Type Description
Tuple[Dict[str, Any], bool]

返回一个元组,该元组的第一个元素是编辑后的对象,第二个元素编辑器是否accepted(一般是点击Ok按钮)。

下面是一个相对完整的示例,展示了如何使用show_schema_object_panel()来编辑一个结构化对象:

from typing import Any, Dict

from qtpy.QtWidgets import QWidget

from pyguiadapter.action import Action
from pyguiadapter.adapter import GUIAdapter
from pyguiadapter.adapter.uoutput import uprint
from pyguiadapter.itemseditor.valuetypes import (
    StringValue,
    IntValue,
    ChoiceValue,
    PasswordEchoOnEditMode,
    FloatValue,
    BoolValue,
    FileValue,
)
from pyguiadapter.toolbar import ToolBar
from pyguiadapter.utils import (
    messagebox,
    show_schema_object_panel,
    SchemaObjectPanelConfig,
)
from pyguiadapter.windows.fnexec import FnExecuteWindow

student_profile_schema = {
    "name": StringValue(default_value="Undefined", display_name="Name"),
    "age": IntValue(default_value=16, display_name="Age", min_value=16, max_value=20),
    "grade": ChoiceValue(
        default_value="A",
        choices=["A", "B", "C", "D", "E"],
    ),
    "password": StringValue(
        default_value="Admin123",
        display_name="ID Password",
        echo_mode=PasswordEchoOnEditMode,
        clear_button=True,
        password_symbol="*",
        max_password_symbols=10,
    ),
    "scores": FloatValue(
        default_value=0.0,
        display_name="Average Scores",
        min_value=0.0,
        max_value=100.0,
        step=0.5,
        suffix=" (avg)",
        display_affix=True,
    ),
    "is_active": BoolValue(
        default_value=True, display_name="Active", true_text="Yes", false_text="No"
    ),
    "photo": FileValue(
        default_value="Not Provided",
        display_name="Photo",
        title="Select a photo",
        file_filters="JPG Files (*.jpg);;PNG Files (*.png);;All Files (*.*)",
    ),
}

student_profile = {
    "name": "John Doe",
    "age": 18,
    "grade": "B",
    "password": "MyPassword123",
    "scores": 99.5,
    "is_active": True,
    "photo": "/path/to/john_doe.jpg",
}


def _accept_hook(editor: QWidget, obj: Dict[str, Any]) -> bool:
    password = obj.get("password")
    if len(password) < 6:
        messagebox.show_warning_message(
            editor,
            message="Password should be at least 6 characters long.",
            title="Warning",
        )
        return False
    return True


def on_action_schema_object_panel(window: FnExecuteWindow, action: Action):
    _ = action  # unused
    new_settings, ok = show_schema_object_panel(
        window,
        schema=student_profile_schema,
        obj=student_profile,
        config=SchemaObjectPanelConfig(
            title="Student Profile",
            center_container_title="Personal Information",
            icon="ei.adult",
        ),
        accept_hook=_accept_hook,
    )
    if not ok:
        return
    student_profile.update(new_settings)


action_profile = Action(
    text="Profile",
    icon="ei.adult",
    on_triggered=on_action_schema_object_panel,
    shortcut="Ctrl+O",
)

toolbar = ToolBar(actions=[action_profile], moveable=False, floatable=False)


def foo():
    uprint("current profile:")
    uprint(student_profile)


if __name__ == "__main__":
    adapter = GUIAdapter()
    adapter.add(foo, window_toolbar=toolbar)
    adapter.run()

三、示例:使用结构化对象编辑器实现配置界面

有些开发者可能已经想到,使用结构化对象和结构化对象编辑器,可以很容易实现一个设置界面,下面是一个简单的示例:

from typing import Any, Dict

from qtpy.QtWidgets import QWidget

from pyguiadapter.action import Action, Separator
from pyguiadapter.adapter import GUIAdapter
from pyguiadapter.adapter.uoutput import uprint
from pyguiadapter.itemseditor.valuetypes import (
    StringValue,
    PasswordEchoOnEditMode,
    BoolValue,
    ChoiceValue,
)
from pyguiadapter.menu import Menu
from pyguiadapter.utils import (
    messagebox,
    show_schema_object_editor,
    show_schema_object_panel,
    SchemaObjectEditorConfig,
    SchemaObjectPanelConfig,
)
from pyguiadapter.windows.fnselect import FnSelectWindow

setting_schema = {
    "username": StringValue(
        default_value="admin", display_name="Username", placeholder="Enter Username"
    ),
    "password": StringValue(
        default_value="123456",
        display_name="Password",
        placeholder="Enter Password",
        echo_mode=PasswordEchoOnEditMode,
        clear_button=True,
    ),
    "remember_me": BoolValue(
        default_value=True, display_name="Remember Me", true_text="Yes", false_text="No"
    ),
    "language": ChoiceValue(
        default_value=0,
        choices=["English", "Chinese", "Japanese", "Korean", "French"],
        display_name="Language",
    ),
    "theme": ChoiceValue(
        default_value="light",
        choices=["light", "dark", "system"],
        display_name="Theme",
    ),
    "port": StringValue(
        default_value="8080",
        display_name="Port",
        validator="^[0-9]+$",
        max_length=5,
        placeholder="Enter Port",
    ),
    "host": StringValue(
        default_value="localhost",
        display_name="Host",
        placeholder="Enter Host",
        max_length=100,
    ),
}

settings = {
    "username": "admin",
    "password": "123456",
    "remember_me": True,
    "language": 0,
    "theme": "light",
    "port": "8080",
    "host": "localhost",
}


def _accept_hook(editor: QWidget, obj: Dict[str, Any]) -> bool:
    password = obj.get("password")
    if len(password) < 6:
        messagebox.show_warning_message(
            editor,
            message="Password should be at least 6 characters long.",
            title="Warning",
        )
        return False
    return True


def on_action_settings(window: FnSelectWindow, action: Action):
    _ = action  # unused
    new_settings, ok = show_schema_object_editor(
        window,
        schema=setting_schema,
        obj=settings,
        config=SchemaObjectEditorConfig(
            title="Settings",
            center_container_title="configurations",
            icon="ri.settings-5-fill",
        ),
        accept_hook=_accept_hook,
    )
    if not ok:
        return
    settings.update(new_settings)


def on_action_settings_panel(window: FnSelectWindow, action: Action):
    _ = action  # unused
    new_settings, ok = show_schema_object_panel(
        window,
        schema=setting_schema,
        obj=settings,
        config=SchemaObjectPanelConfig(
            title="Settings",
            center_container_title="configurations",
            icon="ri.settings-5-fill",
        ),
        accept_hook=_accept_hook,
    )
    if not ok:
        return
    settings.update(new_settings)


def on_action_close(window: FnSelectWindow, _: Action):
    ret = messagebox.show_question_message(
        window,
        message="Are you sure to close the application?",
        buttons=messagebox.Yes | messagebox.No,
    )
    if ret == messagebox.Yes:
        window.close()


action_settings = Action(
    text="Settings",
    icon="msc.settings-gear",
    on_triggered=on_action_settings,
    shortcut="Ctrl+O",
)
action_settings_panel = Action(
    text="Settings Panel",
    icon="msc.settings-gear",
    on_triggered=on_action_settings_panel,
    shortcut="Ctrl+P",
)
action_close = Action(
    text="Close", icon="fa.close", on_triggered=on_action_close, shortcut="Ctrl+Q"
)


menu_file = Menu(
    title="File",
    actions=[action_settings, action_settings_panel, Separator(), action_close],
)


def foo():
    uprint("current settings:")
    uprint(settings)


if __name__ == "__main__":
    adapter = GUIAdapter()
    adapter.add(foo)
    adapter.run(show_select_window=True, select_window_menus=[menu_file])

效果如下: