瀏覽代碼

feat: use Scope system instead of Anchor

wangweimin 5 年之前
父節點
當前提交
3a8907b751

+ 2 - 2
demos/input_usage.py

@@ -28,9 +28,9 @@ def main():
     name = input("What's your name?")
     ```
     """, lstrip=True)
-    put_text("这样一行代码的效果如下:", anchor='input-1')
+    put_text("这样一行代码的效果如下:",)
     name = input("What's your name?")
-    put_markdown("`name = %r`" % name, anchor='input-1')
+    put_markdown("`name = %r`" % name)
 
     # 其他类型的输入
     put_markdown("""PyWebIO的输入函数是同步的,在表单被提交之前,输入函数不会返回。

+ 6 - 30
demos/output_usage.py

@@ -76,8 +76,9 @@ def main():
 
     from functools import partial
 
+    @use_scope('table-callback')
     def edit_row(choice, row):
-        put_markdown("> You click `%s` button ar row `%s`" % (choice, row), anchor='table-callback')
+        put_markdown("> You click `%s` button ar row `%s`" % (choice, row))
 
     put_table([
         ['Idx', 'Actions'],
@@ -85,7 +86,7 @@ def main():
         [2, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=2))],
         [3, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=3))],
     ])
-    set_anchor('table-callback')
+    set_scope('table-callback')
 
     put_markdown(r"""当然,PyWebIO还支持单独的按钮控件:
     ```python
