Jelajahi Sumber

feat: put_table support nested put_xxx call

wangweimin 5 tahun lalu
induk
melakukan
3077b190a3

+ 5 - 0
pywebio/html/css/app.css

@@ -93,6 +93,11 @@ button {
     margin-bottom: 8px;
 }
 
+td blockquote, td dl, td ol, td p, td pre, td table, td ul, td button, td pre {
+    margin-bottom: 0 !important;
+}
+
+
 .input-container .form-group {
     margin-bottom: 0;
 }

File diff ditekan karena terlalu besar
+ 0 - 0
pywebio/html/js/pywebio.min.js


File diff ditekan karena terlalu besar
+ 0 - 0
pywebio/html/js/pywebio.min.js.map


+ 28 - 0
pywebio/io_ctrl.py

@@ -3,6 +3,7 @@
 
 
 """
+import json
 import logging
 
 from .session import chose_impl, next_client_event, get_current_task_id, get_current_session
@@ -10,6 +11,33 @@ from .session import chose_impl, next_client_event, get_current_task_id, get_cur
 logger = logging.getLogger(__name__)
 
 
+class OutputReturn:
+    """ ``output`` 消息的处理类 """
+
+    def __init__(self, spec, on_embed=None):
+        self.spec = spec
+        self.processed = False
+        self.on_embed = on_embed or (lambda d: d)
+
+    def embed_data(self):
+        """返回供嵌入到布局中的数据,可以设置一些默认值"""
+        self.processed = True
+        return self.on_embed(self.spec)
+
+    def __del__(self):
+        """未嵌入时的操作:直接输出消息"""
+        if not self.processed:
+            send_msg('output', self.spec)
+
+
+class OutputEncoder(json.JSONEncoder):
+    def default(self, obj):
+        if isinstance(obj, OutputReturn):
+            return obj.embed_data()
+        # Let the base class default method raise the TypeError
+        return json.JSONEncoder.default(self, obj)
+
+
 def send_msg(cmd, spec=None):
     msg = dict(command=cmd, spec=spec, task_id=get_current_task_id())
     get_current_session().send_task_command(msg)

+ 42 - 25
pywebio/output.py

@@ -38,7 +38,7 @@ import io
 from base64 import b64encode
 from collections.abc import Mapping
 
-from .io_ctrl import output_register_callback, send_msg
+from .io_ctrl import output_register_callback, send_msg, OutputReturn
 
 try:
     from PIL.Image import Image as PILImage
@@ -131,9 +131,9 @@ def scroll_to(anchor, position=TOP):
     send_msg('output_ctl', dict(scroll_to=inner_ancher_name, position=position))
 
 
-def _put_content(type, anchor=None, before=None, after=None, **other_spec):
+def _get_output_spec(type, anchor=None, before=None, after=None, **other_spec):
     """
-    向用户端发送 ``output`` 指令
+    获取 ``output`` 指令的spec字段
 
     :param str type: 输出类型
     :param content: 输出内容
@@ -141,12 +141,14 @@ def _put_content(type, anchor=None, before=None, after=None, **other_spec):
     :param str before: 在给定的锚点之前输出内容。若给定的锚点不存在,则不输出任何内容
     :param str after: 在给定的锚点之后输出内容。若给定的锚点不存在,则不输出任何内容。
         注意: ``before`` 和 ``after`` 参数不可以同时使用
-    :param other_spec: 额外的输出参数
+    :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(other_spec)
+    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:
@@ -154,10 +156,10 @@ def _put_content(type, anchor=None, before=None, after=None, **other_spec):
     elif after:
         spec['after'] = _get_anchor_id(after)
 
-    send_msg("output", spec)
+    return spec
 
 
-def put_text(text, inline=False, anchor=None, before=None, after=None):
+def put_text(text, inline=False, anchor=None, before=None, after=None) -> OutputReturn:
     """
     输出文本内容
 
@@ -170,10 +172,11 @@ def put_text(text, inline=False, anchor=None, before=None, after=None):
     注意: ``before`` 和 ``after`` 参数不可以同时使用。
     当 ``anchor`` 指定的锚点已经在页面上存在时,``before`` 和 ``after`` 参数将被忽略。
     """
-    _put_content('text', content=str(text), inline=inline, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('text', content=str(text), inline=inline, anchor=anchor, before=before, after=after)
+    return OutputReturn(spec)
 
 
-def put_html(html, anchor=None, before=None, after=None):
+def put_html(html, anchor=None, before=None, after=None) -> OutputReturn:
     """
     输出Html内容。
 
@@ -185,10 +188,11 @@ def put_html(html, anchor=None, before=None, after=None):
     if hasattr(html, '__html__'):
         html = html.__html__()
 
-    _put_content('html', content=html, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('html', content=html, anchor=anchor, before=before, after=after)
+    return OutputReturn(spec)
 
 
-def put_code(content, langage='', anchor=None, before=None, after=None):
+def put_code(content, langage='', anchor=None, before=None, after=None) -> OutputReturn:
     """
     输出代码块
 
@@ -197,10 +201,10 @@ def put_code(content, langage='', anchor=None, before=None, after=None):
     :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
     """
     code = "```%s\n%s\n```" % (langage, content)
-    put_markdown(code, anchor=anchor, before=before, after=after)
+    return put_markdown(code, anchor=anchor, before=before, after=after)
 
 
-def put_markdown(mdcontent, strip_indent=0, lstrip=False, anchor=None, before=None, after=None):
+def put_markdown(mdcontent, strip_indent=0, lstrip=False, anchor=None, before=None, after=None) -> OutputReturn:
     """
     输出Markdown内容。
 
@@ -240,14 +244,15 @@ 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)
 
-    _put_content('markdown', content=mdcontent, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('markdown', content=mdcontent, anchor=anchor, before=before, after=after)
+    return OutputReturn(spec)
 
 
-def put_table(tdata, header=None, span=None, anchor=None, before=None, after=None):
+def put_table(tdata, header=None, span=None, anchor=None, before=None, after=None) -> OutputReturn:
     """
     输出表格
 
-    :param list tdata: 表格数据。列表项可以为 ``list`` 或者 ``dict``
+    :param list tdata: 表格数据。列表项可以为 ``list`` 或者 ``dict`` , 单元格的内容可以为字符串或其他输出函数的返回值,字符串内容显示时会被当作html。
     :param list header: 设定表头。
        当 ``tdata`` 的列表项为 ``list`` 类型时,若省略 ``header`` 参数,则使用 ``tdata`` 的第一项作为表头。
 
@@ -298,7 +303,8 @@ 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()}
 
-    _put_content('table', data=tdata, span=span, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('table', data=tdata, span=span, anchor=anchor, before=before, after=after)
+    return OutputReturn(spec)
 
 
 def _format_button(buttons):
@@ -325,7 +331,7 @@ def _format_button(buttons):
     return btns
 
 
-def table_cell_buttons(buttons, onclick, **callback_options):
+def table_cell_buttons(buttons, onclick, **callback_options) -> str:
     """
     在表格中显示一组按钮
 
@@ -355,7 +361,8 @@ def table_cell_buttons(buttons, onclick, **callback_options):
     return ' '.join(btns_html)
 
 
-def put_buttons(buttons, onclick, small=False, anchor=None, before=None, after=None, **callback_options):
+def put_buttons(buttons, onclick, small=None, anchor=None, before=None, after=None,
+                **callback_options) -> OutputReturn:
     """
     输出一组按钮
 
@@ -370,6 +377,7 @@ def put_buttons(buttons, onclick, small=False, anchor=None, before=None, after=N
        函数签名为 ``onclick(btn_value)``.
        当按钮组中的按钮被点击时,``onclick`` 被调用,并传入被点击的按钮的 ``value`` 值。
        可以使用 ``functools.partial`` 来在 ``onclick`` 中保存更多上下文信息,见 `table_cell_buttons` :ref:`代码示例 <table_cell_buttons-code-sample>` 。
+    :param bool small: 是否显示小号按钮,默认为False
     :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
     :param callback_options: 回调函数的其他参数。根据选用的 session 实现有不同参数
 
@@ -383,11 +391,18 @@ def put_buttons(buttons, onclick, small=False, anchor=None, before=None, after=N
     """
     btns = _format_button(buttons)
     callback_id = output_register_callback(onclick, **callback_options)
-    _put_content('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, anchor=anchor, before=before,
+                            after=after)
+
+    def on_embed(spec):
+        spec.setdefault('small', True)
+        return spec
 
+    return OutputReturn(spec, on_embed=on_embed)
 
-def put_image(content, format=None, title='', width=None, height=None, anchor=None, before=None, after=None):
+
+def put_image(content, format=None, title='', width=None, height=None, anchor=None, before=None,
+              after=None) -> OutputReturn:
     """输出图片。
 
     :param content: 文件内容. 类型为 bytes-like object 或者为 ``PIL.Image.Image`` 实例
@@ -412,10 +427,10 @@ 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)
-    put_html(html, anchor=anchor, before=before, after=after)
+    return put_html(html, anchor=anchor, before=before, after=after)
 
 
-def put_file(name, content, anchor=None, before=None, after=None):
+def put_file(name, content, anchor=None, before=None, after=None) -> OutputReturn:
     """输出文件。
     在浏览器上的显示为一个以文件名为名的链接,点击链接后浏览器自动下载文件。
 
@@ -424,4 +439,6 @@ def put_file(name, content, anchor=None, before=None, after=None):
     :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
     """
     content = b64encode(content).decode('ascii')
-    _put_content('file', name=name, content=content, anchor=anchor, before=before, after=after)
+    spec = _get_output_spec('file', name=name, content=content, anchor=anchor, before=before, after=after)
+    return OutputReturn(spec)
+

+ 4 - 1
pywebio/platform/aiohttp.py

@@ -1,5 +1,6 @@
 import asyncio
 import fnmatch
+import json
 import logging
 from functools import partial
 from os import path, listdir
@@ -8,6 +9,7 @@ from urllib.parse import urlparse
 from aiohttp import web
 
 from .tornado import open_webbrowser_on_server_started
+from ..io_ctrl import OutputEncoder
 from ..session import CoroutineBasedSession, ThreadBasedSession, register_session_implement_for_target, AbstractSession
 from ..session.base import get_session_info_from_headers
 from ..utils import get_free_port, STATIC_PATH
@@ -57,7 +59,8 @@ def _webio_handler(target, session_cls, websocket_settings, check_origin_func=_i
 
         def send_msg_to_client(session: AbstractSession):
             for msg in session.get_task_commands():
-                ioloop.create_task(ws.send_json(msg))
+                msg_str = json.dumps(msg, cls=OutputEncoder)
+                ioloop.create_task(ws.send_str(msg_str))
 
         def close_from_session():
             nonlocal close_from_session_tag

+ 3 - 2
pywebio/platform/django.py

@@ -50,15 +50,16 @@ class DjangoHttpContext(HttpContext):
         """为当前响应设置http status"""
         self.response.status_code = status
 
-    def set_content(self, content, json_type=False):
+    def set_content(self, content, json_type=False, json_cls=None):
         """设置相应的内容
 
         :param content:
         :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
+        :param json_cls: json.dumps 使用的JSONEncoder
         """
         if json_type:
             self.set_header('content-type', 'application/json')
-            self.response.content = json.dumps(content)
+            self.response.content = json.dumps(content, cls=json_cls)
         else:
             self.response.content = content
 

+ 3 - 2
pywebio/platform/flask.py

@@ -55,15 +55,16 @@ class FlaskHttpContext(HttpContext):
         """为当前响应设置http status"""
         self.response.status_code = status
 
-    def set_content(self, content, json_type=False):
+    def set_content(self, content, json_type=False, json_cls=None):
         """设置相应的内容
 
         :param content:
         :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
+        :param json_cls: json.dumps 使用的JSONEncoder
         """
         if json_type:
             self.set_header('content-type', 'application/json')
-            self.response.data = json.dumps(content)
+            self.response.data = json.dumps(content, cls=json_cls)
         else:
             self.response.data = content
 

+ 5 - 3
pywebio/platform/httpbased.py

@@ -20,7 +20,8 @@ import threading
 from typing import Dict
 
 import time
-
+import json
+from ..io_ctrl import OutputEncoder
 from ..session import CoroutineBasedSession, AbstractSession, register_session_implement_for_target
 from ..session.base import get_session_info_from_headers
 from ..utils import random_str, LRUDict
@@ -59,11 +60,12 @@ class HttpContext:
         """为当前响应设置http status"""
         pass
 
-    def set_content(self, content, json_type=False):
+    def set_content(self, content, json_type=False, json_cls=None):
         """设置响应的内容。方法应该仅被调用一次
 
         :param content:
         :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
+        :param json_cls: json.dumps 使用的JSONEncoder
         """
         pass
 
@@ -184,7 +186,7 @@ class HttpHandler:
             cls._last_check_session_expire_ts = time.time()
             self._remove_expired_sessions(self.session_expire_seconds)
 
-        context.set_content(webio_session.get_task_commands(), json_type=True)
+        context.set_content(webio_session.get_task_commands(), json_type=True, json_cls=OutputEncoder)
 
         if webio_session.closed():
             self._remove_webio_session(webio_session_id)

+ 3 - 2
pywebio/platform/tornado.py

@@ -14,6 +14,7 @@ import tornado.ioloop
 from tornado.web import StaticFileHandler
 from tornado.websocket import WebSocketHandler
 
+from ..io_ctrl import OutputEncoder
 from ..session import CoroutineBasedSession, ThreadBasedSession, ScriptModeSession, \
     register_session_implement_for_target, AbstractSession
 from ..session.base import get_session_info_from_headers
@@ -71,11 +72,11 @@ def _webio_handler(target, session_cls, check_origin_func=_is_same_site):
 
         def send_msg_to_client(self, session: AbstractSession):
             for msg in session.get_task_commands():
-                self.write_message(json.dumps(msg))
+                self.write_message(json.dumps(msg, cls=OutputEncoder))
 
         def open(self):
             logger.debug("WebSocket opened")
-            self.set_nodelay(True)
+            # self.set_nodelay(True)
 
             self._close_from_session_tag = False  # 由session主动关闭连接
 

+ 11 - 1
test/template.py

@@ -73,6 +73,17 @@ def basic_output():
         {"Course": "DB", "Score": "93"},
     ], header=["Course", "Score"], anchor='put_table')
 
+    img_data = open(path.join(here_dir, 'assets', 'img.png'), 'rb').read()
+    put_table([
+        ['Type', 'Content'],
+        ['text', put_text('<hr/>', inline=True)],
+        ['html', 'X<sup>2</sup>'],
+        ['buttons', put_buttons(['A','B'], onclick=None)],
+        ['markdown', put_markdown('`awesome PyWebIO!`\n - 1\n - 2\n - 3')],
+        ['file', put_file('hello.text', b'')],
+        ['image', put_image(img_data)],
+    ])
+
     put_text('code:')
     put_code(json.dumps(dict(name='pywebio', author='wangweimin'), indent=4), 'json', anchor='scroll_basis')
 
@@ -95,7 +106,6 @@ def basic_output():
 
     put_buttons(['A', 'B', 'C'], onclick=partial(put_text, after='put_buttons'), anchor='put_buttons')
 
-    img_data = open(path.join(here_dir, 'assets', 'img.png'), 'rb').read()
     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')

+ 45 - 11
webiojs/src/models/output.ts

@@ -1,6 +1,11 @@
 import {state} from '../state'
 import {b64toBlob} from "../utils";
 
+/*
+* 当前限制
+* 若外层为layout类的Widget,则内层Widget在get_element中绑定的事件将会失效
+* */
+
 export interface Widget {
     handle_type: string;
 
@@ -33,9 +38,13 @@ let Html = {
     handle_type: 'html',
     get_element: function (spec: any) {
         let nodes = $.parseHTML(spec.content, null, true);
-        let elem = $(nodes) as any;
+        let elem;
         if (nodes.length > 1)
             elem = $('<div><div/>').append(nodes);
+        else if (nodes.length === 1)
+            elem = $(nodes[0]);
+        else
+            elem = $(nodes);
         return elem;
     }
 };
@@ -43,7 +52,7 @@ let Html = {
 let Buttons = {
     handle_type: 'buttons',
     get_element: function (spec: any) {
-        const btns_tpl = `<div class="form-group">{{#buttons}}
+        const btns_tpl = `<div>{{#buttons}}
                              <button value="{{value}}" onclick="WebIO.DisplayAreaButtonOnClick(this, '{{callback_id}}')" class="btn btn-primary {{#small}}btn-sm{{/small}}">{{label}}</button> 
                           {{/buttons}}</div>`;
         let html = Mustache.render(btns_tpl, spec);
@@ -70,7 +79,7 @@ export function DisplayAreaButtonOnClick(this_ele: HTMLElement, callback_id: str
 let File = {
     handle_type: 'file',
     get_element: function (spec: any) {
-        const html = `<div class="form-group"><button type="button" class="btn btn-link">${spec.name}</button></div>`;
+        const html = `<div><button type="button" class="btn btn-link">${spec.name}</button></div>`;
         let element = $(html);
         let blob = b64toBlob(spec.content);
         element.on('click', 'button', function (e) {
@@ -100,28 +109,53 @@ let Table = {
       {{/tdata}}
     
 </table>`;
-        interface itemType  {
-            data:string,
-            col?: number, row?: number
+
+        interface itemType {
+            data: string,
+            col?: number,
+            row?: number
         }
 
-        let table_data:itemType[][] = [];
+        // 将spec转化成模版引擎的输入
+        let table_data: itemType[][] = [];
         for (let row_id in spec.data) {
             table_data.push([]);
             let row = spec.data[row_id];
             for (let col_id in row) {
+                let data = spec.data[row_id][col_id];
+
+                // 处理复合类型单元格,即单元格不是简单的html,而是一个output命令的spec
+                if (typeof data === 'object') {
+                    let html = '';
+                    try {
+                        // @ts-ignore
+                        let nodes = type2processor[data.type](data);
+                        for (let node of nodes)
+                            html += node.outerHTML || '';
+                    } catch (e) {
+                        console.error('Get sub widget html error,', e, data);
+                    }
+                    data = html;
+                }
+
                 table_data[row_id].push({
-                    data: spec.data[row_id][col_id],
+                    data: data,
                     ...(spec.span[row_id + ',' + col_id] || {})
                 });
             }
         }
-        let header:itemType[], data:itemType[][];
+
+        let header: itemType[], data: itemType[][];
         [header, ...data] = table_data;
-        let html = Mustache.render(table_tpl, {header:header, tdata:data});
+        let html = Mustache.render(table_tpl, {header: header, tdata: data});
         return $(html);
     }
 };
 
 
-export let all_widgets: Widget[] = [Text, Markdown, Html, Buttons, File, Table];
+export let all_widgets: Widget[] = [Text, Markdown, Html, Buttons, File, Table];
+
+let type2processor: { [i: string]: (spec: any) => JQuery } = {};
+for (let w of all_widgets)
+    type2processor[w.handle_type] = w.get_element;
+

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini