1
0
Эх сурвалжийг харах

前端按照2/8设想完成,后端存在设计问题

不支持coro未yield from前调用sleep
run_async 也有问题
wangweimin 5 жил өмнө
parent
commit
96464382c6

+ 12 - 3
doc/log.md

@@ -1,5 +1,5 @@
 2/7
-应该不需要msg——id,ws连接是有状态的,不需要msg_id来标示状态。当在多个状态之间切换时,前后端周知,可以同步切换状态。
+应该不需要msg_id,ws连接是有状态的,不需要msg_id来标示状态。当在多个状态之间切换时,前后端周知,可以同步切换状态。
 
 每个ws连接维护一个coro栈,栈顶为当前活跃coro,来消息后激活该coro;前端维护输入栈,栈顶为当前活跃表单,表单控制消息作用于栈顶表单。
 触发上区的一些事件回调时,产生新的coro,压栈;前端当前表单未提交成功又来新表单时,当前表单压栈,显示新表单
@@ -9,5 +9,14 @@
 
 2/8
 需要 msg_id,  或者说是 coro_id/thread_id
-每个ws连接维护一个coros字典,每次根据消息的coro_id判断进入哪一个coro;前端维护form字典,根据指令coro_id判断作用于哪一个表单,并将其置顶。
-触发上区的一些事件回调时,产生新的coro;前端当前表单未提交成功又来新表单时,当前表单隐藏,显示新表单
+每个ws连接维护一个coros字典,每次根据消息的coro_id判断进入哪一个coro;前端维护form字典: coro_id -> form_handler栈,根据指令coro_id判断作用于哪一个表单,并将其置顶。
+触发上区的一些事件回调时,产生新的coro;前端当前表单未提交成功又来新表单时,当前表单隐藏,显示新表单
+
+
+
+
+2/9
+NOTE: 
+含有yield的函数一定是生成器,不管会不会执行到 (比如在分支里)
+
+coro.send 内部可能还会存在 激活协程的调用,要禁止或者将Global改成栈式存储

+ 99 - 0
doc/spec.md

@@ -0,0 +1,99 @@
+服务器->客户端
+{
+    command: ""
+    coro_id: ""
+   	spec: {}
+}
+命令名:
+    参数1:
+    参数2:
+    
+
+
+## 命令
+
+继承关系:
+全局:
+    <button>
+    输入类:
+        <input>
+        <select>
+        <textarea>
+
+全局参数 (带*号的必须, ~可选, ^为非html属性)
+    *^label
+    ^help_text
+    ^invalid_feedback
+    ^valid_feedback
+    输入类全局参数
+
+
+<input> 类命令  // 全局 <input> 参数  ref: https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input
+    *name
+    *type
+    readonly/disabled:bool 禁用的控件的值在提交表单时也不会被提交
+    required:
+    value:
+    placeholder: placeholder 属性是提示用户内容的输入格式。某些情况下 placeholder 属性对用户不可见, 所以当没有它时也需要保证form能被理解。
+    ^on_focus
+    ^on_blur
+    ^inline  // type==checkbox,radio
+    ^options // type==checkbox,radio , 字典列表 {*value:, *label:, checked,disabled }
+
+
+
+<button>
+ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/button
+
+<select>
+ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/select
+
+<textarea>
+ref https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/textarea
+
+
+input_group:
+    label:
+    inputs: [ <input>, ] // 若只有一个input 则可以忽略其label属性
+
+
+
+控制类指令
+update_input:
+    target_name: input主键name
+    ~target_value:str 用于checkbox, radio 过滤input 
+    attributes: {
+        valid_status: bool 输入值的有效性,通过/不通过
+        value:
+        placeholder:
+        ...  // 不支持 on_focus on_blur inline label
+    }
+    
+
+destroy_form:
+    无spec
+
+output:
+    type: text
+    content: {}
+
+
+客户端->服务器
+{
+    event: ""
+    coro_id: ""
+   	data: {}
+}
+事件名:
+    数据项1:
+    数据项2:
+
+input_event
+    event_name: on_blur
+    name:
+    value:
+
+from_submit:
+    
+
+

+ 81 - 12
test.py

@@ -8,22 +8,91 @@ from wsrepl.interact import *
 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():
+    # yield sleep(0.5)
+
     # 向用户输出文字
     text_print("Welcome!!!")