@@ -96,37 +97,12 @@ def main():
     ```
     """, strip_indent=4)
 
+    @use_scope('button-callback')
     def btn_click(btn_val):
-        put_markdown("> You click `%s` button" % btn_val, anchor='button-callback')
+        put_markdown("> You click `%s` button" % btn_val)
 
     put_buttons(['A', 'B', 'C'], onclick=btn_click)
-    set_anchor('button-callback')
-
-    put_markdown(r"""### 锚点
-    就像在控制台输出文本一样,PyWebIO默认在页面的末尾输出各种内容,你可以使用锚点来改变这一行为。
-
-    你可以调用 `set_anchor(name)` 对当前输出位置进行标记。
-    
-    你可以在任何输出函数中使用 `before` 参数将内容插入到指定的锚点之前,也可以使用 `after` 参数将内容插入到指定的锚点之后。
-    
-    在输出函数中使用 `anchor` 参数为当前的输出内容标记锚点,若锚点已经存在,则将锚点处的内容替换为当前内容。
-    
-    以下代码展示了在输出函数中使用锚点:
-    ```python
-    set_anchor('top')
-    put_text('A')
-    put_text('B', anchor='b')
-    put_text('C', after='top')
-    put_text('D', before='b')
-    ```
-    以上代码将输出:
-    
-        C
-        A
-        D
-        B
-
-    """, strip_indent=4)
+    set_scope('button-callback')
 
     put_markdown(r"""### 页面环境设置
     #### 输出区外观

+ 69 - 21
docs/guide.rst

@@ -195,37 +195,85 @@ PyWebIO把程序与用户的交互分成了输入和输出两部分:输入函
 .. note::
    在PyWebIO会话(关于会话的概念见下文 :ref:`Server and script mode <server_and_script_mode>` )结束后,事件回调也将不起作用,你可以在任务函数末尾处使用 :func:`pywebio.session.hold()` 函数来将会话保持,这样在用户关闭浏览器前,事件回调将一直可用。
 
-锚点
+输出域Scope
 ^^^^^^^^^^^^^^
-就像在控制台输出文本一样,PyWebIO默认在页面的末尾输出各种内容,你可以使用锚点来改变这一行为。
+PyWebIO使用Scope模型来对内容输出的位置进行控制,PyWebIO的内容输出区可以划分出不同的输出域,PyWebIO将输出域称作 `Scope` 。
+Scope为一个矩形容器,宽度和内容输出区宽度一致,高度正好可以容纳其中的内容。
+和代码的作用域类似,Scope可以嵌套,可以进入进出。
+每个输出函数(函数名形如 `put_xxx()` )都会将内容输出到一个Scope,默认为"当前Scope","当前Scope"由代码运行上下文确定,输出函数也可以手动指定输出到的Scope。
+输出函数默认将内容输出到Scope的末尾,也同样支持将内容输出到Scope的其他位置(比如顶部或某个元素之后)。
 
-你可以调用 `set_anchor(name) <pywebio.output.set_anchor>` 对当前输出位置进行标记。
+**use_scope()**
 
-你可以在任何输出函数中使用 ``before`` 参数将内容插入到指定的锚点之前,也可以使用 ``after`` 参数将内容插入到指定的锚点之后。
+PyWebIO的顶层Scope为 `ROOT`,
+可以使用 `use_scope() <pywebio.output.use_scope>` 设定上下文内的"当前Scope",use_scope会在指定的scope不存在时创建scope::
 
-在输出函数中使用 ``anchor`` 参数为当前的输出内容标记锚点,若锚点已经存在,则将锚点处的内容替换为当前内容。
+    with use_scope('scope1'):
+        put_text('text1 in scope1')
 
-以下代码展示了在输出函数中使用锚点::
+    put_text('text in parent scope of scope1')
 
-    set_anchor('top')
-    put_text('A')
-    put_text('B', anchor='b')
-    put_text('C', after='top')
-    put_text('D', before='b')
+    with use_scope('scope1'):
+        put_text('text2 in scope1')
 
-以上代码将输出::
+以上代码将会输出::
 
-    C
-    A
-    D
-    B
+    text1 in scope1
+    text2 in scope1
+    text in parent scope of scope1
 
-PyWebIO还提供了以下锚点控制函数:
+`use_scope() <pywebio.output.use_scope>` 还可以使用 `clear` 参数来在输出前先将scope内容清空::
 
-* `set_anchor(anchor) <pywebio.output.set_anchor>` 可以清除 ``anchor`` 锚点之前输出的内容
-* `clear_after(anchor) <pywebio.output.clear_after>` 可以清除 ``anchor`` 锚点之后输出的内容
-* `clear_range(start_anchor, end_anchor) <pywebio.output.clear_range>` 可以清除 ``start_anchor`` 到 ``end_anchor`` 锚点之间的内容
-* `scroll_to(anchor) <pywebio.output.scroll_to>`  可以将页面滚动到 ``anchor`` 锚点处
+    with use_scope('scope1', clear=True):
+        put_text('text1 in scope1')
+
+    put_text('text in parent scope of scope1')
+
+    with use_scope('scope1', clear=True):
+        put_text('text2 in scope1')
+
+以上代码将会输出::
+
+    text2 in scope1
+    text in parent scope of scope1
+
+`use_scope() <pywebio.output.use_scope>` 还可以作为装饰器来使用::
+
+    from datetime import datetime
+    @use_scope('time', clear=True)
+    def show_time():
+        put_text(datetime.now())
+
+.. _scope_param:
+
+**输出函数**
+
+输出函数(函数名形如 `put_xxx()` )在没有任何设置的情况下,会将内容输出到"当前Scope","当前Scope"可以通过use_scope()设置。
+
+此外,输出函数也可以通过 `scope` 参数指定目的Scope::
+
+    with use_scope('scope1', clear=True):
+        put_text('text2 in scope1')   # 内容输出目的Scope:scope1
+        put_text('text in ROOT scope', scope='ROOT')   # 内容输出目的Scope:ROOT
+
+`scope` 参数除了直接指定目标Scope名,还可以使用int通过索引Scope栈来确定Scope:0表示最顶层也就是ROOT Scope,-1表示当前Scope,-2表示当前Scope的父Scope,...
+
+内容默认输出到目标Scope的底部,输出函数的 `position` 参数可以指定输出到scope中的位置,接收int类型,position为非负数时表示输出到scope的第position个(从0计数)子元素的前面;position为负数时表示输出到scope的倒数第position个(从-1计数)元素之后::
+
+    with use_scope('scope1'):
+        put_text('A')               # 输出内容: A
+        put_text('B', position=0)   # 输出内容: B A
+        put_text('C', position=-2)  # 输出内容: B C A
+        put_text('D', position=1)   # 输出内容: B D C A
+
+**Scope控制函数**
+
+除了use_scope(), PyWebIO同样提供了以下scope控制函数:
+
+* `set_scope() <pywebio.output.set_scope>` : 在当前位置(或指定位置)创建scope
+* `clear(scope) <pywebio.output.clear>` : 清除scope的内容
+* `remove(scope) <pywebio.output.remove>` : 移除scope
+* `scroll_to(scope) <pywebio.output.scroll_to>` : 将页面滚动到scope处
 
 
 页面环境设置

+ 14 - 5
docs/spec.rst

@@ -169,9 +169,8 @@ output:
 命令 spec 字段:
 
 * type
-* before
-* after
-* anchor
+* scope
+* position
 * 不同type时的特有字段
 
 不同 ``type`` 时的特有字段:
@@ -230,13 +229,23 @@ output_ctl:
 * title: 设定标题
 * output_fixed_height: 设置是否输出区固定高度
 * auto_scroll_bottom: 设置有新内容时是否自动滚动到底部
-* set_anchor
+* set_scope: 创建scope
+
+    * container: 新创建的scope的父scope
+    * position: 在父scope中创建此scope的位置. int, position>=0表示在父scope的第position个(从0计数)子元素的前面创建;position<0表示在父scope的倒数第position个(从-1计数)元素之后创建新scope
+    * if_exist: scope已经存在时如何操作:
+
+        - `'none'` 表示不进行任何操作
+        - `'remove'` 表示先移除旧scope再创建新scope
+        - `'clear'` 表示将旧scope的内容清除,不创建新scope
+
+* clear: 清空scope的内容
 * clear_before
 * clear_after
 * clear_range:[,]
 * scroll_to:
 * position: top/middle/bottom 与scroll_to一起出现, 表示滚动页面,让锚点位于屏幕可视区域顶部/中部/底部
-* remove: 将给定的锚点连同锚点处的内容移除
+* remove: 将给定的scope连同scope处的内容移除
 
 Event
 ------------

+ 1 - 1
pywebio/html/index.html

@@ -22,7 +22,7 @@
         <div class="body">
             <div class="viewer">
                 <div class="markdown-body" id="markdown-body">
-
+                    <div id="pywebio-scope-ROOT"></div>
                 </div>
             </div>
         </div>

文件差異過大導致無法顯示
+ 0 - 0
pywebio/html/js/pywebio.min.js


文件差異過大導致無法顯示
+ 0 - 0
pywebio/html/js/pywebio.min.js.map


+ 161 - 94
pywebio/output.py

@@ -5,15 +5,15 @@ r"""输出内容到用户浏览器
 输出控制
 --------------
 
-锚点
+输出域Scope
 ^^^^^^^^^^^^^^^^^
 
-.. autofunction:: set_anchor
-.. autofunction:: clear_before
-.. autofunction:: clear_after
-.. autofunction:: clear_range
+.. autofunction:: set_scope
+.. autofunction:: clear
 .. autofunction:: remove
 .. autofunction:: scroll_to
+.. autofunction:: use_scope
+
 
 环境设置
 ^^^^^^^^^^^^^^^^^
@@ -38,8 +38,11 @@ import io
 import logging
 from base64 import b64encode
 from collections.abc import Mapping
+from functools import wraps
 
 from .io_ctrl import output_register_callback, send_msg, OutputReturn, safely_destruct_output_when_exp
+from .session import get_current_session
+from .utils import random_str
 
 try:
     from PIL.Image import Image as PILImage
@@ -48,8 +51,8 @@ except ImportError:
 
 logger = logging.getLogger(__name__)
 
-__all__ = ['Position', 'set_title', 'set_output_fixed_height', 'set_auto_scroll_bottom', 'set_anchor', 'clear_before',
-           'clear_after', 'clear_range', 'remove', 'scroll_to', 'put_text', 'put_html', 'put_code', 'put_markdown',
+__all__ = ['Position', 'set_title', 'set_output_fixed_height', 'set_auto_scroll_bottom', 'remove', 'scroll_to',
+           'put_text', 'put_html', 'put_code', 'put_markdown', 'use_scope', 'set_scope', 'clear', 'remove',
            'put_table', 'table_cell_buttons', 'put_buttons', 'put_image', 'put_file', 'PopupSize', 'popup',
            'close_popup']
 
@@ -67,6 +70,18 @@ class Position:
     BOTTOM = 'bottom'
 
 
+# put_xxx()中的position值
+class OutputPosition:
+    TOP = 0
+    BOTTOM = -1
+
+
+class Scope:
+    Current = -1
+    Root = 0
+    Parent = -2
+
+
 def set_title(title):
     r"""设置页面标题"""
     send_msg('output_ctl', dict(title=title))
@@ -82,150 +97,146 @@ def set_auto_scroll_bottom(enabled=True):
     send_msg('output_ctl', dict(auto_scroll_bottom=enabled))
 
 
-def _get_anchor_id(name):
-    """获取实际用于前端html页面中的id属性"""
-    name = name.replace(' ', '-')
-    return 'pywebio-anchor-%s' % name
-
+def _parse_scope(name):
+    """获取实际用于前端html页面中的id属性
 
-def set_anchor(name):
+    :param str name:
     """
-    在当前输出处标记锚点。 若已经存在 ``name`` 锚点,则先将旧锚点删除
-    """
-    inner_ancher_name = _get_anchor_id(name)
-    send_msg('output_ctl', dict(set_anchor=inner_ancher_name))
+    name = name.replace(' ', '-')
+    return 'pywebio-scope-%s' % name
 
 
-def clear_before(anchor):
-    """清除 ``anchor`` 锚点之前输出的内容。
-    ⚠️注意: 位于 ``anchor`` 锚点之前设置的锚点也会被清除
-    """
-    inner_ancher_name = _get_anchor_id(anchor)
-    send_msg('output_ctl', dict(clear_before=inner_ancher_name))
+def set_scope(name, container_scope=Scope.Current, position=OutputPosition.BOTTOM, if_exist='none'):
+    """创建一个新的scope.
+
+    :param str name: scope名
+    :param int/str container_scope: 此scope的父scope. 可以直接指定父scope名或使用 `Scope` 常量. scope不存在时,不进行任何操作.
+    :param int position: 在父scope中创建此scope的位置.
+       `OutputPosition.TOP` : 在父scope的顶部创建, `OutputPosition.BOTTOM` : 在父scope的尾部创建
+    :param str if_exist: 已经存在 ``name`` scope 时如何操作:
 
+        - `'none'` 表示不进行任何操作
+        - `'remove'` 表示先移除旧scope再创建新scope
+        - `'clear'` 表示将旧scope的内容清除,不创建新scope
 
-def clear_after(anchor):
-    """清除 ``anchor`` 锚点之后输出的内容。
-    ⚠️注意: 位于 ``anchor`` 锚点之后设置的锚点也会被清除
+       默认为 `'none'`
     """
-    inner_ancher_name = _get_anchor_id(anchor)
-    send_msg('output_ctl', dict(clear_after=inner_ancher_name))
+    if isinstance(container_scope, int):
+        container_scope = get_current_session().get_scope_name(container_scope)
 
+    send_msg('output_ctl', dict(set_scope=_parse_scope(name),
+                                container=_parse_scope(container_scope),
+                                position=position, if_exist=if_exist))
 
-def clear_range(start_anchor, end_anchor):
-    """
-    清除 ``start_anchor`` - ``end_ancher`` 锚点之间输出的内容.
-    若 ``start_anchor`` 或 ``end_ancher`` 不存在,则不进行任何操作。
 
-    ⚠️注意: 在 ``start_anchor`` - ``end_ancher`` 之间设置的锚点也会被清除
+def clear(scope=Scope.Current):
+    """清空scope内容
+
+    :param int/str scope: 可以直接指定scope名或使用 `Scope` 常量
     """
-    inner_start_anchor_name = 'pywebio-anchor-%s' % start_anchor
-    inner_end_ancher_name = 'pywebio-anchor-%s' % end_anchor
-    send_msg('output_ctl', dict(clear_range=[inner_start_anchor_name, inner_end_ancher_name]))
+    scope_name = _parse_scope(scope)
+    send_msg('output_ctl', dict(clear=scope_name))
 
 
-def remove(anchor):
-    """将 ``anchor`` 锚点连同锚点处的内容移除"""
-    inner_ancher_name = _get_anchor_id(anchor)
-    send_msg('output_ctl', dict(remove=inner_ancher_name))
+def remove(scope):
+    """移除Scope"""
+    send_msg('output_ctl', dict(remove=_parse_scope(scope)))
 
 
-def scroll_to(anchor, position=Position.TOP):
-    """scroll_to(anchor, position=Position.TOP)
+def scroll_to(scope, position=Position.TOP):
+    """scroll_to(scope, position=Position.TOP)
 
-    将页面滚动到 ``anchor`` 锚点
+    将页面滚动到 ``scope`` Scope
 
-    :param str anchor: 锚点
-    :param str position: 将锚点置于屏幕可视区域的位置。可用值:
+    :param str scope: Scope
+    :param str position: 将Scope置于屏幕可视区域的位置。可用值:
 
-       * ``Position.TOP`` : 滚动页面,让锚点位于屏幕可视区域顶部
-       * ``Position.MIDDLE`` : 滚动页面,让锚点位于屏幕可视区域中间
-       * ``Position.BOTTOM`` : 滚动页面,让锚点位于屏幕可视区域底部
+       * ``Position.TOP`` : 滚动页面,让Scope位于屏幕可视区域顶部
+       * ``Position.MIDDLE`` : 滚动页面,让Scope位于屏幕可视区域中间
+       * ``Position.BOTTOM`` : 滚动页面,让Scope位于屏幕可视区域底部
     """
-    inner_ancher_name = 'pywebio-anchor-%s' % anchor
-    send_msg('output_ctl', dict(scroll_to=inner_ancher_name, position=position))
+    send_msg('output_ctl', dict(scroll_to=_parse_scope(scope), position=position))
 
 
-def _get_output_spec(type, anchor=None, before=None, after=None, **other_spec):
+def _get_output_spec(type, scope, position, **other_spec):
     """
     获取 ``output`` 指令的spec字段
 
     :param str type: 输出类型
-    :param str anchor: 为当前的输出内容标记锚点,若锚点已经存在,则将锚点处的内容替换为当前内容。
-    :param str before: 在给定的锚点之前输出内容。若给定的锚点不存在,则不输出任何内容
-    :param str after: 在给定的锚点之后输出内容。若给定的锚点不存在,则不输出任何内容。
-        注意: ``before`` 和 ``after`` 参数不可以同时使用
+    :param int/str scope: 输出到的scope
+    :param int position: 在scope输出的位置, `OutputPosition.TOP` : 输出到scope的顶部, `OutputPosition.BOTTOM` : 输出到scope的尾部
     :param other_spec: 额外的输出参数,值为None的参数不会包含到返回值中
 
     :return dict:  ``output`` 指令的spec字段
     """
-    assert not (before and after), "Parameter 'before' and 'after' cannot be specified at the same time"
-
     spec = dict(type=type)
     spec.update({k: v for k, v in other_spec.items() if v is not None})
-    if anchor:
-        spec['anchor'] = _get_anchor_id(anchor)
-    if before:
-        spec['before'] = _get_anchor_id(before)
-    elif after:
-        spec['after'] = _get_anchor_id(after)
+
+    if isinstance(scope, int):
+        scope_name = get_current_session().get_scope_name(scope)
+    else:
+        scope_name = scope
+
+    spec['scope'] = _parse_scope(scope_name)
+    spec['position'] = position
 
     return spec
 
 
-def put_text(text, inline=False, anchor=None, before=None, after=None) -> OutputReturn:
+def put_text(text, inline=False, scope=Scope.Current, position=OutputPosition.BOTTOM) -> OutputReturn:
     """
     输出文本内容
 
-    :param str text: 文本内容
+    :param any text: 文本内容
     :param bool inline: 文本行末不换行。默认换行
-    :param str anchor: 为当前的输出内容标记锚点,若锚点已经存在,则将锚点处的内容替换为当前内容。
-    :param str before: 在给定的锚点之前输出内容。若给定的锚点不存在,则不输出任何内容
-    :param str after: 在给定的锚点之后输出内容。若给定的锚点不存在,则不输出任何内容。
+    :param int/str scope: 内容输出的目标scope, 若scope不存在,则不进行任何输出操作。
+       `scope` 可以直接指定目标Scope名,或者使用int通过索引Scope栈来确定Scope:0表示最顶层也就是ROOT Scope,-1表示当前Scope,-2表示当前Scope的父Scope,...
+    :param int position: 在scope中输出的位置。
+       position为非负数时表示输出到scope的第position个(从0计数)子元素的前面;position为负数时表示输出到scope的倒数第position个(从-1计数)元素之后。
 
-    注意: ``before`` 和 ``after`` 参数不可以同时使用。
-    当 ``anchor`` 指定的锚点已经在页面上存在时,``before`` 和 ``after`` 参数将被忽略。
+    参数 `scope` 和 `position` 的更多使用说明参见 :ref:`用户手册 <scope_param>`
     """
-    spec = _get_output_spec('text', content=str(text), inline=inline, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('text', content=str(text), inline=inline, scope=scope, position=position)
     return OutputReturn(spec)
 
 
-def put_html(html, anchor=None, before=None, after=None) -> OutputReturn:
+def put_html(html, scope=Scope.Current, position=OutputPosition.BOTTOM) -> OutputReturn:
     """
     输出Html内容。
 
     与支持通过Html输出内容到 `Jupyter Notebook <https://nbviewer.jupyter.org/github/ipython/ipython/blob/master/examples/IPython%20Kernel/Rich%20Output.ipynb#HTML>`_ 的库兼容。
 
     :param html: html字符串或 实现了 `IPython.display.HTML` 接口的类的实例
-    :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
+    :param int scope, position: 与 `put_text` 函数的同名参数含义一致
     """
     if hasattr(html, '__html__'):
         html = html.__html__()
 
-    spec = _get_output_spec('html', content=html, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('html', content=html, scope=scope, position=position)
     return OutputReturn(spec)
 
 
-def put_code(content, langage='', anchor=None, before=None, after=None) -> OutputReturn:
+def put_code(content, langage='', scope=Scope.Current, position=OutputPosition.BOTTOM) -> OutputReturn:
     """
     输出代码块
 
     :param str content: 代码内容
     :param str langage: 代码语言
-    :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
+    :param int scope, position: 与 `put_text` 函数的同名参数含义一致
     """
     code = "```%s\n%s\n```" % (langage, content)
-    return put_markdown(code, anchor=anchor, before=before, after=after)
+    return put_markdown(code, scope=scope, position=position)
 
 
-def put_markdown(mdcontent, strip_indent=0, lstrip=False, anchor=None, before=None, after=None) -> OutputReturn:
+def put_markdown(mdcontent, strip_indent=0, lstrip=False, scope=Scope.Current,
+                 position=OutputPosition.BOTTOM) -> OutputReturn:
     """
     输出Markdown内容。
 
     :param str mdcontent: Markdown文本
     :param int strip_indent: 对于每一行,若前 ``strip_indent`` 个字符都为空格,则将其去除
     :param bool lstrip: 是否去除每一行开始的空白符
-    :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
+    :param int scope, position: 与 `put_text` 函数的同名参数含义一致
 
     当在函数中使用Python的三引号语法输出多行内容时,为了排版美观可能会对Markdown文本进行缩进,
     这时候,可以设置 ``strip_indent`` 或 ``lstrip`` 来防止Markdown错误解析(但不要同时使用 ``strip_indent`` 和 ``lstrip`` )::
@@ -258,12 +269,12 @@ def put_markdown(mdcontent, strip_indent=0, lstrip=False, anchor=None, before=No
         lines = (i.lstrip() for i in mdcontent.splitlines())
         mdcontent = '\n'.join(lines)
 
-    spec = _get_output_spec('markdown', content=mdcontent, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('markdown', content=mdcontent, scope=scope, position=position)
     return OutputReturn(spec)
 
 
 @safely_destruct_output_when_exp('tdata')
-def put_table(tdata, header=None, span=None, anchor=None, before=None, after=None) -> OutputReturn:
+def put_table(tdata, header=None, span=None, scope=Scope.Current, position=OutputPosition.BOTTOM) -> OutputReturn:
     """
     输出表格
 
@@ -276,7 +287,7 @@ def put_table(tdata, header=None, span=None, anchor=None, before=None, after=Non
 
     :param dict span: 表格的跨行/跨列信息,格式为 ``{ (行id,列id):{"col": 跨列数, "row": 跨行数} }``
        其中 ``行id`` 和 ``列id`` 为将表格转为二维数组后的需要跨行/列的单元格,二维数据包含表头,``id`` 从 0 开始记数。
-    :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
+    :param int scope, position: 与 `put_text` 函数的同名参数含义一致
 
     使用示例::
 
@@ -335,7 +346,7 @@ def put_table(tdata, header=None, span=None, anchor=None, before=None, after=Non
     span = span or {}
     span = {('%s,%s' % row_col): val for row_col, val in span.items()}
 
-    spec = _get_output_spec('table', data=tdata, span=span, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('table', data=tdata, span=span, scope=scope, position=position)
     return OutputReturn(spec)
 
 
@@ -398,7 +409,7 @@ def table_cell_buttons(buttons, onclick, **callback_options) -> str:
     return ' '.join(btns_html)
 
 
-def put_buttons(buttons, onclick, small=None, anchor=None, before=None, after=None,
+def put_buttons(buttons, onclick, small=None, scope=Scope.Current, position=OutputPosition.BOTTOM,
                 **callback_options) -> OutputReturn:
     """
     输出一组按钮
@@ -415,7 +426,7 @@ def put_buttons(buttons, onclick, small=None, anchor=None, before=None, after=No
        当按钮组中的按钮被点击时,``onclick`` 被调用,并传入被点击的按钮的 ``value`` 值。
        可以使用 ``functools.partial`` 来在 ``onclick`` 中保存更多上下文信息 。
     :param bool small: 是否显示小号按钮,默认为False
-    :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
+    :param int scope, position: 与 `put_text` 函数的同名参数含义一致
     :param callback_options: 回调函数的其他参数。根据选用的 session 实现有不同参数
 
        CoroutineBasedSession 实现
@@ -438,14 +449,14 @@ def put_buttons(buttons, onclick, small=None, anchor=None, before=None, after=No
     """
     btns = _format_button(buttons)
     callback_id = output_register_callback(onclick, **callback_options)
-    spec = _get_output_spec('buttons', callback_id=callback_id, buttons=btns, small=small, anchor=anchor, before=before,
-                            after=after)
+    spec = _get_output_spec('buttons', callback_id=callback_id, buttons=btns, small=small,
+                            scope=scope, position=position)
 
     return OutputReturn(spec)
 
 
-def put_image(content, format=None, title='', width=None, height=None, anchor=None, before=None,
-              after=None) -> OutputReturn:
+def put_image(content, format=None, title='', width=None, height=None,
+              scope=Scope.Current, position=OutputPosition.BOTTOM) -> OutputReturn:
     """输出图片。
 
     :param content: 文件内容. 类型为 bytes-like object 或者为 ``PIL.Image.Image`` 实例
@@ -453,7 +464,7 @@ def put_image(content, format=None, title='', width=None, height=None, anchor=No
     :param str width: 图像的宽度,单位是CSS像素(数字px)或者百分比(数字%)。
     :param str height: 图像的高度,单位是CSS像素(数字px)或者百分比(数字%)。可以只指定 width 和 height 中的一个值,浏览器会根据原始图像进行缩放。
     :param str format: 图片格式。如 ``png`` , ``jpeg`` , ``gif`` 等
-    :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
+    :param int scope, position: 与 `put_text` 函数的同名参数含义一致
     """
     if isinstance(content, PILImage):
         format = content.format
@@ -470,19 +481,19 @@ def put_image(content, format=None, title='', width=None, height=None, anchor=No
     html = r'<img src="data:{format};base64, {b64content}" ' \
            r'alt="{title}" {width} {height}/>'.format(format=format, b64content=b64content,
                                                       title=title, height=height, width=width)
-    return put_html(html, anchor=anchor, before=before, after=after)
+    return put_html(html, scope=scope, position=position)
 
 
-def put_file(name, content, anchor=None, before=None, after=None) -> OutputReturn:
+def put_file(name, content, scope=Scope.Current, position=OutputPosition.BOTTOM) -> OutputReturn:
     """输出文件。
     在浏览器上的显示为一个以文件名为名的链接,点击链接后浏览器自动下载文件。
 
     :param str name: 文件名
     :param content: 文件内容. 类型为 bytes-like object
-    :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
+    :param int scope, position: 与 `put_text` 函数的同名参数含义一致
     """
     content = b64encode(content).decode('ascii')
-    spec = _get_output_spec('file', name=name, content=content, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('file', name=name, content=content, scope=scope, position=position)
     return OutputReturn(spec)
 
 
@@ -530,3 +541,59 @@ def popup(title, content, size=PopupSize.NORMAL, implicit_close=True, closable=T
 def close_popup():
     """关闭弹窗"""
     send_msg(cmd='close_popup')
+
+
+clear_scope = clear
+
+
+def use_scope(name=None, clear=False, create_scope=True, **scope_params):
+    """scope的上下文管理器和装饰器
+
+    :param name: scope名. 若为None则生成一个全局唯一的scope名
+    :param bool clear: 是否要清除scope内容
+    :param bool create_scope: scope不存在时是否创建scope
+    :param scope_params: 创建scope时传入set_scope()的参数. 仅在 `create_scope=True` 时有效.
+
+    :Usage:
+    ::
+
+        with use_scope(...):
+            put_xxx()
+
+        @use_scope(...)
+        def app():
+            put_xxx()
+    """
+    if name is None:
+        name = random_str(10)
+
+    class use_scope_:
+        def __enter__(self):
+            if create_scope:
+                set_scope(name, **scope_params)
+
+            if clear:
+                clear_scope(name)
+
+            get_current_session().push_scope(name)
+            return name
+
+        def __exit__(self, exc_type, exc_val, exc_tb):
+            """该方法如果返回True ,说明上下文管理器可以处理异常,使得 with 语句终止异常传播"""
+            get_current_session().pop_scope()
+            return False  # Propagate Exception
+
+        def __call__(self, func):
+            """装饰器"""
+
+            @wraps(func)
+            def inner(*args, **kwargs):
+                self.__enter__()
+                try:
+                    return func(*args, **kwargs)
+                finally:
+                    self.__exit__(None, None, None)
+
+            return inner
+
+    return use_scope_()

+ 2 - 1
pywebio/session/__init__.py

@@ -5,6 +5,7 @@ r"""
 .. autofunction:: register_thread
 .. autofunction:: defer_call
 .. autofunction:: hold
+.. autofunction:: data
 .. autofunction:: get_info
 
 .. autoclass:: pywebio.session.coroutinebased.TaskHandle
@@ -23,7 +24,7 @@ from ..utils import iscoroutinefunction, isgeneratorfunction, run_as_function, t
 # 当前进程中正在使用的会话实现的列表
 _active_session_cls = []
 
-__all__ = ['run_async', 'run_asyncio_coroutine', 'register_thread', 'hold', 'defer_call', 'get_info']
+__all__ = ['run_async', 'run_asyncio_coroutine', 'register_thread', 'hold', 'defer_call', 'data', 'get_info']
 
 
 def register_session_implement_for_target(target_func):

+ 33 - 3
pywebio/session/base.py

@@ -3,6 +3,7 @@ import logging
 import user_agents
 
 from ..utils import ObjectDict, Setter, catch_exp_call
+from collections import defaultdict
 
 logger = logging.getLogger(__name__)
 
@@ -15,12 +16,13 @@ class Session:
         info 表示会话信息的对象
         save 会话的数据对象,提供用户在对象上保存一些会话相关数据
 
-        _save 用于内部实现的一些状态保存
-
     由Task在当前Session上下文中调用:
         get_current_session
         get_current_task_id
 
+        get_scope_name
+        pop_scope
+        push_scope
         send_task_command
         next_client_event
         on_task_exception
@@ -55,11 +57,39 @@ class Session:
         """
         self.info = session_info
         self.save = Setter()
-        self._save = Setter()
+        self.scope_stack = defaultdict(lambda: ['ROOT'])  # task_id -> scope栈
 
         self.deferred_functions = []  # 会话结束时运行的函数
         self._closed = False
 
+    def get_scope_name(self, idx):
+        """获取当前任务的scope栈检索scope名
+
+        :param int idx: scope栈的索引
+        :return: scope名,不存在时返回 None
+        """
+        task_id = type(self).get_current_task_id()
+        try:
+            return self.scope_stack[task_id][idx]
+        except IndexError:
+            raise ValueError("Scope not found")
+
+    def pop_scope(self):
+        """弹出当前scope
+
+        :return: 当前scope名
+        """
+        task_id = type(self).get_current_task_id()
+        try:
+            return self.scope_stack[task_id].pop()
+        except IndexError:
+            raise ValueError("ROOT Scope can't pop")
+
+    def push_scope(self, name):
+        """进入新scope"""
+        task_id = type(self).get_current_task_id()
+        self.scope_stack[task_id].append(name)
+
     def send_task_command(self, command):
         raise NotImplementedError
 

+ 49 - 50
test/template.py

@@ -26,7 +26,7 @@ def get_visible_form(browser):
 
 
 def basic_output():
-    set_anchor('top')
+    set_scope('top')
 
     for i in range(3):
         put_text('text_%s' % i)
@@ -51,10 +51,10 @@ def basic_output():
 
     [链接](./#)
     ~~删除线~~
-    """, lstrip=True, anchor='put_markdown')
+    """, lstrip=True)
 
     put_text('<hr/>:')
-    put_html("<hr/>", anchor='put_html')
+    put_html("<hr/>")
 
     put_text('table:')
     put_table([
@@ -71,7 +71,7 @@ def basic_output():
     put_table([
         {"Course": "OS", "Score": "80"},
         {"Course": "DB", "Score": "93"},
-    ], header=["Course", "Score"], anchor='put_table')
+    ], header=["Course", "Score"])
 
     img_data = open(path.join(here_dir, 'assets', 'img.png'), 'rb').read()
     put_table([
@@ -89,14 +89,15 @@ def basic_output():
     ])
 
     put_text('code:')
-    put_code(json.dumps(dict(name='pywebio', author='wangweimin'), indent=4), 'json', anchor='scroll_basis')
+    put_code(json.dumps(dict(name='pywebio', author='wangweimin'), indent=4), 'json')
 
     put_text('move ⬆ code block to screen ... :')
-    put_buttons(buttons=[
-        ('BOTTOM', Position.BOTTOM),
-        ('TOP', Position.TOP),
-        ('MIDDLE', Position.MIDDLE),
-    ], onclick=lambda pos: scroll_to('scroll_basis', pos), anchor='scroll_basis_btns')
+    with use_scope('scroll_basis_btns'):
+        put_buttons(buttons=[
+            ('BOTTOM', Position.BOTTOM),
+            ('TOP', Position.TOP),
+            ('MIDDLE', Position.MIDDLE),
+        ], onclick=lambda pos: scroll_to('scroll_basis', pos))
 
     def show_popup():
         popup('Popup title', [
@@ -114,48 +115,44 @@ def basic_output():
             put_buttons(['close_popup()'], onclick=lambda _: close_popup())
         ], size=PopupSize.NORMAL)
 
-    put_buttons(['popup()'], onclick=lambda _: show_popup(), anchor='popup_btn')
+    with use_scope('popup_btn'):
+        put_buttons(['popup()'], onclick=lambda _: show_popup())
 
     def edit_row(choice, row):
-        put_text("You click %s button at row %s" % (choice, row), after='table_cell_buttons')
+        put_text("You click %s button at row %s" % (choice, row), scope='table_cell_buttons')
 
-    put_table([
-        ['Idx', 'Actions'],
-        ['1', put_buttons(['edit', 'delete'], onclick=partial(edit_row, row=1))],
-        ['2', put_buttons(['edit', 'delete'], onclick=partial(edit_row, row=2))],
-        ['3', put_buttons(['edit', 'delete'], onclick=partial(edit_row, row=3))],
-    ], anchor='table_cell_buttons')
-
-    put_buttons(['A', 'B', 'C'], onclick=partial(put_text, after='put_buttons'), anchor='put_buttons')
-
-    put_image(img_data, anchor='put_image1')
-    put_image(img_data, width="30px", anchor='put_image2')
-    put_image(img_data, height="50px", anchor='put_image3')
+    with use_scope('table_cell_buttons'):
+        put_table([
+            ['Idx', 'Actions'],
+            ['1', put_buttons(['edit', 'delete'], onclick=partial(edit_row, row=1))],
+            ['2', put_buttons(['edit', 'delete'], onclick=partial(edit_row, row=2))],
+            ['3', put_buttons(['edit', 'delete'], onclick=partial(edit_row, row=3))],
+        ])
 
-    put_file('hello_word.txt', b'hello word!', anchor='put_file')
+    with use_scope('put_buttons'):
+        put_buttons(['A', 'B', 'C'], onclick=partial(put_text, scope='put_buttons'))
 
-    put_markdown('### 锚点')
+    put_image(img_data)
+    put_image(img_data, width="30px")
+    put_image(img_data, height="50px")
 
-    put_text('anchor A1', anchor='A1')
-    put_text('new anchor A1', anchor='A1')
-    put_text('anchor A2', anchor='A2')
-    put_text('anchor A3', anchor='A3')
+    put_file('hello_word.txt', b'hello word!')
 
-    put_text('after=A1', after='A1')
-    put_text('after=A2', after='A2')
-    put_text('before=A1', before='A1')
-    put_text('before=A3', before='A3')
-    put_text('after=A3', after='A3')
+    put_markdown('### Scope')
 
-    clear_range('A1', "A2")
-    clear_range('A3', 'A2')
-    clear_after('A3')
+    with use_scope('scope1'):
+        put_text('A')  # 输出内容: A
+        put_text('B', position=0)  # 输出内容: B A
+        put_text('C', position=-2)  # 输出内容: B C A
+        with use_scope('scope2'):
+            put_text('scope2')
+            put_text('scope2')
+        put_text('D', position=1)  # 输出内容: B D C A
 
-    put_text('before=top', before='top')
-    clear_before('top')
-    put_text('before=top again', before='top')
+    put_text('before=top again', scope='top')
 
-    put_text('to remove', anchor='to_remove')
+    with use_scope('to_remove'):
+        put_text('to remove')
     remove('to_remove')
 
     session_info = get_info()
@@ -201,11 +198,12 @@ def basic_output():
 
 
 def background_output():
-    put_text("Background output", anchor='background')
+    put_text("Background output")
+    set_scope('background')
 
     def background():
         for i in range(20):
-            put_text('%s ' % i, inline=True, after='background')
+            put_text('%s ' % i, inline=True, scope='background')
 
     t = threading.Thread(target=background)
     register_thread(t)
@@ -213,11 +211,12 @@ def background_output():
 
 
 async def coro_background_output():
-    put_text("Background output", anchor='background')
+    put_text("Background output")
+    set_scope('background')
 
     async def background():
         for i in range(20):
-            put_text('%s ' % i, inline=True, after='background')
+            put_text('%s ' % i, inline=True, scope='background')
 
     return run_async(background())
 
@@ -235,18 +234,18 @@ def test_output(browser: Chrome, enable_percy=False):
     # get focus
     browser.find_element_by_tag_name('body').click()
     time.sleep(0.5)
-    tab_btns = browser.find_elements_by_css_selector('#pywebio-anchor-table_cell_buttons button')
+    tab_btns = browser.find_elements_by_css_selector('#pywebio-scope-table_cell_buttons button')
     for btn in tab_btns:
         time.sleep(0.5)
         browser.execute_script("arguments[0].click();", btn)
 
-    btns = browser.find_elements_by_css_selector('#pywebio-anchor-put_buttons button')
+    btns = browser.find_elements_by_css_selector('#pywebio-scope-put_buttons button')
     for btn in btns:
         time.sleep(0.5)
         browser.execute_script("arguments[0].click();", btn)
 
     # 滚动窗口
-    btns = browser.find_elements_by_css_selector('#pywebio-anchor-scroll_basis_btns button')
+    btns = browser.find_elements_by_css_selector('#pywebio-scope-scroll_basis_btns button')
     for btn in btns:
         time.sleep(1)
         browser.execute_script("arguments[0].click();", btn)
@@ -255,7 +254,7 @@ def test_output(browser: Chrome, enable_percy=False):
     enable_percy and percySnapshot(browser=browser, name='basic output')
 
     # popup
-    btn = browser.find_element_by_css_selector('#pywebio-anchor-popup_btn button')
+    btn = browser.find_element_by_css_selector('#pywebio-scope-popup_btn button')
     browser.execute_script("arguments[0].click();", btn)
 
     time.sleep(1)

+ 55 - 22
webiojs/src/handlers/output.ts

@@ -39,23 +39,24 @@ export class OutputHandler implements CommandHandler {
             }
 
             if (config.outputAnimation) elem.hide();
-            if (msg.spec.anchor !== undefined && this.container_elem.find(`#${msg.spec.anchor}`).length) {
-                let pos = this.container_elem.find(`#${msg.spec.anchor}`);
-                pos.empty().append(elem);
-                elem.unwrap().attr('id', msg.spec.anchor);
-            } else {
-                if (msg.spec.anchor !== undefined)
-                    elem.attr('id', msg.spec.anchor);
-
-                if (msg.spec.before !== undefined) {
-                    this.container_elem.find('#' + msg.spec.before).before(elem);
-                } else if (msg.spec.after !== undefined) {
-                    this.container_elem.find('#' + msg.spec.after).after(elem);
-                } else {
-                    this.container_elem.append(elem);
-                    scroll_bottom = true;
-                }
+            let container_elem = this.container_elem.find(`#${msg.spec.scope || 'pywebio-scope-ROOT'}`);
+            if (container_elem.length === 0)
+                return console.error(`Scope '${msg.spec.scope}' not found`);
+
+            if (!msg.spec.scope || msg.spec.scope === 'pywebio-scope-ROOT') scroll_bottom = true;
+
+            if (msg.spec.position === 0)
+                container_elem.prepend(elem);
+            else if (msg.spec.position === -1)
+                container_elem.append(elem);
+            else {
+                let pos = $(container_elem[0].children).eq(msg.spec.position);
+                if (msg.spec.position >= 0)
+                    elem.insertBefore(pos);
+                else
+                    elem.insertAfter(pos);
             }
+
             if (config.outputAnimation) elem.fadeIn();
         } else if (msg.command === 'output_ctl') {
             this.handle_output_ctl(msg);
@@ -79,12 +80,44 @@ export class OutputHandler implements CommandHandler {
         }
         if (msg.spec.auto_scroll_bottom !== undefined)
             state.AutoScrollBottom = msg.spec.auto_scroll_bottom;
-        if (msg.spec.set_anchor !== undefined) {
-            this.container_elem.find(`#${msg.spec.set_anchor}`).removeAttr('id');
-            this.container_elem.append(`<div id="${msg.spec.set_anchor}"></div>`);
-            // if (this.container_elem.find(`#${msg.spec.set_anchor}`).length === 0)
-            //     this.container_elem.append(`<div id="${msg.spec.set_anchor}"></div>`);
+        if (msg.spec.set_scope !== undefined) {
+            let spec = msg.spec as {
+                set_scope: string, // scope名
+                container: string, // 此scope的父scope
+                position: number, // 在父scope中创建此scope的位置 0 -> 在父scope的顶部创建, -1 -> 在父scope的尾部创建
+                if_exist: string // 已经存在 ``name`` scope 时如何操作:  `'remove'` 表示先移除旧scope再创建新scope, `'none'` 表示不进行任何操作, `'clear'` 表示将旧scope的内容清除,不创建新scope
+            };
+
+            let container_elem = $(`#${spec.container}`);
+            if (container_elem.length === 0)
+                return console.error(`Scope '${msg.spec.scope}' not found`);
+
+            let old = this.container_elem.find(`#${spec.set_scope}`);
+            if (old.length) {
+                if (spec.if_exist == 'none')
+                    return;
+                else if (spec.if_exist == 'remove')
+                    old.remove();
+                else if (spec.if_exist == 'clear') {
+                    old.empty();
+                    return;
+                }
+            }
+
+            let html = `<div id="${spec.set_scope}"></div>`;
+            if (spec.position === 0)
+                container_elem.prepend(html);
+            else if (spec.position === -1)
+                container_elem.append(html);
+            else {
+                if (spec.position >= 0)
+                    $(`#${spec.container}>*`).eq(spec.position).insertBefore(html);
+                else
+                    $(`#${spec.container}>*`).eq(spec.position).insertAfter(html);
+            }
         }
+        if (msg.spec.clear !== undefined)
+            this.container_elem.find(`#${msg.spec.clear}`).empty();
         if (msg.spec.clear_before !== undefined)
             this.container_elem.find(`#${msg.spec.clear_before}`).prevAll().remove();
         if (msg.spec.clear_after !== undefined)
@@ -92,7 +125,7 @@ export class OutputHandler implements CommandHandler {
         if (msg.spec.scroll_to !== undefined) {
             let target = $(`#${msg.spec.scroll_to}`);
             if (!target.length) {
-                console.error(`Anchor ${msg.spec.scroll_to} not found`);
+                console.error(`Scope ${msg.spec.scroll_to} not found`);
             } else if (state.OutputFixedHeight) {
                 box_scroll_to(target, this.container_parent, msg.spec.position);
             } else {

部分文件因文件數量過多而無法顯示