Bläddra i källkod

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

不支持coro未yield from前调用sleep
run_async 也有问题
wangweimin 5 år sedan
förälder
incheckning
96464382c6
11 ändrade filer med 782 tillägg och 102 borttagningar
  1. 12 3
      doc/log.md
  2. 99 0
      doc/spec.md
  3. 81 12
      test.py
  4. 51 15
      wsrepl/framework.py
  5. 19 14
      wsrepl/html/bs.html
  6. 22 29
      wsrepl/html/index.html
  7. 18 5
      wsrepl/html/js/app.js
  8. 417 0
      wsrepl/html/js/form.js
  9. 0 0
      wsrepl/html/js/mustache.min.js
  10. 26 6
      wsrepl/interact.py
  11. 37 18
      wsrepl/ioloop.py

+ 12 - 3
doc/log.md

@@ -1,5 +1,5 @@
 2/7
 2/7
-应该不需要msg——id,ws连接是有状态的,不需要msg_id来标示状态。当在多个状态之间切换时,前后端周知,可以同步切换状态。
+应该不需要msg_id,ws连接是有状态的,不需要msg_id来标示状态。当在多个状态之间切换时,前后端周知,可以同步切换状态。
 
 
 每个ws连接维护一个coro栈,栈顶为当前活跃coro,来消息后激活该coro;前端维护输入栈,栈顶为当前活跃表单,表单控制消息作用于栈顶表单。
 每个ws连接维护一个coro栈,栈顶为当前活跃coro,来消息后激活该coro;前端维护输入栈,栈顶为当前活跃表单,表单控制消息作用于栈顶表单。
 触发上区的一些事件回调时,产生新的coro,压栈;前端当前表单未提交成功又来新表单时,当前表单压栈,显示新表单
 触发上区的一些事件回调时,产生新的coro,压栈;前端当前表单未提交成功又来新表单时,当前表单压栈,显示新表单
@@ -9,5 +9,14 @@
 
 
 2/8
 2/8
 需要 msg_id,  或者说是 coro_id/thread_id
 需要 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
 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!!!")
-    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)
 start_ioloop(say_hello)

+ 51 - 15
wsrepl/framework.py

@@ -1,6 +1,9 @@
 import tornado.websocket
 import tornado.websocket
 import time, json
 import time, json
 from collections import defaultdict
 from collections import defaultdict
+from tornado.gen import coroutine, sleep
+import random, string
+from contextlib import contextmanager
 
 
 
 
 class Future:
 class Future:
@@ -13,27 +16,58 @@ class Future:
 
 
 
 
 class Task:
 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
         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:
         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:
         except StopIteration as e:
             if len(e.args) == 1:
             if len(e.args) == 1:
                 self.result = e.args[0]
                 self.result = e.args[0]
-            if self.on_task_finish:
-                self.on_task_finish(self.result)
-            return
+
+            self.task_finished = Task
+
+            # raise
 
 
 
 
 class Msg:
 class Msg:
@@ -62,4 +96,6 @@ class Msg:
 
 
 
 
 class Global:
 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>
     </form>
 
 
     <hr>
     <hr>
-    <form>
+    <form id="f">
         <div class="form-group">
         <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>
         <div class="form-group">
         <div class="form-group">
             <label for="exampleFormControlSelect1">Select</label>
             <label for="exampleFormControlSelect1">Select</label>
@@ -83,7 +83,7 @@
 
 
         <div class="form-group">
         <div class="form-group">
             <label for="exampleFormControlSelect2">Multiple select</label>
             <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>1</option>
                 <option>2</option>
                 <option>2</option>
                 <option>3</option>
                 <option>3</option>
@@ -121,47 +121,52 @@
         <div class="form-group">
         <div class="form-group">
             <label>Radio inline</label> <br>
             <label>Radio inline</label> <br>
             <div class="form-check form-check-inline">
             <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>
                        checked>
-                <label class="form-check-label" for="exampleRadios1">
+                <label class="form-check-label" for="exampleRadios21">
                     Option one
                     Option one
                 </label>
                 </label>
             </div>
             </div>
             <div class="form-check form-check-inline">
             <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
                     Option two
                 </label>
                 </label>
             </div>
             </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>
 
 
         <div class="form-group">
         <div class="form-group">
             <label>Checkout</label>
             <label>Checkout</label>
             <div class="form-check">
             <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">
                 <label class="form-check-label" for="defaultCheck1">
                     Option one is this and that—be sure to include why it's great
                     Option one is this and that—be sure to include why it's great
                 </label>
                 </label>