-    name = yield from text_input_coro('input your name')
-    text_print("Hello %s!" % name)
-
-    for i in range(3):
-        yield sleep(1)
-        text_print("%s" % i)
-
-    age = yield from text_input_coro('input your age')
-    if int(age) < 30:
-        text_print("Wow. So young!!")
-    else:
-        text_print("Old man~")
+    # 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("收到")
+
+    yield from get_response("destroy_form", spec={})
+
+    text_print("Bye ")
+
+    yield sleep(1)
 
 
 start_ioloop(say_hello)

+ 51 - 15
wsrepl/framework.py

@@ -1,6 +1,9 @@
 import tornado.websocket
 import time, json
 from collections import defaultdict
+from tornado.gen import coroutine, sleep
+import random, string
+from contextlib import contextmanager
 
 
 class Future:
@@ -13,27 +16,58 @@ class Future:
 
 
 class Task:
-    def __init__(self, coro):
+    @contextmanager
+    def ws_context(self):
+        """
+        >>> with ws_context():
+        ...     res = self.coros[-1].send(data)
+        """
+        Global.active_ws = self.ws
+        Global.active_coro_id = self.coro_id
+        try:
+            yield
+        finally:
+            Global.active_ws = None
+            Global.active_coro_id = None
+
+    @staticmethod
+    def gen_coro_id(coro=None):
+        name = 'coro'
+        if hasattr(coro, '__name__'):
+            name = coro.__name__
+
+        random_str = ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(10))
+        return '%s-%s' % (name, random_str)
+
+    def __init__(self, coro, ws):
+        print('into Task __init__ `', coro, ws)
+        self.ws = ws
         self.coro = coro
-        f = Future()
-        f.set_result(None)
-        self.step(f)
+        self.coro_id = None
+        self.result = None
+        self.task_finished = False  # 协程完毕
 
-        self.result = None  # 协程的返回值
-        self.on_task_finish = None  # 协程完毕的回调函数
+        self.coro_id = self.gen_coro_id(self.coro)
 
-    def step(self, future):
+        # 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):
         try:
-            # send会进入到coro执行, 即fetch, 直到下次yield
-            # next_future 为yield返回的对象
-            next_future = self.coro.send(future.result)
-            next_future.add_done_callback(self.step)
+            with self.ws_context():
+                res = self.coro.send(result)
+            return res
         except StopIteration as e:
             if len(e.args) == 1:
                 self.result = e.args[0]
-            if self.on_task_finish:
-                self.on_task_finish(self.result)
-            return
+
+            self.task_finished = Task
+
+            # raise
 
 
 class Msg:
@@ -62,4 +96,6 @@ class Msg:
 
 
 class Global:
-    active_ws: "EchoWebSocket"
+    # todo issue: with 语句可能发生嵌套,导致内层with退出时,将属性置空
+    active_ws: "EchoWebSocket" = None
+    active_coro_id = None

+ 19 - 14
wsrepl/html/bs.html

@@ -48,10 +48,10 @@
     </form>
 
     <hr>
-    <form>
+    <form id="f">
         <div class="form-group">
-            <label for="exampleInputPassword2">Input</label>
-            <input type="password" class="form-control" id="exampleInputPassword2">
+            <label for="num_input">Input</label>
+            <input type="number" class="form-control"  id="num_input">
         </div>
         <div class="form-group">
             <label for="exampleFormControlSelect1">Select</label>
@@ -83,7 +83,7 @@
 
         <div class="form-group">
             <label for="exampleFormControlSelect2">Multiple select</label>
-            <select multiple class="form-control" id="exampleFormControlSelect2">
+            <select multiple class="form-control" id="exampleFormControlSelect2" name="multiple_select">
                 <option>1</option>
                 <option>2</option>
                 <option>3</option>
@@ -121,47 +121,52 @@
         <div class="form-group">
             <label>Radio inline</label> <br>
             <div class="form-check form-check-inline">
-                <input class="form-check-input" type="radio" name="exampleRadios" id="exampleRadios1" value="option1"
+                <input class="form-check-input" type="radio" name="exampleRadios1" id="exampleRadios21" value="option1"
                        checked>
-                <label class="form-check-label" for="exampleRadios1">
+                <label class="form-check-label" for="exampleRadios21">
                     Option one
                 </label>
             </div>
             <div class="form-check form-check-inline">
-                <input class="form-check-input" type="radio" name="exampleRadios" id="exampleRadios2" value="option2">
-                <label class="form-check-label" for="exampleRadios2">
+                <input class="form-check-input is-invalid" type="radio" name="exampleRadios1" id="exampleRadios22" value="option2">
+                <label class="form-check-label" for="exampleRadios22">
                     Option two
                 </label>
             </div>
+            <div class="invalid-feedback">invalid-feedback.</div>  <!-- input 添加 is-invalid 类 -->
+            <small class="form-text text-muted">We'll never share your email with anyone else.</small>
         </div>
 
         <div class="form-group">
             <label>Checkout</label>
             <div class="form-check">
-                <input class="form-check-input" type="checkbox" value="" id="defaultCheck1">
+                <input class="form-check-input is-invalid" type="checkbox" value="" id="defaultCheck1" name="defaultCheck1">
                 <label class="form-check-label" for="defaultCheck1">
                     Option one is this and that—be sure to include why it's great
                 </label>
+
             </div>
             <div class="form-check">
-                <input class="form-check-input" type="checkbox" value="" id="defaultCheck2">
+                <input class="form-check-input is-invalid" type="checkbox" value="" id="defaultCheck2" name="defaultCheck1">
                 <label class="form-check-label" for="defaultCheck2">
                     Option two is disabled
                 </label>
             </div>
+            <div class="invalid-feedback">invalid-feedback.</div>  <!-- input 添加 is-invalid 类 -->
+            <small class="form-text text-muted">We'll never share your email with anyone else.</small>
         </div>
 
         <div class="form-group">
             <label>Checkout inline</label> <br>
             <div class="form-check form-check-inline">
-                <input class="form-check-input" type="checkbox" value="" id="defaultCheck1">
-                <label class="form-check-label" for="defaultCheck1">
+                <input class="form-check-input" type="checkbox" value="" id="defaultCheck21"  name="defaultCheck2">
+                <label class="form-check-label" for="defaultCheck21">
                     Option one
                 </label>
             </div>
             <div class="form-check form-check-inline">
-                <input class="form-check-input" type="checkbox" value="" id="defaultCheck2">
-                <label class="form-check-label" for="defaultCheck2">
+                <input class="form-check-input" type="checkbox" value="" id="defaultCheck22"  name="defaultCheck2">
+                <label class="form-check-label" for="defaultCheck22">
                     Option two
                 </label>
             </div>

+ 22 - 29
wsrepl/html/index.html

@@ -16,7 +16,6 @@
 
         #input-container {
             margin-top: 20px;
-            display: none;
         }
 
         #title {
@@ -55,34 +54,14 @@
         </div>
     </div>
 
-    <div class="card" id="input-container">
-        <div>
-            <h5 class="card-header">需要您输入</h5>
-            <div class="card-body">
-                <label for="basic-url">Your vanity URL</label>
-                <div class="input-group mb-3">
-                    <div class="input-group-prepend">
-                        <span class="input-group-text" id="basic-addon3">Prompt</span>
-                    </div>
-                    <input type="text" class="form-control" id="basic-url" aria-describedby="basic-addon3">
-                </div>
-                <a href="#" class="btn btn-primary">提交</a>
-
-                <div class="input-group mb-3">
-                    <input type="text" class="form-control" placeholder="Recipient's username"
-                           aria-label="Recipient's username" aria-describedby="button-addon2">
-                    <div class="input-group-append">
-                        <button class="btn btn-outline-secondary" type="button" id="button-addon2">Button</button>
-                    </div>
-                </div>
-            </div>
-        </div>
+    <div id="input-container">
+
     </div>
 </div>
 
-
+<script src="js/mustache.min.js"></script>
 <script src="js/mditor.min.js"></script>
-<script src="js/app.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/popper.js@1.16.0/dist/umd/popper.min.js"
         integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
@@ -91,19 +70,33 @@
         integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
         crossorigin="anonymous"></script>
 <script>
+    var md_body = $('#markdown-body');
 
     var ws = new WebSocket("ws://localhost:8080/test");
-    var handles = get_handles(ws);
+
+    var ctrl = new WSREPL.WSREPLController(ws, md_body, $('#input-container'));
+
+    var old_send = ws.send;
+    ws.send = function (d) {
+        console.log('<<<', JSON.parse(d));
+        old_send.apply(this, arguments);
+    };
 
     ws.onopen = function () {
         // ws.send("Hello, world");
     };
+    var viewer = $('.viewer');
     ws.onmessage = function (evt) {
-        console.log(">>>", evt.data);
+        viewer.scrollTop(md_body[0].scrollHeight);  // 自动滚动
+
         var msg = JSON.parse(evt.data);
-        handles[msg.command](msg.spec);
+        console.log('>>>', msg);
+        ctrl.handle_message(msg);
     };
-
+    ws.onclose = function () {
+        document.title = 'Closed';
+        $('#title').text('Closed');
+    }
 </script>
 
 

+ 18 - 5
wsrepl/html/js/app.js

@@ -1,8 +1,21 @@
+var ws = new WebSocket("ws://localhost:8080/test");
+var handles = get_handles(ws);
+
+ws.onopen = function () {
+    // ws.send("Hello, world");
+};
+ws.onmessage = function (evt) {
+    console.log(">>>", evt.data);
+    var msg = JSON.parse(evt.data);
+    handles[msg.command](msg.spec, msg.coro_id);
+};
 
 var input_item = {
-    html:'',
-    set_invalid: msg => {},
-    set_valid: msg => {},
+    html: '',
+    set_invalid: msg => {
+    },
+    set_valid: msg => {
+    },
 
 };
 
@@ -26,7 +39,7 @@ function get_handles(ws) {
 
 
     var handles = {
-        text_input: function (spec) {
+        text_input: function (spec, coro_id) {
 
             var html = `<h5 class="card-header">需要您输入</h5>
             <div class="card-body">
@@ -52,7 +65,7 @@ function get_handles(ws) {
             $('#input-form').submit(function (e) {
                 e.preventDefault(); // avoid to execute the actual submit of the form.
 
-                ws.send(JSON.stringify({msg_id: spec.msg_id, data: $('#input-1').val()}));
+                ws.send(JSON.stringify({coro_id: coro_id, msg_id: spec.msg_id, data: $('#input-1').val()}));
 
                 input_container.hide(100);
                 input_container.empty();

+ 417 - 0
wsrepl/html/js/form.js

@@ -0,0 +1,417 @@
+(function (global, factory) {
+    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+        typeof define === 'function' && define.amd ? define(factory) :
+            (global = global || self, global.WSREPL = factory());
+}(this, (function () {
+    'use strict';
+
+    function extend(Child, Parent) {
+        var F = function () {
+        };
+        F.prototype = Parent.prototype;
+        Child.prototype = new F();
+        Child.prototype.constructor = Child;
+        Child.uber = Parent.prototype;
+    }
+
+    function make_set(arr) {
+        var set = {};
+        for (var idx in arr)
+            set[arr[idx]] = '';
+        return set;
+    }
+
+    function deep_copy(obj) {
+        return JSON.parse(JSON.stringify(obj));
+    }
+
+    function LRUMap() {
+        this.keys = [];
+        this.map = {};
+
+        this.push = function (key, value) {
+            if (key in this.map)
+                return console.error("LRUMap: key:%s already in map", key);
+            this.keys.push(key);
+            this.map[key] = value;
+        };
+
+        this.get_value = function (key) {
+            return this.map[key];
+        };
+        this.get_top = function () {
+            var top_key = this.keys[this.keys.length - 1];
+            return this.map[top_key];
+        };
+        this.set_value = function (key, value) {
+            if (!(key in this.map))
+                return console.error("LRUMap: key:%s not in map when call `set_value`", key);
+            this.map[key] = value;
+        };
+
+        this.move_to_top = function (key) {
+            const index = this.keys.indexOf(key);
+            if (index > -1) {
+                this.keys.splice(index, 1);
+                this.keys.push(key);
+            } else {
+                return console.error("LRUMap: key:%s not in map when call `move_to_top`", key);
+            }
+        };
+
+        this.remove = function (key) {
+            if (key in this.map) {
+                delete this.map[key];
+                this.keys.splice(this.keys.indexOf(key), 1);
+            } else {
+                return console.error("LRUMap: key:%s not in map when call `remove`", key);
+            }
+        };
+    }
+
+    function OutputController(ws_client, container_elem) {
+        this.ws_client = ws_client;
+        this.container_elem = container_elem;
+        this.md_parser = new Mditor.Parser();
+
+        this.handle_message = function (msg) {
+            this.container_elem[0].innerHTML += this.md_parser.parse(msg.spec.content);
+        }
+    }
+
+    OutputController.prototype.accept_command = ['output'];
+
+
+    FormsController.prototype.accept_command = ['input', 'input_group', 'update_input', 'destroy_form'];
+
+    function FormsController(ws_client, container_elem) {
+        this.ws_client = ws_client;
+        this.container_elem = container_elem;
+
+        this.form_ctrls = new LRUMap(); // coro_id -> stack of FormGroupController
+
+        // hide old_ctrls显示的表单,激活coro_id对应的表单
+        // 需要保证 coro_id 对应有表单
+        this._activate_form = function (coro_id, old_ctrl) {
+            var ctrls = this.form_ctrls.get_value(coro_id);
+            var ctrl = ctrls[ctrls.length - 1];
+            if (ctrl === old_ctrl || old_ctrl === undefined)
+                return ctrl.element.show(100);
+            this.form_ctrls.move_to_top(coro_id);
+            old_ctrl.element.hide(100, () => {
+                ctrl.element.show(100);
+            });
+        };
+
+        /*
+        * 每次函数调用返回后,this.form_ctrls.get_top()的栈顶对应的表单为当前活跃表单
+        * */
+        this.handle_message = function (msg) {
+            var old_ctrls = this.form_ctrls.get_top();
+            var old_ctrl = old_ctrls && old_ctrls[old_ctrls.length - 1];
+            var target_ctrls = this.form_ctrls.get_value(msg.coro_id);
+            if (target_ctrls === undefined) {
+                this.form_ctrls.push(msg.coro_id, []);
+                target_ctrls = this.form_ctrls.get_value(msg.coro_id);
+            }
+
+            // 创建表单
+            if (msg.command in make_set(['input', 'input_group'])) {
+                var ctrl = new FormController(this.ws_client, msg.coro_id, msg.spec);
+                target_ctrls.push(ctrl);
+                this.container_elem.append(ctrl.element);
+                this._activate_form(msg.coro_id, old_ctrl);
+            } else if (msg.command in make_set(['update_input'])) {
+                // 更新表单
+                if (target_ctrls.length === 0) {
+                    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);
+                // 表单前置
+                this._activate_form(msg.coro_id, old_ctrl);
+            } else if (msg.command === 'destroy_form') {
+                if (target_ctrls.length === 0) {
+                    return console.error('No form to current message. coro_id:%s', msg.coro_id);
+                }
+                var deleted = target_ctrls.pop();
+                if (target_ctrls.length === 0)
+                    this.form_ctrls.remove(msg.coro_id);
+
+                // 销毁的是当前显示的form
+                if (old_ctrls === target_ctrls) {
+                    var that = this;
+                    deleted.element.hide(100, () => {
+                        var t = that.form_ctrls.get_top();
+                        if (t) t[t.length - 1].element.show(100);
+                    });
+                }
+            }
+            // todo: 如果当前栈顶key is not coro_id, hide show, move to top
+        }
+    }
+
+
+    function FormStack() {
+        push();
+        pop();
+        empty();
+
+        show();// 显示栈顶元素
+        hide();// 隐藏栈顶元素
+    }
+
+
+    function FormController(ws_client, coro_id, spec) {
+        this.ws_client = ws_client;
+        this.coro_id = coro_id;
+        this.spec = spec;
+
+        this.element = undefined;
+        this.input_controllers = {};  // name -> input_controller
+
+        this.create_element();
+    }
+
+    FormController.prototype.create_element = function () {
+        var tpl = `
+        <div class="card" style="display: none">
+            <h5 class="card-header">{{label}}</h5>
+            <div class="card-body">
+                <form>
+                    <div class="input-container"></div>
+                    <button type="submit" class="btn btn-primary">提交</button>
+                    <button type="reset" class="btn btn-warning">重置</button>
+                </form>
+            </div>
+        </div>`;
+
+        const html = Mustache.render(tpl, {label: this.spec.label});
+        this.element = $(html);
+
+        // 输入控件创建
+        var body = this.element.find('.input-container');
+        for (var idx in this.spec.inputs) {
+            var i = this.spec.inputs[idx];
+            var ctrl;
+            if (i.type in make_set(CommonInputController.prototype.accept_input_types)) {
+                ctrl = new CommonInputController(this.ws_client, this.coro_id, i);
+            } else if (i.type in make_set(CheckboxRadioController.prototype.accept_input_types)) {
+                ctrl = new CheckboxRadioController(this.ws_client, this.coro_id, i);
+            }
+
+            this.input_controllers[i.name] = ctrl;
+            body.append(ctrl.element);
+        }
+
+        // 事件绑定
+        var that = this;
+        this.element.on('submit', 'form', function (e) {
+            e.preventDefault(); // avoid to execute the actual submit of the form.
+            var inputs = $(this).serializeArray();
+            var data = {};
+            $.each(inputs, (idx, item) => {
+                if (data[item.name] === undefined) data[item.name] = [];
+                data[item.name].push(item.value);
+            });
+            ws.send(JSON.stringify({
+                event: "from_submit",
+                coro_id: that.coro_id,
+                data: data
+            }));
+        })
+    };
+
+    FormController.prototype.dispatch_ctrl_message = function (spec) {
+        if (!(spec.target_name in this.input_controllers)) {
+            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);
+    };
+
+
+    function FormItemController(ws_client, coro_id, spec) {
+        this.ws_client = ws_client;
+        this.coro_id = coro_id;
+        this.spec = spec;
+        this.element = undefined;
+
+        var that = this;
+        this.send_value_listener = function (e) {
+            var this_elem = $(this);
+            that.ws_client.send(JSON.stringify({
+                event: "input_event",
+                coro_id: that.coro_id,
+                data: {
+                    event_name: e.type.toLowerCase(),
+                    name: this_elem.attr('name'),
+                    value: this_elem.val()
+                }
+            }));
+        };
+
+        /*
+        * input_idx: 更新作用对象input标签的索引, -1 为不指定对象
+        * attributes:更新值字典
+        * */
+        this.update_input_helper = function (input_idx, attributes) {
+            var attr2selector = {
+                'invalid_feedback': 'div.invalid-feedback',
+                'valid_feedback': 'div.valid-feedback',
+                'help_text': 'small.text-muted'
+            };
+            for (var attribute in attr2selector) {
+                if (attribute in attributes) {
+                    if (input_idx === -1)
+                        this.element.find(attr2selector[attribute]).text(attributes[attribute]);
+                    else
+                        this.element.find(attr2selector[attribute]).eq(input_idx).text(attributes[attribute]);
+                    delete attributes[attribute];
+                }
+            }
+
+            var input_elem = this.element.find('input');
+            if (input_idx >= 0)
+                input_elem = input_elem.eq(input_idx);
+
+            if ('valid_status' in attributes) {
+                var class_name = attributes.valid_status ? 'is-valid' : 'is-invalid';
+                input_elem.removeClass('is-valid is-invalid').addClass(class_name);
+                delete attributes.valid_status;
+            }
+
+            input_elem.attr(attributes);
+        }
+    }
+
+
+    function CommonInputController(ws_client, coro_id, spec) {
+        FormItemController.apply(this, arguments);
+
+        this.create_element();
+    }
+
+    CommonInputController.prototype.accept_input_types = ["text", "password", "number", "color", "date", "range", "time"];
+    /*
+    *
+    * type=
+    * */
+    const common_input_tpl = `
+<div class="form-group">
+    <label for="{{id_name}}">{{label}}</label>
+    <input type="{{type}}" id="{{id_name}}" aria-describedby="{{id_name}}_help"  class="form-control">
+    <div class="invalid-feedback">{{invalid_feedback}}</div>  <!-- input 添加 is-invalid 类 -->
+    <div class="valid-feedback">{{valid_feedback}}</div> <!-- input 添加 is-valid 类 -->
+    <small id="{{id_name}}_help" class="form-text text-muted">{{help_text}}</small>
+</div>`;
+    CommonInputController.prototype.create_element = function () {
+        var spec = deep_copy(this.spec);
+        const id_name = spec.name + '-' + Math.floor(Math.random() * Math.floor(9999));
+        spec['id_name'] = id_name;
+        const html = Mustache.render(common_input_tpl, spec);
+
+        this.element = $(html);
+        var input_elem = this.element.find('#' + id_name);
+
+        // blur事件时,发送当前值到服务器
+        input_elem.on('blur', this.send_value_listener);
+
+        // 将额外的html参数加到input标签上
+        const ignore_keys = {'type': '', 'label': '', 'invalid_feedback': '', 'valid_feedback': '', 'help_text': ''};
+        for (var key in this.spec) {
+            if (key in ignore_keys) continue;
+            input_elem.attr(key, this.spec[key]);
+        }
+    };
+
+    CommonInputController.prototype.update_input = function (spec) {
+        var attributes = spec.attributes;
+
+        this.update_input_helper(-1, attributes);
+    };
+
+    function CheckboxRadioController(ws_client, coro_id, spec) {
+        FormItemController.apply(this, arguments);
+
+        this.create_element();
+    }
+
+    CheckboxRadioController.prototype.accept_input_types = ["checkbox", "radio"];
+
+    const checkbox_radio_tpl = `
+<div class="form-group">
+    <label>{{label}}</label> {{#inline}}<br>{{/inline}}
+    {{#options}}
+    <div class="form-check {{#inline}}form-check-inline{{/inline}}">
+        <input type="{{type}}" id="{{id_name_prefix}}-{{idx}}" class="form-check-input" name="{{name}}" value="{{value}}">
+        <label class="form-check-label" for="{{id_name_prefix}}-{{idx}}">
+            {{label}}
+        </label>
+        <div class="invalid-feedback">{{invalid_feedback}}</div>  <!-- input 添加 is-invalid 类 -->
+        <div class="valid-feedback">{{valid_feedback}}</div> <!-- input 添加 is-valid 类 -->
+    </div>
+    {{/options}}
+    <small id="{{id_name}}_help" class="form-text text-muted">{{help_text}}</small>
+</div>`;
+
+    CheckboxRadioController.prototype.create_element = function () {
+        var spec = deep_copy(this.spec);
+        const id_name_prefix = spec.name + '-' + Math.floor(Math.random() * Math.floor(9999));
+        spec['id_name_prefix'] = id_name_prefix;
+        for (var idx in spec.options) {
+            spec.options[idx]['idx'] = idx;
+        }
+        const html = Mustache.render(checkbox_radio_tpl, spec);
+        var elem = $(html);
+        this.element = elem;
+
+        const ignore_keys = {'value': '', 'label': ''};
+        for (idx = 0; idx < this.spec.options.length; idx++) {
+            var input_elem = elem.find('#' + id_name_prefix + '-' + idx);
+            // blur事件时,发送当前值到服务器
+            input_elem.on('blur', this.send_value_listener);
+
+            // 将额外的html参数加到input标签上
+            for (var key in this.spec.options[idx]) {
+                if (key in ignore_keys) continue;
+                input_elem.attr(key, this.spec[key]);
+            }
+        }
+    };
+
+    CheckboxRadioController.prototype.update_input = function (spec) {
+        var attributes = spec.attributes;
+        var idx = -1;
+        if ('target_value' in spec) {
+            this.element.find('input').each(function (index) {
+                if ($(this).val() == spec.target_value) {
+                    idx = index;
+                    return false;
+                }
+            });
+        }
+        this.update_input_helper(idx, attributes);
+    };
+
+
+    function WSREPLController(ws_client, output_container_elem, input_container_elem) {
+        this.output_ctrl = new OutputController(ws_client, output_container_elem);
+        this.input_ctrl = new FormsController(ws_client, input_container_elem);
+
+        this.output_cmds = make_set(this.output_ctrl.accept_command);
+        this.input_cmds = make_set(this.input_ctrl.accept_command);
+        this.handle_message = function (msg) {
+            if (msg.command in this.input_cmds)
+                this.input_ctrl.handle_message(msg);
+            else if (msg.command in this.output_cmds)
+                this.output_ctrl.handle_message(msg);
+            else
+                console.error('Unknown command:%s', msg.command);
+        };
+    }
+
+    return {
+        'WSREPLController': WSREPLController
+    }
+
+})));

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
wsrepl/html/js/mustache.min.js


+ 26 - 6
wsrepl/interact.py

@@ -4,21 +4,35 @@ from collections import defaultdict
 from .framework import Future, Msg, Global
 
 
-def _get_response(cmd, spec):
+def run_async(coro):
+    Global.active_ws.inactive_coro_instances.append(coro)
 
-    msg = dict(command=cmd, spec=spec)
+
+def send_msg(cmd, spec):
+    msg = dict(command=cmd, spec=spec, coro_id=Global.active_coro_id)
     Global.active_ws.write_message(json.dumps(msg))
 
-    response_msg = yield from Future()
 
+def get_response(cmd, spec):
+    send_msg(cmd, spec)
+    response_msg = yield from Future()
     return response_msg
 
 
 # 非阻塞协程工具库
 def text_input_coro(prompt):
-    data = yield from _get_response("text_input", spec=dict(prompt=prompt))
+    data = yield from get_response("input_group", spec={
+        "label": prompt,
+        "inputs": [{
+            'name': 'name',
+            'type': 'text',
+            'label': prompt,
+            'help_text': 'help_text',
+
+        }]
+    })
     input_text = data['data']
-    return input_text
+    return input_text['name']
 
 
 def ctrl_coro(ctrl_info):
@@ -27,5 +41,11 @@ def ctrl_coro(ctrl_info):
 
 
 def text_print(text, *, ws=None):
-    msg = dict(command="text_print", spec=dict(content=text))
+    print('text_print', Global.active_ws, text)
+    msg = dict(command="output", spec=dict(content=text, type='text'))
     (ws or Global.active_ws).write_message(json.dumps(msg))
+
+
+def json_print(obj):
+    text = "```\n%s\n```" % json.dumps(obj, indent=4, ensure_ascii=False)
+    text_print(text)

+ 37 - 18
wsrepl/ioloop.py

@@ -4,13 +4,14 @@ from collections import defaultdict, OrderedDict
 from .framework import Global, Msg, Task
 from os.path import abspath, dirname
 from tornado.web import StaticFileHandler
-from tornado.gen import coroutine,sleep
+from tornado.gen import coroutine, sleep
 
 project_dir = dirname(abspath(__file__))
 
 
-def start_ioloop(coro, port=8080):
+def start_ioloop(coro_func, port=8080):
     class EchoWebSocket(tornado.websocket.WebSocketHandler):
+
         def check_origin(self, origin):
             return True
 
@@ -18,33 +19,51 @@ def start_ioloop(coro, port=8080):
             # Non-None enables compression with default options.
             return {}
 
+        @coroutine
         def open(self):
             print("WebSocket opened")
             self.set_nodelay(True)
             ############
-
-            self.coros = [coro()]
+            self.coros = {}  # coro_id -> coro
             self.callbacks = OrderedDict()  # UI元素时的回调, key -> callback, mark_id
-            self.mark2id = {}  # 锚点 -> id
+            self.mark2id = {}  # mark_name -> mark_id
+
+            self.inactive_coro_instances = []  # 待激活的协程实例列表
+            self.tornado_coro_instances = []  # 待执行的tornado coro列表
+
+            task = Task(coro_func(), ws=self)
+            self.coros[task.coro_id] = task
+
+            yield self.after_step()
 
-            Global.active_ws = self
-            next(self.coros[-1])
+        @coroutine
+        def after_step(self):
+            while self.inactive_coro_instances:
+                coro = self.inactive_coro_instances.pop()
+                task = Task(coro, ws=self)
+                self.coros[task.coro_id] = task
+
+            while self.tornado_coro_instances:
+                yield self.tornado_coro_instances.pop()
 
         @coroutine
         def on_message(self, message):
             print('on_message', message)
-            # { event: , data: }
+            # { event:, coro_id:, data: }
             data = json.loads(message)
-            try:
-                Global.active_ws = self
-                res = self.coros[-1].send(data)
-                while res is not None:
-                    print('get not none form coro ', res)
-                    yield res
-                    Global.active_ws = self
-                    res = self.coros[-1].send(data)
-
-            except StopIteration:
+            coro_id = data['coro_id']
+
+            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()
+
+            if self.coros[coro_id].task_finished:
+                del self.coros[coro_id]
+
+            if not self.coros:
                 self.close()
 
         def on_close(self):

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно