Selaa lähdekoodia

初步可用 2/11

wangweimin 5 vuotta sitten
vanhempi
säilyke
0b04baef93
8 muutettua tiedostoa jossa 402 lisäystä ja 139 poistoa
  1. 18 1
      doc/log.md
  2. 12 8
      doc/spec.md
  3. 11 78
      test.py
  4. 10 11
      wsrepl/framework.py
  5. 3 2
      wsrepl/html/index.html
  6. 86 14
      wsrepl/html/js/form.js
  7. 236 15
      wsrepl/interact.py
  8. 26 10
      wsrepl/ioloop.py

+ 18 - 1
doc/log.md

@@ -19,4 +19,21 @@
 NOTE: 
 NOTE: 
 含有yield的函数一定是生成器,不管会不会执行到 (比如在分支里)
 含有yield的函数一定是生成器,不管会不会执行到 (比如在分支里)
 
 
-coro.send 内部可能还会存在 激活协程的调用,要禁止或者将Global改成栈式存储
+coro.send 内部可能还会存在 激活协程的调用,要禁止嵌套创建协程Task或者将Global改成栈式存储
+
+
+2/10
+当前问题:
+    对于tornado coro的支持不是很友好:连续 yield tornado coro时,无法在yield间隙调度到其他coro执行 [todo]
+    使用tornado Future的callback应该可以解决
+    
+对于yield input()和 yield input_group([input(), input()])语法的实现:
+    input()返回一个msg对象,task接收到后,处理 发送
+    比上述更好地实现 [ok]
+    
+    
+2/11
+用户输入函数中,对结果无影响的非法参数可以以warnning而不是异常的方式提示用户 [ok]
+
+
+

+ 12 - 8
doc/spec.md

@@ -35,25 +35,27 @@
     required:
     required:
     value:
     value:
     placeholder: placeholder 属性是提示用户内容的输入格式。某些情况下 placeholder 属性对用户不可见, 所以当没有它时也需要保证form能被理解。
     placeholder: placeholder 属性是提示用户内容的输入格式。某些情况下 placeholder 属性对用户不可见, 所以当没有它时也需要保证form能被理解。
-    ^on_focus
-    ^on_blur
     ^inline  // type==checkbox,radio
     ^inline  // type==checkbox,radio
     ^options // type==checkbox,radio , 字典列表 {*value:, *label:, checked,disabled }
     ^options // type==checkbox,radio , 字典列表 {*value:, *label:, checked,disabled }
 
 
 
 
 
 
-<button>
-ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/button
-
-<select>
+type=<select>
 ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/select
 ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/select
 
 
-<textarea>
+type=<textarea>
 ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/textarea
 ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/textarea
 
 
+<button>
+ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/button
+
+type=buttons
+    label
+    name
+    actions 字典列表 {*value:, *label:, disabled}
 
 
 input_group:
 input_group:
-    label:
+    label: # todo change to label
     inputs: [ <input>, ] // 若只有一个input 则可以忽略其label属性
     inputs: [ <input>, ] // 若只有一个input 则可以忽略其label属性
 
 
 
 
@@ -93,6 +95,8 @@ input_event
     name:
     name:
     value:
     value:
 
 
+checkbox_radio 不产生blur事件
+
 from_submit:
 from_submit:
     
     
 
 

+ 11 - 78
test.py

@@ -8,91 +8,24 @@ from wsrepl.interact import *
 from tornado.gen import sleep
 from tornado.gen import sleep
 
 
 
 
-def other():
-    text_print("Welcome from other!!!")
-
-
-    event = yield from get_response("input_group", spec={
-        "label": 'other',
-        "inputs": [{
-            'name': 'other',
-            'type': 'text',
-            'autofocus': True,
-            'label': 'other',
-            'help_text': 'other help_text',
-        }]
-    })
-    idx = 0
-    while 1:
-        yield sleep(0.5)
-        text_print(str(idx))
-
-
 # 业务逻辑 协程
 # 业务逻辑 协程
 def say_hello():
 def say_hello():
-    # yield sleep(0.5)
-
     # 向用户输出文字
     # 向用户输出文字
     text_print("Welcome!!!")
     text_print("Welcome!!!")
-    # run_async(other())
-    # name = yield from text_input_coro('input your name')
-
-    event = yield from get_response("input_group", spec={
-        "label": 'another',
-        "inputs": [{
-            'name': 'name',
-            'type': 'text',
-            'autofocus': True,
-            'label': 'another text',
-            'help_text': 'another text help_text',
-        }]
-    })
-
-    event = yield from get_response("input_group", spec={
-        "label": 'label',
-        "inputs": [{
-            'name': 'name',
-            'type': 'text',
-            'autofocus': True,
-            'label': 'text',
-            'help_text': 'text help_text',
-        },
-            {
-                'name': 'checkbox',
-                'type': 'checkbox',
-                'inline': True,
-                'label': '性别',
-                'help_text': 'help_text',
-                'options': [
-                    {'value': 'man', 'label': '男', 'checked': True},
-                    {'value': 'woman', 'label': '女', 'checked': False}
-                ]
-            }
-        ]
-    })
-    json_print(event)
-
-    while event['event'] != 'from_submit':
-        json_print(event)
-        if event['event'] == 'input_event':
-            send_msg("update_input", spec={
-                'target_name': event['data']['name'],
-                'attributes': {
-                    'valid_status': True,
-                    'valid_feedback': 'ok'
-                }
-            })
-        event = yield
-
-    yield sleep(0.5)
-
-    text_print("收到")
+    res = yield from input('This is single input')
+    text_print('Your input:%s' % res)
 
 
-    yield from get_response("destroy_form", spec={})
+    res = yield from input('This is another single input')
+    text_print('Your input:%s' % res)
 
 
-    text_print("Bye ")
+    res = yield from input_group('Group input', [
+        input('Input 1', name='one'),
+        input('Input 2', name='two'),
+        select('Input 2', options=['A', 'B', 'C'], type=CHECKBOX, name='three')
+    ])
 
 
-    yield sleep(1)
+    text_print('Your input:')
+    json_print(res)
 
 
 
 
 start_ioloop(say_hello)
 start_ioloop(say_hello)

+ 10 - 11
wsrepl/framework.py

@@ -4,10 +4,9 @@ from collections import defaultdict
 from tornado.gen import coroutine, sleep
 from tornado.gen import coroutine, sleep
 import random, string
 import random, string
 from contextlib import contextmanager
 from contextlib import contextmanager
-
+from tornado.log import gen_log
 
 
 class Future:
 class Future:
-
     def __iter__(self):
     def __iter__(self):
         result = yield
         result = yield
         return result
         return result
@@ -49,23 +48,23 @@ class Task:
 
 
         self.coro_id = self.gen_coro_id(self.coro)
         self.coro_id = self.gen_coro_id(self.coro)
 
 
-        # todo issue: 激活协程后,写成的返回值可能是tornado coro,需要执行
-        with self.ws_context():
-            res = self.coro.send(None)  # 激活协程
 
 
-        if res is not None:  # todo 执行完,还需要获取结果,coro.send(res)
-            self.ws.tornado_coro_instances.append(res)
-
-    def step(self, result):
+    @coroutine
+    def step(self, result=None):
         try:
         try:
             with self.ws_context():
             with self.ws_context():
                 res = self.coro.send(result)
                 res = self.coro.send(result)
-            return res
+            while res is not None:
+                r = yield res
+                with self.ws_context():
+                    res = self.coro.send(r)
         except StopIteration as e:
         except StopIteration as e:
             if len(e.args) == 1:
             if len(e.args) == 1:
                 self.result = e.args[0]
                 self.result = e.args[0]
 
 
-            self.task_finished = Task
+            self.task_finished = True
+
+            gen_log.debug('Task[%s] finished, self.coros:%s', self.coro_id, self.ws.coros)
 
 
             # raise
             # raise
 
 

+ 3 - 2
wsrepl/html/index.html

@@ -61,6 +61,7 @@
 
 
 <script src="js/mustache.min.js"></script>
 <script src="js/mustache.min.js"></script>
 <script src="js/mditor.min.js"></script>
 <script src="js/mditor.min.js"></script>