+
             </div>
             </div>
             <div class="form-check">
             <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">
                 <label class="form-check-label" for="defaultCheck2">
                     Option two is disabled
                     Option two is disabled
                 </label>
                 </label>
             </div>
             </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>
 
 
         <div class="form-group">
         <div class="form-group">
             <label>Checkout inline</label> <br>
             <label>Checkout inline</label> <br>
             <div class="form-check form-check-inline">
             <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
                     Option one
                 </label>
                 </label>
             </div>
             </div>
             <div class="form-check form-check-inline">
             <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
                     Option two
                 </label>
                 </label>
             </div>
             </div>

+ 22 - 29
wsrepl/html/index.html

@@ -16,7 +16,6 @@
 
 
         #input-container {
         #input-container {
             margin-top: 20px;
             margin-top: 20px;
-            display: none;
         }
         }
 
 
         #title {
         #title {
@@ -55,34 +54,14 @@
         </div>
         </div>
     </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>
 </div>
 </div>
 
 
-
+<script src="js/mustache.min.js"></script>
 <script src="js/mditor.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/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"
         integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
         integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
@@ -91,19 +70,33 @@
         integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
         integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
         crossorigin="anonymous"></script>
         crossorigin="anonymous"></script>
 <script>
 <script>
+    var md_body = $('#markdown-body');
 
 
     var ws = new WebSocket("ws://localhost:8080/test");
     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.onopen = function () {
         // ws.send("Hello, world");
         // ws.send("Hello, world");
     };
     };
+    var viewer = $('.viewer');
     ws.onmessage = function (evt) {
     ws.onmessage = function (evt) {
-        console.log(">>>", evt.data);
+        viewer.scrollTop(md_body[0].scrollHeight);  // 自动滚动
+
         var msg = JSON.parse(evt.data);
         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>
 </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 = {
 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 = {
     var handles = {
-        text_input: function (spec) {
+        text_input: function (spec, coro_id) {
 
 
             var html = `<h5 class="card-header">需要您输入</h5>
             var html = `<h5 class="card-header">需要您输入</h5>
             <div class="card-body">
             <div class="card-body">
@@ -52,7 +65,7 @@ function get_handles(ws) {
             $('#input-form').submit(function (e) {
             $('#input-form').submit(function (e) {
                 e.preventDefault(); // avoid to execute the actual submit of the form.
                 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.hide(100);
                 input_container.empty();
                 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
+    }
+
+})));

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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
 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))
     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
     return response_msg
 
 
 
 
 # 非阻塞协程工具库
 # 非阻塞协程工具库
 def text_input_coro(prompt):
 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']
     input_text = data['data']
-    return input_text
+    return input_text['name']
 
 
 
 
 def ctrl_coro(ctrl_info):
 def ctrl_coro(ctrl_info):
@@ -27,5 +41,11 @@ def ctrl_coro(ctrl_info):
 
 
 
 
 def text_print(text, *, ws=None):
 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))
     (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 .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
 
 
 project_dir = dirname(abspath(__file__))
 project_dir = dirname(abspath(__file__))
 
 
 
 
-def start_ioloop(coro, port=8080):
+def start_ioloop(coro_func, port=8080):
     class EchoWebSocket(tornado.websocket.WebSocketHandler):
     class EchoWebSocket(tornado.websocket.WebSocketHandler):
+
         def check_origin(self, origin):
         def check_origin(self, origin):
             return True
             return True
 
 
@@ -18,33 +19,51 @@ def start_ioloop(coro, port=8080):
             # Non-None enables compression with default options.
             # Non-None enables compression with default options.
             return {}
             return {}
 
 
+        @coroutine
         def open(self):
         def open(self):
             print("WebSocket opened")
             print("WebSocket opened")
             self.set_nodelay(True)
             self.set_nodelay(True)
             ############
             ############
-
-            self.coros = [coro()]
+            self.coros = {}  # coro_id -> coro
             self.callbacks = OrderedDict()  # UI元素时的回调, key -> callback, mark_id
             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
         @coroutine
         def on_message(self, message):
         def on_message(self, message):
             print('on_message', message)
             print('on_message', message)
-            # { event: , data: }
+            # { event:, coro_id:, data: }
             data = json.loads(message)
             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()
                 self.close()
 
 
         def on_close(self):
         def on_close(self):

Vissa filer visades inte eftersom för många filer har ändrats