+<script src="js/async.min.js"></script>
 <script src="js/form.js"></script>
 <script src="js/form.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
 <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
@@ -78,7 +79,7 @@
 
 
     var old_send = ws.send;
     var old_send = ws.send;
     ws.send = function (d) {
     ws.send = function (d) {
-        console.log('<<<', JSON.parse(d));
+        // console.log('<<<', JSON.parse(d));
         old_send.apply(this, arguments);
         old_send.apply(this, arguments);
     };
     };
 
 
@@ -90,7 +91,7 @@
         viewer.scrollTop(md_body[0].scrollHeight);  // 自动滚动
         viewer.scrollTop(md_body[0].scrollHeight);  // 自动滚动
 
 
         var msg = JSON.parse(evt.data);
         var msg = JSON.parse(evt.data);
-        console.log('>>>', msg);
+        // console.log('>>>', msg);
         ctrl.handle_message(msg);
         ctrl.handle_message(msg);
     };
     };
     ws.onclose = function () {
     ws.onclose = function () {

+ 86 - 14
wsrepl/html/js/form.js

@@ -25,6 +25,26 @@
         return JSON.parse(JSON.stringify(obj));
         return JSON.parse(JSON.stringify(obj));
     }
     }
 
 
+    function Lock(func) {
+        this.func = func;
+        this.func_lock = false;
+        this.func_call_requests = [];
+
+        this.mutex_run = function (that, args) {
+            if (this.func_lock) {
+                this.func_call_requests.push(args);
+            } else {
+                this.func_lock = true;
+                this.func.call(that, args);
+                while (this.func_call_requests.length) {
+                    this.func.call(that, this.func_call_requests.pop());
+                }
+                this.func_lock = false;
+            }
+
+        }
+    }
+
     function LRUMap() {
     function LRUMap() {
         this.keys = [];
         this.keys = [];
         this.map = {};
         this.map = {};
@@ -95,14 +115,43 @@
         this._activate_form = function (coro_id, old_ctrl) {
         this._activate_form = function (coro_id, old_ctrl) {
             var ctrls = this.form_ctrls.get_value(coro_id);
             var ctrls = this.form_ctrls.get_value(coro_id);
             var ctrl = ctrls[ctrls.length - 1];
             var ctrl = ctrls[ctrls.length - 1];
-            if (ctrl === old_ctrl || old_ctrl === undefined)
-                return ctrl.element.show(100);
+            if (ctrl === old_ctrl || old_ctrl === undefined) {
+                console.log('开:%s', ctrl.spec.label);
+                return ctrl.element.show(200, function () {
+                    // 有时候autofocus属性不生效,手动激活一下
+                    $('input[autofocus]').focus();
+                });
+            }
             this.form_ctrls.move_to_top(coro_id);
             this.form_ctrls.move_to_top(coro_id);
+            var that = this;
             old_ctrl.element.hide(100, () => {
             old_ctrl.element.hide(100, () => {
-                ctrl.element.show(100);
+                // ctrl.element.show(100);
+                // 需要在回调中重新获取当前前置表单元素,因为100ms内可能有变化
+                var t = that.form_ctrls.get_top();
+                if (t) t[t.length - 1].element.show(200, function () {
+                    // 有时候autofocus属性不生效,手动激活一下
+                    $('input[autofocus]').focus();
+                });
             });
             });
         };
         };
 
 
+        var that = this;
+        this.msg_queue = async.queue((msg) => {
+            that.consume_message(msg)
+        }, 1);
+
+        var l = new Lock(this.consume_message);
+
+        this.handle_message_ = function (msg) {
+            // this.msg_queue.push(msg);
+            // l.mutex_run(that, msg);
+            // console.log('start handle_message %s %s', msg.command, msg.spec.label);
+            this.consume_message(msg);
+            // console.log('end handle_message %s %s', msg.command, msg.spec.label);
+
+        };
+
+
         /*
         /*
         * 每次函数调用返回后,this.form_ctrls.get_top()的栈顶对应的表单为当前活跃表单
         * 每次函数调用返回后,this.form_ctrls.get_top()的栈顶对应的表单为当前活跃表单
         * */
         * */
@@ -127,8 +176,8 @@
                     return console.error('No form to current message. coro_id:%s', msg.coro_id);
                     return console.error('No form to current message. coro_id:%s', msg.coro_id);
                 }
                 }
                 target_ctrls[target_ctrls.length - 1].dispatch_ctrl_message(msg.spec);
                 target_ctrls[target_ctrls.length - 1].dispatch_ctrl_message(msg.spec);
-                // 表单前置
-                this._activate_form(msg.coro_id, old_ctrl);
+                // 表单前置 removed
+                // this._activate_form(msg.coro_id, old_ctrl);
             } else if (msg.command === 'destroy_form') {
             } else if (msg.command === 'destroy_form') {
                 if (target_ctrls.length === 0) {
                 if (target_ctrls.length === 0) {
                     return console.error('No form to current message. coro_id:%s', msg.coro_id);
                     return console.error('No form to current message. coro_id:%s', msg.coro_id);
@@ -141,12 +190,16 @@
                 if (old_ctrls === target_ctrls) {
                 if (old_ctrls === target_ctrls) {
                     var that = this;
                     var that = this;
                     deleted.element.hide(100, () => {
                     deleted.element.hide(100, () => {
+                        deleted.element.remove();
                         var t = that.form_ctrls.get_top();
                         var t = that.form_ctrls.get_top();
-                        if (t) t[t.length - 1].element.show(100);
+                        if (t) t[t.length - 1].element.show(200, function () {
+                            $('input[autofocus]').focus();
+                        });
                     });
                     });
+                } else {
+                    deleted.element.remove();
                 }
                 }
             }
             }
-            // todo: 如果当前栈顶key is not coro_id, hide show, move to top
         }
         }
     }
     }
 
 
@@ -207,23 +260,21 @@
         var that = this;
         var that = this;
         this.element.on('submit', 'form', function (e) {
         this.element.on('submit', 'form', function (e) {
             e.preventDefault(); // avoid to execute the actual submit of the form.
             e.preventDefault(); // avoid to execute the actual submit of the form.
-            var inputs = $(this).serializeArray();
             var data = {};
             var data = {};
-            $.each(inputs, (idx, item) => {
-                if (data[item.name] === undefined) data[item.name] = [];
-                data[item.name].push(item.value);
+            $.each(that.input_controllers, (name, ctrl) => {
+                data[name] = ctrl.get_value();
             });
             });
             ws.send(JSON.stringify({
             ws.send(JSON.stringify({
                 event: "from_submit",
                 event: "from_submit",
                 coro_id: that.coro_id,
                 coro_id: that.coro_id,
                 data: data
                 data: data
             }));
             }));
-        })
+        });
     };
     };
 
 
     FormController.prototype.dispatch_ctrl_message = function (spec) {
     FormController.prototype.dispatch_ctrl_message = function (spec) {
         if (!(spec.target_name in this.input_controllers)) {
         if (!(spec.target_name in this.input_controllers)) {
-            return console.error('Can\'t find input[name=%s] element in curr form!' , spec.target_name);
+            return console.error('Can\'t find input[name=%s] element in curr form!', spec.target_name);
         }
         }
 
 
         this.input_controllers[spec.target_name].update_input(spec);
         this.input_controllers[spec.target_name].update_input(spec);
@@ -330,6 +381,10 @@
         this.update_input_helper(-1, attributes);
         this.update_input_helper(-1, attributes);
     };
     };
 
 
+    CommonInputController.prototype.get_value = function () {
+        return this.element.find('input').val();
+    };
+
     function CheckboxRadioController(ws_client, coro_id, spec) {
     function CheckboxRadioController(ws_client, coro_id, spec) {
         FormItemController.apply(this, arguments);
         FormItemController.apply(this, arguments);
 
 
@@ -369,7 +424,8 @@
         for (idx = 0; idx < this.spec.options.length; idx++) {
         for (idx = 0; idx < this.spec.options.length; idx++) {
             var input_elem = elem.find('#' + id_name_prefix + '-' + idx);
             var input_elem = elem.find('#' + id_name_prefix + '-' + idx);
             // blur事件时,发送当前值到服务器
             // blur事件时,发送当前值到服务器
-            input_elem.on('blur', this.send_value_listener);
+            // checkbox_radio 不产生blur事件
+            // input_elem.on('blur', this.send_value_listener);
 
 
             // 将额外的html参数加到input标签上
             // 将额外的html参数加到input标签上
             for (var key in this.spec.options[idx]) {
             for (var key in this.spec.options[idx]) {
@@ -393,6 +449,22 @@
         this.update_input_helper(idx, attributes);
         this.update_input_helper(idx, attributes);
     };
     };
 
 
+    CheckboxRadioController.prototype.get_value = function () {
+        if (this.spec.type === 'radio') {
+            return this.element.find('input').val();
+        } else {
+            var value_arr = this.element.find('input').serializeArray();
+            var res = [];
+            var that = this;
+            $.each(value_arr, function (idx, val) {
+                if (val.name === that.spec.name)
+                    res.push(val.value);
+            });
+            return res;
+        }
+    };
+
+
 
 
     function WSREPLController(ws_client, output_container_elem, input_container_elem) {
     function WSREPLController(ws_client, output_container_elem, input_container_elem) {
         this.output_ctrl = new OutputController(ws_client, output_container_elem);
         this.output_ctrl = new OutputController(ws_client, output_container_elem);

+ 236 - 15
wsrepl/interact.py

@@ -2,13 +2,18 @@ import tornado.websocket
 import time, json
 import time, json
 from collections import defaultdict
 from collections import defaultdict
 from .framework import Future, Msg, Global
 from .framework import Future, Msg, Global
+from collections.abc import Iterable, Mapping, Sequence
+
+import logging
+
+logger = logging.getLogger(__name__)
 
 
 
 
 def run_async(coro):
 def run_async(coro):
     Global.active_ws.inactive_coro_instances.append(coro)
     Global.active_ws.inactive_coro_instances.append(coro)
 
 
 
 
-def send_msg(cmd, spec):
+def send_msg(cmd, spec=None):
     msg = dict(command=cmd, spec=spec, coro_id=Global.active_coro_id)
     msg = dict(command=cmd, spec=spec, coro_id=Global.active_coro_id)
     Global.active_ws.write_message(json.dumps(msg))
     Global.active_ws.write_message(json.dumps(msg))
 
 
@@ -19,20 +24,237 @@ def get_response(cmd, spec):
     return response_msg
     return response_msg
 
 
 
 
-# 非阻塞协程工具库
-def text_input_coro(prompt):
-    data = yield from get_response("input_group", spec={
-        "label": prompt,
-        "inputs": [{
-            'name': 'name',
-            'type': 'text',
-            'label': prompt,
-            'help_text': 'help_text',
+TEXT = 'text'
+NUMBER = "number"
+PASSWORD = "password"
+CHECKBOX = 'checkbox'
+RADIO = 'radio'
+SELECT = 'select'
+
+
+def _input_event_handle(valid_funcs, whole_valid_func=None):
+    """
+    根据提供的校验函数处理表单事件
+    :param valid_funcs: map(name -> valid_func)  valid_func 为 None 时,不进行验证
+                        valid_func: callback(data) -> error_msg
+    :param whole_valid_func: callback(data) -> (name, error_msg)
+    :return:
+    """
+    while True:
+        event = yield
+        event_name, event_data = event['event'], event['data']
+        if event_name == 'input_event':
+            input_event = event_data['event_name']
+            if input_event == 'on_blur':
+                onblur_name = event_data['name']
+                valid_func = valid_funcs.get(onblur_name)
+                if valid_func is None:
+                    continue
+                error_msg = valid_func(event_data['value'])
+                if error_msg is not None:
+                    send_msg('update_input', dict(target_name=onblur_name, attributes={
+                        'valid_status': False,
+                        'invalid_feedback': error_msg
+                    }))
+        elif event_name == 'from_submit':
+            all_valid = True
+
+            # 调用输入项验证函数进行校验
+            for name, valid_func in valid_funcs.items():
+                if valid_func is None:
+                    continue
+                error_msg = valid_func(event_data[name])
+                if error_msg is not None:
+                    all_valid = False
+                    send_msg('update_input', dict(target_name=name, attributes={
+                        'valid_status': False,
+                        'invalid_feedback': error_msg
+                    }))
+
+            # 调用表单验证函数进行校验
+            if whole_valid_func:
+                v_res = whole_valid_func(event_data)
+                if v_res is not None:
+                    all_valid = False
+                    onblur_name, error_msg = v_res
+                    send_msg('update_input', dict(target_name=onblur_name, attributes={
+                        'valid_status': False,
+                        'invalid_feedback': error_msg
+                    }))
+
+            if all_valid:
+                break
+
+    return event['data']
+
+
+def _make_input_spec(label, type, name, valid_func=None, multiple=None, inline=None, other_html_attrs=None,
+                     **other_kwargs):
+    """
+    校验传入input函数和select函数的参数
+    生成input_group消息中spec inputs参数列表项
+    支持的input类型 TEXT, NUMBER, PASSWORD, CHECKBOX, RADIO, SELECT
+    """
+    allowed_type = {TEXT, NUMBER, PASSWORD, CHECKBOX, RADIO, SELECT}
+    assert type in allowed_type, 'Input type not allowed.'
+
+    input_item = other_kwargs
+    input_item.update(other_html_attrs or {})
+    input_item.update(dict(label=label, type=type, name=name))
+
+    if valid_func is not None and type in (CHECKBOX, RADIO):  # CHECKBOX, RADIO 不支持valid_func参数
+        logger.warning('valid_func can\'t be used when type in (CHECKBOX, RADIO)')
+
+    if inline is not None and type not in {CHECKBOX, RADIO}:
+        logger.warning('inline 只能用于 CHECKBOX, RADIO type')
+    elif inline is not None:
+        input_item['inline'] = inline
+
+    if multiple is not None and type != SELECT:
+        logger.warning('multiple 参数只能用于SELECT type')
+    elif multiple is not None:
+        input_item['multiple'] = multiple
+
+    if type in {CHECKBOX, RADIO, SELECT}:
+        assert 'options' in input_item, 'Input type not allowed.'
+        assert isinstance(input_item['options'], Iterable), 'options must be list type'
+        # option 可用形式:
+        # {value:, label:, [checked:,] [disabled:]}
+        # (value, label, [checked,] [disabled])
+        # value 单值,label等于value
+        opts = input_item['options']
+        opts_res = []
+        for opt in opts:
+            if isinstance(opt, Mapping):
+                assert 'value' in opt and 'label' in opt, 'options item must have value and label key'
+            elif isinstance(opt, list):
+                assert len(opt) > 1 and len(opt) <= 4, 'options item format error'
+                opt = dict(zip(('value', 'label', 'checked', 'disabled'), opt))
+            else:
+                opt = dict(value=opt, label=opt)
+            opts_res.append(opt)
+
+        input_item['options'] = opts_res
+
+    # todo spec参数中,为默认值的可以不发送
+    return input_item
+
+
+def input(label, type=TEXT, *, valid_func=None, name='data', value='', placeholder='', required=False, readonly=False,
+          disabled=False, **other_html_attrs):
+    input_kwargs = dict(locals())
+    input_kwargs['label'] = ''
+    input_kwargs['__name__'] = input.__name__
+    input_kwargs.setdefault('autofocus', True)  # 如果没有设置autofocus参数,则开启参数
+
+    # 参数检查
+    allowed_type = {TEXT, NUMBER, PASSWORD}
+    assert type in allowed_type, 'Input type not allowed.'
+
+    data = yield from input_group(label=label, inputs=[input_kwargs])
+    return data[name]
+
+
+def select(label, options, type=SELECT, *, multiple=False, valid_func=None, name='data', value='', placeholder='',
+           required=False, readonly=False, disabled=False, inline=False, **other_html_attrs):
+    input_kwargs = dict(locals())
+    input_kwargs['label'] = ''
+    input_kwargs['__name__'] = select.__name__
+
+    allowed_type = {CHECKBOX, RADIO, SELECT}
+    assert type in allowed_type, 'Input type not allowed.'
+
+    data = yield from input_group(label=label, inputs=[input_kwargs])
+    return data[name]
+
+
+def _make_actions_input_spec(label, actions, name):
+    """
+    :param label:
+    :param actions: action 列表
+    action 可用形式:
+        {value:, label:, [disabled:]}
+        (value, label, [disabled])
+        value 单值,label等于value
+    :return:
+    """
+    act_res = []
+    for act in actions:
+        if isinstance(act, Mapping):
+            assert 'value' in act and 'label' in act, 'actions item must have value and label key'
+        elif isinstance(act, Sequence):
+            assert len(act) in (2, 3), 'actions item format error'
+            act = dict(zip(('value', 'label', 'disabled'), act))
+        else:
+            act = dict(value=act, label=act)
+        act_res.append(act)
+
+    input_item = dict(type='buttons', label=label, name=name, actions=actions)
+    return input_item
+
+
+def actions(label, actions, name='data'):
+    """
+    选择一个动作。UI为多个按钮,点击后会将整个表单提交
+    :param label:
+    :param actions: action 列表
+    action 可用形式:
+        {value:, label:, [disabled:]}
+        (value, label, [disabled])
+        value 单值,label等于value
+
+    实现方式:
+    多个type=submit的input组成
+    [
+        <button data-name value>label</button>,
+        ...
+    ]
+    """
+    input_kwargs = dict(label='', actions=actions, name=name)
+    input_kwargs['__name__'] = select.__name__
+    data = yield from input_group(label=label, inputs=[input_kwargs])
+    return data[name]
+
+
+def input_group(label, inputs, valid_func=None):
+    """
+    :param label:
+    :param inputs: list of generator or dict, dict的话,需要多加一项 __name__ 为当前函数名
+    :param valid_func: callback(data) -> (name, error_msg)
+    :return:
+    """
+    make_spec_funcs = {
+        actions.__name__: _make_actions_input_spec,
+        input.__name__: _make_input_spec,
+        select.__name__: _make_input_spec
+    }
+
+    item_valid_funcs = {}
+    spec_inputs = []
+    for input_g in inputs:
+        if isinstance(input_g, dict):
+            func_name = input_g.pop('__name__')
+            input_kwargs = input_g
+        else:
+            input_kwargs = dict(input_g.gi_frame.f_locals)  # 拷贝一份,不可以对locals进行修改
+            func_name = input_g.__name__
+
+        input_name = input_kwargs['name']
+        item_valid_funcs[input_name] = input_kwargs['valid_func']
+        input_item = make_spec_funcs[func_name](**input_kwargs)
+        spec_inputs.append(input_item)
+
+    if all('autofocus' not in i for i in spec_inputs):  # 每一个输入项都没有设置autofocus参数
+        for i in spec_inputs:
+            text_inputs = {TEXT, NUMBER, PASSWORD}  # todo update
+            if i.get('type') in text_inputs:
+                i['autofocus'] = True
+                break
 
 
-        }]
-    })
-    input_text = data['data']
-    return input_text['name']
+    send_msg('input_group', dict(label=label, inputs=spec_inputs))
+    data = yield from _input_event_handle(item_valid_funcs, valid_func)
+    send_msg('destroy_form')
+    return data
 
 
 
 
 def ctrl_coro(ctrl_info):
 def ctrl_coro(ctrl_info):
@@ -41,7 +263,6 @@ def ctrl_coro(ctrl_info):
 
 
 
 
 def text_print(text, *, ws=None):
 def text_print(text, *, ws=None):
-    print('text_print', Global.active_ws, text)
     msg = dict(command="output", spec=dict(content=text, type='text'))
     msg = dict(command="output", spec=dict(content=text, type='text'))
     (ws or Global.active_ws).write_message(json.dumps(msg))
     (ws or Global.active_ws).write_message(json.dumps(msg))
 
 

+ 26 - 10
wsrepl/ioloop.py

@@ -5,6 +5,8 @@ from .framework import Global, Msg, Task
 from os.path import abspath, dirname
 from os.path import abspath, dirname
 from tornado.web import StaticFileHandler
 from tornado.web import StaticFileHandler
 from tornado.gen import coroutine, sleep
 from tornado.gen import coroutine, sleep
+from tornado.log import gen_log
+import logging
 
 
 project_dir = dirname(abspath(__file__))
 project_dir = dirname(abspath(__file__))
 
 
@@ -29,11 +31,16 @@ def start_ioloop(coro_func, port=8080):
             self.mark2id = {}  # mark_name -> mark_id
             self.mark2id = {}  # mark_name -> mark_id
 
 
             self.inactive_coro_instances = []  # 待激活的协程实例列表
             self.inactive_coro_instances = []  # 待激活的协程实例列表
-            self.tornado_coro_instances = []  # 待执行的tornado coro列表
+            # self.tornado_coro_instances = []  # 待执行的tornado coro列表
 
 
             task = Task(coro_func(), ws=self)
             task = Task(coro_func(), ws=self)
             self.coros[task.coro_id] = task
             self.coros[task.coro_id] = task
 
 
+            yield task.step()
+            if task.task_finished:
+                gen_log.debug('del self.coros[%s]', task.coro_id)
+                del self.coros[task.coro_id]
+
             yield self.after_step()
             yield self.after_step()
 
 
         @coroutine
         @coroutine
@@ -42,9 +49,14 @@ def start_ioloop(coro_func, port=8080):
                 coro = self.inactive_coro_instances.pop()
                 coro = self.inactive_coro_instances.pop()
                 task = Task(coro, ws=self)
                 task = Task(coro, ws=self)
                 self.coros[task.coro_id] = task
                 self.coros[task.coro_id] = task
+                yield task.step()
+                if self.coros[task.coro_id].task_finished:
+                    gen_log.debug('del self.coros[%s]', task.coro_id)
+                    del self.coros[task.coro_id]
+                # yield self.after_step()
 
 
-            while self.tornado_coro_instances:
-                yield self.tornado_coro_instances.pop()
+            # while self.tornado_coro_instances:
+            #     yield self.tornado_coro_instances.pop()
 
 
         @coroutine
         @coroutine
         def on_message(self, message):
         def on_message(self, message):
@@ -52,17 +64,19 @@ def start_ioloop(coro_func, port=8080):
             # { event:, coro_id:, data: }
             # { event:, coro_id:, data: }
             data = json.loads(message)
             data = json.loads(message)
             coro_id = data['coro_id']
             coro_id = data['coro_id']
+            coro = self.coros.get(coro_id)
+            if not coro_id:
+                gen_log.error('coro not found, coro_id:%s', coro_id)
+                return
 
 
-            res = self.coros[coro_id].step(data)
-            while res is not None:
-                r = yield res
-                res = self.coros[coro_id].step(r)
-
-            yield self.after_step()
+            yield coro.step(data)
 
 
-            if self.coros[coro_id].task_finished:
+            if coro.task_finished:
+                gen_log.debug('del self.coros[%s]', coro_id)
                 del self.coros[coro_id]
                 del self.coros[coro_id]
 
 
+            yield self.after_step()
+
             if not self.coros:
             if not self.coros:
                 self.close()
                 self.close()
 
 
@@ -74,6 +88,8 @@ def start_ioloop(coro_func, port=8080):
                  {"path": '%s/html/' % project_dir,
                  {"path": '%s/html/' % project_dir,
                   'default_filename': 'index.html'})]
                   'default_filename': 'index.html'})]
 
 
+    gen_log.setLevel(logging.DEBUG)
+
     app = tornado.web.Application(handlers=handlers, debug=True)
     app = tornado.web.Application(handlers=handlers, debug=True)
     http_server = tornado.httpserver.HTTPServer(app)
     http_server = tornado.httpserver.HTTPServer(app)
     http_server.listen(port)
     http_server.listen(port)