Menuconfig Implementation and auto-qemu refactoring (#44)
authorLunaixsky <lunaixsky@qq.com>
Sun, 25 Aug 2024 00:55:17 +0000 (01:55 +0100)
committerGitHub <noreply@github.com>
Sun, 25 Aug 2024 00:55:17 +0000 (01:55 +0100)
* unify the chardev backend definitions

* add --dry option for dry-running
* allow supply extra raw qemu options

* implement ncurses based tui framework

* basic lunamenu framework built on top libtui

* (libtui) add scrollable, list, textbox
* (menu) add listview, dialogue

* interface LunaConfig into menuconfig

* remove flickering when new context being added and redrawn
* add ability to navigate, edit the config node
* adjust the layout parameters
* some refactors

* add help dialogue

* layout refinement
* add <BACKSPACE> as hot key to navigate up level
* add confirmation dialogue for exiting
* refactors

* add user friendly alias to LConfig terms

* fix the focusing problem with textbox

* bypass the configuration for user program generation

* bypass the showing of config view during usr program generation, use
  default value instead
* house keeping stuff

* add terminal dimension check and fallback to prompt based

* replenish the help messages
* fix: udiv64 is not used when doing u64 division

20 files changed:
lunaix-os/LConfig
lunaix-os/arch/LConfig
lunaix-os/arch/x86/LConfig
lunaix-os/hal/LConfig
lunaix-os/hal/ahci/LConfig
lunaix-os/hal/bus/LConfig
lunaix-os/hal/char/LConfig
lunaix-os/kernel/block/blkbuf.c
lunaix-os/kernel/fs/LConfig
lunaix-os/kernel/mm/LConfig
lunaix-os/makeinc/lunabuild.mkinc
lunaix-os/scripts/build-tools/integration/libmenu.py [new file with mode: 0644]
lunaix-os/scripts/build-tools/integration/libtui.py [new file with mode: 0644]
lunaix-os/scripts/build-tools/integration/lunamenu.py [new file with mode: 0644]
lunaix-os/scripts/build-tools/lcfg/types.py
lunaix-os/scripts/build-tools/luna_build.py
lunaix-os/scripts/qemu.py
lunaix-os/scripts/qemus/qemu_x86_dev.json
lunaix-os/usr/LConfig
lunaix-os/usr/makefile

index 36be155018f83d5aa16a4a4f186edacb8d2106a0..1c151e3c7ce94dd8b5611b9e488d79998c756093 100644 (file)
@@ -4,7 +4,7 @@ include("kernel")
 include("arch")
 include("hal")
 
-@Term("Version")
+@Term("Kernel Version")
 @ReadOnly
 def lunaix_ver():
     """
@@ -16,7 +16,7 @@ def lunaix_ver():
     seq_num = int(time.time() / 3600)
     default("%s dev-2024_%d"%(v(arch), seq_num))
 
-@Collection
+@Collection("Kernel Debug and Testing")
 def debug_and_testing():
     """
     General settings for kernel debugging feature
index 95403e3624847db5d0ba8e79842d2513872b05b3..7afea85ce59caf5d1bfcf721d14d1dc47f26cf39 100644 (file)
@@ -1,24 +1,34 @@
 include("x86/LConfig")
 
-@Collection
+@Collection("Platform")
 def architecture_support():
     """
         Config ISA related features
     """
 
-    @Term
+    @Term("Architecture")
     def arch():
-        """ Config ISA support """
-        type(["i386", "x86_64", "aarch64", "rv64"])
-        default("i386")
+        """ 
+            Config ISA support 
+        """
+        # type(["i386", "x86_64", "aarch64", "rv64"])
+        type(["i386", "x86_64"])
+        default("x86_64")
 
         env_val = env("ARCH")
         if env_val:
             set_value(env_val)
 
-    @Term
+    @Term("Base operand size")
     @ReadOnly
     def arch_bits():
+        """ 
+            Defines the base size of a general register of the 
+            current selected ISA.
+
+            This the 'bits' part when we are talking about a CPU
+        """
+
         type(["64", "32"])
         match v(arch):
             case "i386": 
index 7580a36f1cea1c584c8764d5fcd8bc52ed8f385d..2b345246214d989aa9f5f40c2561a4ad1bb87503 100644 (file)
@@ -4,7 +4,7 @@ def x86_configurations():
     
     add_to_collection(architecture_support)
 
-    @Term
+    @Term("Use SSE2/3/4 extension")
     def x86_enable_sse_feature():
         """ 
             Config whether to allow using SSE feature for certain
@@ -15,14 +15,14 @@ def x86_configurations():
         default(False)
 
 
-    @Term
+    @Term("Bootloader Model")
     def x86_bl():
         """
             Select the bootloader interface
             
             Supported interface
-                mb:      multiboot compliant
-                mb2:     multiboot2 compliant
+                mb:      multiboot compliance
+                mb2:     multiboot2 compliance
                 none:    do not use any interface
         """
 
index 35385c9450920be346376d813c2e68c19100d490..63598dbd4dcca3795990be6d88f04091303b6a7b 100644 (file)
@@ -2,7 +2,7 @@ include("char")
 include("bus")
 include("ahci")
 
-@Collection
+@Collection("Devices & Peripherials")
 def hal():
     """ Lunaix hardware asbtraction layer """
 
index b9578813c06b241fc4ac9bc9dd5f4e3ec3fa3987..a199abfce77c858345e5db13b44ab1a60acea305 100644 (file)
@@ -1,10 +1,10 @@
 
-@Collection
+@Collection("AHCI")
 def sata_ahci():
 
     add_to_collection(hal)
 
-    @Term
+    @Term("Enable AHCI support")
     def ahci_enable():
         """ Enable the support of SATA AHCI. 
             Must require PCI at current stage """
index 74e3dbf7c3d9de2dd9a17fae9c495ba88241e3aa..e4281d294cd6018da966ab66f952c6e193f0a89a 100644 (file)
@@ -1,17 +1,17 @@
 
-@Collection
+@Collection("Buses & Interconnects")
 def bus_if():
     """ System/platform bus interface """
 
     add_to_collection(hal)
 
-    @Term
+    @Term("PCI")
     def pci_enable():
         """ Peripheral Component Interconnect (PCI) Bus """
         type(bool)
         default(True)
 
-    @Term
+    @Term("PCI Express")
     def pcie_ext():
         """ Enable support of PCI-Express extension """
         type(bool)
@@ -19,7 +19,7 @@ def bus_if():
 
         return v(pci_enable)
 
-    @Term
+    @Term("Use PMIO for PCI")
     def pci_pmio():
         """ Use port-mapped I/O interface for controlling PCI """
         type(bool)
index 788bfb4bff1d20a9bbccfbfa28782e57c242f26a..8e662fa5be4494450cfc8e0ca6e5633787ae6576 100644 (file)
@@ -1,21 +1,25 @@
 include("uart")
 
-@Collection
+@Collection("Character Devices")
 def char_device():
     """ Controlling support of character devices """
 
     add_to_collection(hal)
 
-    @Term
+    @Term("VGA 80x25 text-mode console")
     def vga_console():
         """ Enable VGA console device (text mode only) """
 
         type(bool)
         default(True)
 
-    @Term
+    @Term("VGA character game device")
     def chargame_console():
-        """ Enable VGA Charactor Game console device (text mode only) """
+        """ 
+            Enable VGA Charactor Game console device (text mode only) 
+
+            You normally don't need to include this, unless you want some user space fun ;)
+        """
 
         type(bool)
         default(False)
\ No newline at end of file
index 4f255c90d08b9aa37f2e42cb12eba5cd2c231ef2..302cd962e659458be8d708b0450c6c197933498b 100644 (file)
@@ -3,6 +3,7 @@
 #include <lunaix/mm/valloc.h>
 #include <lunaix/owloysius.h>
 #include <lunaix/syslog.h>
+#include <sys/muldiv64.h>
 
 LOG_MODULE("blkbuf")  
 
@@ -17,7 +18,8 @@ static struct cake_pile* bb_pile;
 static inline u64_t
 __tolba(struct blkbuf_cache* cache, unsigned int blk_id)
 {
-    return ((u64_t)cache->blksize * (u64_t)blk_id) / cache->blkdev->blk_size;
+    return udiv64(((u64_t)cache->blksize * (u64_t)blk_id), 
+                    cache->blkdev->blk_size);
 }
 
 static void
index 37a9695aee4ac491dacb6ca263a01f4e1c92697c..194e1868a7ab0737b1fcfe093f08108e954b67c5 100644 (file)
@@ -1,18 +1,18 @@
 
-@Collection
+@Collection("File Systems")
 def file_system():
     """ Config feature related to file system supports """
 
     add_to_collection(kernel_feature)
 
-    @Term
+    @Term("ext2 support")
     def fs_ext2():
         """ Enable ext2 file system support """
 
         type(bool)
         default(True)
     
-    @Term
+    @Term("iso9660 support")
     def fs_iso9660():
         """ Enable iso9660 file system support """
 
index 7db89f5c4abdd9781242b20ba564015169853820..17247b0d692ddb8e53e55a838ea6c770252e949b 100644 (file)
@@ -1,86 +1,86 @@
 
-@Collection
+@Collection("Memory Management")
 def memory_subsystem():
     """ Config the memory subsystem """
 
-    @Collection
+    @Collection("Physical Memory")
     def physical_mm():
         """ Physical memory manager  """
 
-        @Term
+        @Term("Allocation policy")
         def pmalloc_method():
             """ Allocation policy for phiscal memory  """
             
             type(["simple", "buddy", "ncontig"])
             default("simple")
 
-        @Group
+        @Group("Simple")
         def pmalloc_simple_po_thresholds():
             
-            @Term
+            @Term("Maximum cached order-0 free pages")
             def pmalloc_simple_max_po0():
                 """ free list capacity for order-0 pages  """
                 
                 type(int)
                 default(4096)
 
-            @Term
+            @Term("Maximum cached order-1 free pages")
             def pmalloc_simple_max_po1():
                 """ free list capacity for order-1 pages  """
 
                 type(int)
                 default(2048)
             
-            @Term
+            @Term("Maximum cached order-2 free pages")
             def pmalloc_simple_max_po2():
                 """ free list capacity for order-2 pages  """
 
                 type(int)
                 default(2048)
             
-            @Term
+            @Term("Maximum cached order-3 free pages")
             def pmalloc_simple_max_po3():
                 """ free list capacity for order-3 pages  """
                 
                 type(int)
                 default(2048)
             
-            @Term
+            @Term("Maximum cached order-4 free pages")
             def pmalloc_simple_max_po4():
                 """ free list capacity for order-4 pages  """
 
                 type(int)
                 default(512)
             
-            @Term
+            @Term("Maximum cached order-5 free pages")
             def pmalloc_simple_max_po5():
                 """ free list capacity for order-5 pages  """
 
                 type(int)
                 default(512)
             
-            @Term
+            @Term("Maximum cached order-6 free pages")
             def pmalloc_simple_max_po6():
                 """ free list capacity for order-6 pages  """
 
                 type(int)
                 default(128)
             
-            @Term
+            @Term("Maximum cached order-7 free pages")
             def pmalloc_simple_max_po7():
                 """ free list capacity for order-7 pages  """
 
                 type(int)
                 default(128)
             
-            @Term
+            @Term("Maximum cached order-8 free pages")
             def pmalloc_simple_max_po8():
                 """ free list capacity for order-8 pages  """
 
                 type(int)
                 default(64)
             
-            @Term
+            @Term("Maximum cached order-9 free pages")
             def pmalloc_simple_max_po9():
                 """ free list capacity for order-9 pages  """
 
index d246a6e9c209db014fcfc44b7ebe63147c9789bb..aee8e495a70f776a876caf1b2acaa67e90e00433 100644 (file)
@@ -7,15 +7,16 @@ lbuild_opts := --lconfig-file LConfig
 
 all_lconfigs = $(shell find $(CURDIR) -name "LConfig")
 
+LCONFIG_FLAGS += --config $(lbuild_opts) --config-save $(lconfig_save)
+
 export
 $(lconfig_save): $(all_lconfigs)
        @echo restarting configuration...
-       @$(LBUILD) --config $(lbuild_opts) --config-save $(lconfig_save)  --force\
-                          -o $(lbuild_dir)/
+       @$(LBUILD) $(LCONFIG_FLAGS)  --force -o $(lbuild_dir)/
 
 export
 $(lbuild_config_h): $(lconfig_save)
-       @$(LBUILD) --config $(lbuild_opts) --config-save $(lconfig_save) -o $(@D)
+       @$(LBUILD) $(LCONFIG_FLAGS) -o $(@D)
 
 export
 $(lbuild_mkinc): $(lbuild_config_h)
@@ -24,5 +25,5 @@ $(lbuild_mkinc): $(lbuild_config_h)
 .PHONY: config
 export
 config: $(all_lconfigs)
-       @$(LBUILD) --config $(lbuild_opts) --config-save $(lconfig_save) --force\
+       @$(LBUILD) $(LCONFIG_FLAGS) --force\
                           -o $(lbuild_dir)/
diff --git a/lunaix-os/scripts/build-tools/integration/libmenu.py b/lunaix-os/scripts/build-tools/integration/libmenu.py
new file mode 100644 (file)
index 0000000..3b0492e
--- /dev/null
@@ -0,0 +1,261 @@
+import curses
+import integration.libtui as tui
+from integration.libtui import ColorScope, TuiColor, Alignment, EventType
+
+def create_buttons(main_ctx, btn_defs, sizes = "*,*"):
+    size_defs = ",".join(['*'] * len(btn_defs))
+
+    layout = tui.FlexLinearLayout(main_ctx, "buttons", size_defs)
+    layout.orientation(tui.FlexLinearLayout.LANDSCAPE)
+    layout.set_size(*(sizes.split(',')[:2]))
+    layout.set_padding(1, 1, 1, 1)
+    layout.set_alignment(Alignment.CENTER | Alignment.BOT)
+
+    for i, btn_def in enumerate(btn_defs):
+        but1 = tui.TuiButton(main_ctx, "b1")
+        but1.set_text(btn_def["text"])
+        but1.set_click_callback(btn_def["onclick"])
+        but1.set_alignment(Alignment.CENTER)
+        
+        layout.set_cell(i, but1)
+
+    return layout
+
+def create_title(ctx, title):
+    _t = tui.TuiLabel(ctx, "label")
+    _t.set_text(title)
+    _t.set_local_pos(1, 0)
+    _t.set_alignment(Alignment.TOP | Alignment.CENTER)
+    _t.hightlight(True)
+    _t.pad_around(True)
+    return _t
+
+class ListView(tui.TuiObject):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+
+        self.__create_layout()
+
+        self.__sel_changed = None
+        self.__sel = None
+
+    def __create_layout(self):
+        hint_moveup = tui.TuiLabel(self._context, "movup")
+        hint_moveup.override_color(ColorScope.HINT)
+        hint_moveup.set_text("^^^ - MORE")
+        hint_moveup.set_visbility(False)
+        hint_moveup.set_alignment(Alignment.TOP)
+
+        hint_movedown = tui.TuiLabel(self._context, "movdown")
+        hint_movedown.override_color(ColorScope.HINT)
+        hint_movedown.set_text("vvv - MORE")
+        hint_movedown.set_visbility(False)
+        hint_movedown.set_alignment(Alignment.BOT)
+
+        list_ = tui.SimpleList(self._context, "list")
+        list_.set_size("*", "*")
+        list_.set_alignment(Alignment.CENTER | Alignment.TOP)
+
+        list_.set_onselected_cb(self._on_selected)
+        list_.set_onselection_change_cb(self._on_sel_changed)
+
+        scroll = tui.TuiScrollable(self._context, "scroll")
+        scroll.set_size("*", "*")
+        scroll.set_alignment(Alignment.CENTER)
+        scroll.set_content(list_)
+
+        layout = tui.FlexLinearLayout(
+                    self._context, f"main_layout", "2,*,2")
+        layout.set_size("*", "*")
+        layout.set_alignment(Alignment.CENTER)
+        layout.orientation(tui.FlexLinearLayout.PORTRAIT)
+        layout.set_parent(self)
+
+        layout.set_cell(0, hint_moveup)
+        layout.set_cell(1, scroll)
+        layout.set_cell(2, hint_movedown)
+
+        self.__hint_up   = hint_moveup
+        self.__hint_down = hint_movedown
+        self.__list      = list_
+        self.__scroll    = scroll
+        self.__layout    = layout
+
+    def add_item(self, item):
+        self.__list.add_item(item)
+
+    def clear(self):
+        self.__list.clear()
+
+    def on_draw(self):
+        super().on_draw()
+        
+        more_above = not self.__scroll.reached_top()
+        more_below = not self.__scroll.reached_last()
+        self.__hint_up.set_visbility(more_above)
+        self.__hint_down.set_visbility(more_below)
+
+        self.__layout.on_draw()
+
+    def on_layout(self):
+        super().on_layout()
+        self.__layout.on_layout()
+
+    def _on_sel_changed(self, listv, prev, new):
+        h = self.__scroll._size.y()
+        self.__scroll.set_scrollY((new + 1) // h * h)
+
+        if self.__sel_changed:
+            self.__sel_changed(listv, prev, new)
+
+    def _on_selected(self, listv, index, item):
+        if self.__sel:
+            self.__sel(listv, index, item)
+    
+    def set_onselected_cb(self, cb):
+        self.__sel = cb
+
+    def set_onselect_changed_cb(self, cb):
+        self.__sel_changed = cb
+
+class Dialogue(tui.TuiContext):
+    Pending = 0
+    Yes = 1
+    No = 2
+    Abort = 3
+    def __init__(self, session, title = "", content = "", input=False,
+                 ok_btn = "OK", no_btn = "No", abort_btn = None):
+        super().__init__(session)
+
+        self.__btns = [
+            { "text": ok_btn, "onclick": lambda x: self._ok_onclick() }
+        ]
+
+        if no_btn:
+            self.__btns.append({ 
+                "text": no_btn, 
+                "onclick": lambda x: self._no_onclick() 
+            })
+        if abort_btn:
+            self.__btns.append({ 
+                "text": abort_btn, 
+                "onclick": lambda x: self._abort_onclick() 
+            })
+
+        self.__title_txt = title
+        self.__status = Dialogue.Pending
+        self.__content = content
+        self.__input_dialog = input
+        self._textbox = None
+
+        self.set_size("70", "0.5*")
+        self.set_alignment(Alignment.CENTER)
+
+    def set_content(self, content):
+        self.__content = content
+
+    def set_input_dialogue(self, yes):
+        self.__input_dialog = yes
+
+    def prepare(self):
+        self.__create_layout(self.__title_txt)
+
+    def _handle_key_event(self, key):
+        if key == 27:
+            self.__close()
+            return
+        super()._handle_key_event(key)
+        
+    
+    def _ok_onclick(self):
+        self.__status = Dialogue.Yes
+        self.__close()
+
+    def _no_onclick(self):
+        self.__status = Dialogue.No
+        self.__close()
+
+    def _abort_onclick(self):
+        self.__status = Dialogue.Abort
+        self.__close()
+
+    def __create_layout(self, title):
+        panel  = tui.TuiPanel(self, "panel")
+        layout = tui.FlexLinearLayout(self, "layout", "*,3")
+        btn_grp = create_buttons(self, self.__btns)
+        t = create_title(self, title)
+        content = self.__create_content()
+
+        self.__title = t
+        self.__layout = layout
+        self.__panel = panel
+
+        panel._dyn_size.set(self._dyn_size)
+        panel._local_pos.set(self._local_pos)
+        panel.set_alignment(self._align)
+        panel.drop_shadow(1, 2)
+        panel.border(True)
+
+        layout.orientation(tui.FlexLinearLayout.PORTRAIT)
+        layout.set_size("*", "*")
+        layout.set_padding(4, 1, 1, 1)
+
+        t.set_alignment(Alignment.CENTER | Alignment.TOP)
+
+        layout.set_cell(0, content)
+        layout.set_cell(1, btn_grp)
+
+        panel.add(t)
+        panel.add(layout)
+
+        self.set_root(panel)
+
+    def __create_content(self):
+        text = None
+        if isinstance(self.__content, str):
+            text = tui.TuiTextBlock(self, "tb")
+            text.set_size("0.6*", "0.5*")
+            text.set_alignment(Alignment.CENTER)
+            text.set_text(self.__content)
+        elif self.__content is not None:
+            return self.__content
+        
+        if not self.__input_dialog:
+            self.set_size(h = "20")
+            return text
+        
+        tb = tui.TuiTextBox(self, "input")
+        tb.set_size("0.5*", "3")
+        tb.set_alignment(Alignment.CENTER)
+        
+        if text:
+            layout = tui.FlexLinearLayout(self, "layout", "*,5")
+            layout.orientation(tui.FlexLinearLayout.PORTRAIT)
+            layout.set_size("*", "*")
+            layout.set_cell(0, text)
+            layout.set_cell(1, tb)
+        else:
+            layout = tb
+            self.set_size(h = "10")
+        
+        self.set_curser_mode(1)
+
+        self._textbox = tb
+
+        return layout
+        
+    def __close(self):
+        self.session().pop_context()
+
+    def status(self):
+        return self.__status
+    
+    def show(self, title=None):
+        if title:
+            self.__title.set_text(title)
+        self.session().push_context(self)
+
+
+def show_dialog(session, title, text):
+    dia = Dialogue(session, title=title, content=text, no_btn=None)
+    dia.show()
\ No newline at end of file
diff --git a/lunaix-os/scripts/build-tools/integration/libtui.py b/lunaix-os/scripts/build-tools/integration/libtui.py
new file mode 100644 (file)
index 0000000..30e28f6
--- /dev/null
@@ -0,0 +1,1267 @@
+#
+# libtui - TUI framework using ncurses
+#  (c) 2024 Lunaixsky
+# 
+# I sware, this is the last time I ever touch 
+#  any sort of the GUI messes.
+#
+
+import curses
+import re
+import curses.panel as cpanel
+import curses.textpad as textpad
+import textwrap
+
+def __invoke_fn(obj, fn, *args):
+    try:
+        fn(*args)
+    except:
+        _id = obj._id if obj else "<root>"
+        raise Exception(_id, str(fn), args)
+
+def resize_safe(obj, co, y, x):
+    __invoke_fn(obj, co.resize, y, x)
+
+def move_safe(obj, co, y, x):
+    __invoke_fn(obj, co.move, y, x)
+    
+def addstr_safe(obj, co, y, x, str, *args):
+    __invoke_fn(obj, co.addstr, y, x, str, *args)
+
+class _TuiColor:
+    def __init__(self, v) -> None:
+        self.__v = v
+    def __int__(self):
+        return self.__v
+    def bright(self):
+        return self.__v + 8
+
+class TuiColor:
+    black     = _TuiColor(curses.COLOR_BLACK)
+    red       = _TuiColor(curses.COLOR_RED)
+    green     = _TuiColor(curses.COLOR_GREEN)
+    yellow    = _TuiColor(curses.COLOR_YELLOW)
+    blue      = _TuiColor(curses.COLOR_BLUE)
+    magenta   = _TuiColor(curses.COLOR_MAGENTA)
+    cyan      = _TuiColor(curses.COLOR_CYAN)
+    white     = _TuiColor(curses.COLOR_WHITE)
+
+class Alignment:
+    LEFT    = 0b000001
+    RIGHT   = 0b000010
+    CENTER  = 0b000100
+    TOP     = 0b001000
+    BOT     = 0b010000
+    ABS     = 0b000000
+    REL     = 0b100000
+
+class ColorScope:
+    WIN       = 1
+    PANEL     = 2
+    TEXT      = 3
+    TEXT_HI   = 4
+    SHADOW    = 5
+    SELECT    = 6
+    HINT      = 7
+    BOX       = 8
+
+class EventType:
+    E_KEY = 0
+    E_REDRAW = 1
+    E_QUIT = 2
+    E_TASK = 3
+    E_CHFOCUS = 4
+    E_M_FOCUS = 0b10000000
+
+    def focused_only(t):
+        return (t & EventType.E_M_FOCUS)
+    
+    def value(t):
+        return t & ~EventType.E_M_FOCUS
+    
+    def key_press(t):
+        return (t & ~EventType.E_M_FOCUS) == EventType.E_KEY
+
+class Matchers:
+    RelSize = re.compile(r"(?P<mult>[0-9]+(?:\.[0-9]+)?)?\*(?P<add>[+-][0-9]+)?")
+
+class BoundExpression:
+    def __init__(self, expr = None):
+        self._mult = 0
+        self._add  = 0
+
+        if expr:
+            self.update(expr)
+
+    def set_pair(self, mult, add):
+        self._mult = mult
+        self._add  = add
+
+    def set(self, expr):
+        self._mult = expr._mult
+        self._add  = expr._add
+        
+    def update(self, expr):
+        if isinstance(expr, int):
+            m = None
+        else:
+            m = Matchers.RelSize.match(expr)
+
+        if m:
+            g = m.groupdict()
+            mult = 1 if not g["mult"] else float(g["mult"])
+            add = 0 if not g["add"] else int(g["add"])
+            self._mult = mult
+            self._add  = add
+        else:
+            self.set_pair(0, int(expr))
+
+    def calc(self, ref_val):
+        return int(self._mult * ref_val + self._add)
+    
+    def absolute(self):
+        return self._mult == 0
+    
+    def nullity(self):
+        return self._mult == 0 and self._add == 0
+    
+    def scale_mult(self, scalar):
+        self._mult *= scalar
+        return self
+    
+    @staticmethod
+    def normalise(*exprs):
+        v = BoundExpression()
+        for e in exprs:
+            v += e
+        return [e.scale_mult(1 / v._mult) for e in exprs]
+
+    def __add__(self, b):
+        v = BoundExpression()
+        v.set(self)
+        v._mult += b._mult
+        v._add  += b._add
+        return v
+
+    def __sub__(self, b):
+        v = BoundExpression()
+        v.set(self)
+        v._mult -= b._mult
+        v._add  -= b._add
+        return v
+    
+    def __iadd__(self, b):
+        self._mult += b._mult
+        self._add  += b._add
+        return self
+
+    def __isub__(self, b):
+        self._mult -= b._mult
+        self._add  -= b._add
+        return self
+    
+    def __rmul__(self, scalar):
+        v = BoundExpression()
+        v.set(self)
+        v._mult *= scalar
+        v._add *= scalar
+        return v
+
+    def __truediv__(self, scalar):
+        v = BoundExpression()
+        v.set(self)
+        v._mult /= float(scalar)
+        v._add /= scalar
+        return v
+    
+class DynamicBound:
+    def __init__(self):
+        self.__x = BoundExpression()
+        self.__y = BoundExpression()
+    
+    def dyn_x(self):
+        return self.__x
+
+    def dyn_y(self):
+        return self.__y
+    
+    def resolve(self, ref_w, ref_h):
+        return (self.__y.calc(ref_h), self.__x.calc(ref_w))
+    
+    def set(self, dyn_bound):
+        self.__x.set(dyn_bound.dyn_x())
+        self.__y.set(dyn_bound.dyn_y())
+
+class Bound:
+    def __init__(self) -> None:
+        self.__x = 0
+        self.__y = 0
+
+    def shrinkX(self, dx):
+        self.__x -= dx
+    def shrinkY(self, dy):
+        self.__y -= dy
+
+    def growX(self, dx):
+        self.__x += dx
+    def growY(self, dy):
+        self.__y += dy
+
+    def resetX(self, x):
+        self.__x = x
+    def resetY(self, y):
+        self.__y = y
+
+    def update(self, dynsz, ref_bound):
+        y, x = dynsz.resolve(ref_bound.x(), ref_bound.y())
+        self.__x = x
+        self.__y = y
+
+    def reset(self, x, y):
+        self.__x, self.__y = x, y
+
+    def x(self):
+        return self.__x
+    
+    def y(self):
+        return self.__y
+    
+    def yx(self, scale = 1):
+        return int(self.__y * scale), int(self.__x * scale)
+
+class TuiStackWindow:
+    def __init__(self, obj) -> None:
+        self.__obj = obj
+        self.__win = curses.newwin(0, 0)
+        self.__pan = cpanel.new_panel(self.__win)
+        self.__pan.hide()
+
+    def resize(self, h, w):
+        resize_safe(self.__obj, self.__win, h, w)
+    
+    def relocate(self, y, x):
+        move_safe(self.__obj, self.__pan, y, x)
+
+    def set_geometric(self, h, w, y, x):
+        resize_safe(self.__obj, self.__win, h, w)
+        move_safe(self.__obj, self.__pan, y, x)
+
+    def set_background(self, color_scope):
+        self.__win.bkgd(' ', curses.color_pair(color_scope))
+
+    def show(self):
+        self.__pan.show()
+
+    def hide(self):
+        self.__pan.hide()
+    
+    def send_back(self):
+        self.__pan.bottom()
+
+    def send_front(self):
+        self.__pan.top()
+
+    def window(self):
+        return self.__win
+
+class SpatialObject:
+    def __init__(self) -> None:
+        self._local_pos = DynamicBound()
+        self._pos = Bound()
+        self._dyn_size = DynamicBound()
+        self._size = Bound()
+        self._margin = (0, 0, 0, 0)
+        self._padding = (0, 0, 0, 0)
+        self._align = Alignment.TOP | Alignment.LEFT
+
+    def set_local_pos(self, x, y):
+        self._local_pos.dyn_x().update(x)
+        self._local_pos.dyn_y().update(y)
+
+    def set_alignment(self, align):
+        self._align = align
+
+    def set_size(self, w = None, h = None):
+        if w:
+            self._dyn_size.dyn_x().update(w)
+        if h:
+            self._dyn_size.dyn_y().update(h)
+
+    def set_margin(self, top, right, bottom, left):
+        self._margin = (top, right, bottom, left)
+
+    def set_padding(self, top, right, bottom, left):
+        self._padding = (top, right, bottom, left)
+
+    def reset(self):
+        self._pos.reset(0, 0)
+        self._size.reset(0, 0)
+
+    def deduce_spatial(self, constrain):
+        self.reset()
+        self.__satisfy_bound(constrain)
+        self.__satisfy_alignment(constrain)
+        self.__satisfy_margin(constrain)
+        self.__satisfy_padding(constrain)
+
+        self.__to_corner_pos(constrain)
+
+    def __satisfy_alignment(self, constrain):
+        local_pos = self._local_pos
+        cbound = constrain._size
+        size = self._size
+
+        cy, cx = cbound.yx()
+        ry, rx = local_pos.resolve(cx, cy)
+        ay, ax = size.yx(0.5)
+        
+        if self._align & Alignment.CENTER:
+            ax = cx // 2
+            ay = cy // 2
+        
+        if self._align & Alignment.BOT:
+            ay = min(cy - ay, cy - 1)
+            ry = -ry
+        elif self._align & Alignment.TOP:
+            ay = size.y() // 2
+
+        if self._align & Alignment.RIGHT:
+            ax = cx - ax
+            rx = -rx
+        elif self._align & Alignment.LEFT:
+            ax = size.x() // 2
+
+        self._pos.reset(ax + rx, ay + ry)
+
+    def __satisfy_margin(self, constrain):
+        tm, lm, bm, rm = self._margin
+        
+        self._pos.growX(rm - lm)
+        self._pos.growY(bm - tm)
+
+    def __satisfy_padding(self, constrain):
+        csize = constrain._size
+        ch, cw = csize.yx()
+        h, w = self._size.yx(0.5)
+        y, x = self._pos.yx()
+
+        tp, lp, bp, rp = self._padding
+
+        if not (tp or lp or bp or rp):
+            return
+
+        dtp = min(y - h, tp) - tp
+        dbp = min(ch - (y + h), bp) - bp
+
+        dlp = min(x - w, lp) - lp
+        drp = min(cw - (x + w), rp) - rp
+
+        self._size.growX(drp + dlp)
+        self._size.growY(dtp + dbp)
+
+    def __satisfy_bound(self, constrain):
+        self._size.update(self._dyn_size, constrain._size)
+
+    def __to_corner_pos(self, constrain):
+        h, w = self._size.yx(0.5)
+        g_pos = constrain._pos
+
+        self._pos.shrinkX(w)
+        self._pos.shrinkY(h)
+
+        self._pos.growX(g_pos.x())
+        self._pos.growY(g_pos.y())
+        
+
+class TuiObject(SpatialObject):
+    def __init__(self, context, id):
+        super().__init__()
+        self._id = id
+        self._context = context
+        self._parent = None
+        self._visible = True
+        self._focused = False
+
+    def set_parent(self, parent):
+        self._parent = parent
+
+    def canvas(self):
+        if self._parent:
+            return self._parent.canvas()
+        return (self, self._context.window())
+    
+    def context(self):
+        return self._context
+    
+    def session(self):
+        return self._context.session()
+
+    def on_create(self):
+        pass
+
+    def on_destory(self):
+        pass
+
+    def on_quit(self):
+        pass
+
+    def on_layout(self):
+        if self._parent:
+            self.deduce_spatial(self._parent)
+
+    def on_draw(self):
+        pass
+
+    def on_event(self, ev_type, ev_arg):
+        pass
+
+    def on_focused(self):
+        self._focused = True
+
+    def on_focus_lost(self):
+        self._focused = False
+
+    def set_visbility(self, visible):
+        self._visible = visible
+
+    def do_draw(self):
+        if self._visible:
+            self.on_draw()
+    
+    def do_layout(self):
+        self.on_layout()
+
+class TuiWidget(TuiObject):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+    
+    def on_layout(self):
+        super().on_layout()
+        
+        co, _ = self.canvas()
+
+        y, x = co._pos.yx()
+        self._pos.shrinkX(x)
+        self._pos.shrinkY(y)
+
+class TuiContainerObject(TuiObject):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+        self._children = []
+
+    def add(self, child):
+        child.set_parent(self)
+        self._children.append(child)
+
+    def children(self):
+        return self._children
+
+    def on_create(self):
+        super().on_create()
+        for child in self._children:
+            child.on_create()
+
+    def on_destory(self):
+        super().on_destory()
+        for child in self._children:
+            child.on_destory()
+    
+    def on_quit(self):
+        super().on_quit()
+        for child in self._children:
+            child.on_quit()
+
+    def on_layout(self):
+        super().on_layout()
+        for child in self._children:
+            child.do_layout()
+
+    def on_draw(self):
+        super().on_draw()
+        for child in self._children:
+            child.do_draw()
+
+    def on_event(self, ev_type, ev_arg):
+        super().on_event(ev_type, ev_arg)
+        for child in self._children:
+            child.on_event(ev_type, ev_arg)
+
+
+class TuiScrollable(TuiObject):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+        self.__spos = Bound()
+
+        self.__pad = curses.newpad(1, 1)
+        self.__pad.bkgd(' ', curses.color_pair(ColorScope.PANEL))
+        self.__pad_panel = cpanel.new_panel(self.__pad)
+        self.__content = None
+
+    def canvas(self):
+        return (self, self.__pad)
+
+    def set_content(self, content):
+        self.__content = content
+        self.__content.set_parent(self)
+
+    def set_scrollY(self, y):
+        self.__spos.resetY(y)
+
+    def set_scrollX(self, x):
+        self.__spos.resetX(x)
+
+    def reached_last(self):
+        off = self.__spos.y() + self._size.y()
+        return off >= self.__content._size.y()
+    
+    def reached_top(self):
+        return self.__spos.y() < self._size.y()
+
+    def on_layout(self):
+        super().on_layout()
+
+        if not self.__content:
+            return
+        
+        self.__content.on_layout()
+                
+        h, w = self._size.yx()
+        ch, cw = self.__content._size.yx()
+        sh, sw = max(ch, h), max(cw, w)
+
+        self.__spos.resetX(min(self.__spos.x(), max(cw, w) - w))
+        self.__spos.resetY(min(self.__spos.y(), max(ch, h) - h))
+
+        resize_safe(self, self.__pad, sh, sw)
+
+    def on_draw(self):
+        if not self.__content:
+            return
+        
+        self.__pad.erase()
+
+        self.__pad_panel.top()
+        self.__content.on_draw()
+
+        wminy, wminx = self._pos.yx()
+        wmaxy, wmaxx = self._size.yx()
+        wmaxy, wmaxx = wmaxy + wminy, wmaxx + wminx
+        self.__pad.touchwin()
+        self.__pad.refresh(*self.__spos.yx(), 
+                            wminy, wminx, wmaxy - 1, wmaxx - 1)
+
+
+class Layout(TuiContainerObject):
+    class Cell(TuiObject):
+        def __init__(self, context):
+            super().__init__(context, "cell")
+            self.__obj = None
+
+        def set_obj(self, obj):
+            self.__obj = obj
+            self.__obj.set_parent(self)
+
+        def on_create(self):
+            if self.__obj:
+                self.__obj.on_create()
+
+        def on_destory(self):
+            if self.__obj:
+                self.__obj.on_destory()
+
+        def on_quit(self):
+            if self.__obj:
+                self.__obj.on_quit()
+
+        def on_layout(self):
+            super().on_layout()
+            if self.__obj:
+                self.__obj.do_layout()
+
+        def on_draw(self):
+            if self.__obj:
+                self.__obj.do_draw()
+
+        def on_event(self, ev_type, ev_arg):
+            if self.__obj:
+                self.__obj.on_event(ev_type, ev_arg)
+    
+    def __init__(self, context, id, ratios):
+        super().__init__(context, id)
+
+        rs = [BoundExpression(r) for r in ratios.split(',')]
+        self._rs = BoundExpression.normalise(*rs)
+
+        for _ in range(len(self._rs)):
+            cell = Layout.Cell(self._context)
+            super().add(cell)
+
+        self._adjust_to_fit()
+
+    def _adjust_to_fit(self):
+        pass
+
+    def add(self, child):
+        raise RuntimeError("invalid operation")
+    
+    def set_cell(self, i, obj):
+        if i > len(self._children):
+            raise ValueError(f"cell #{i} out of bound")
+        
+        self._children[i].set_obj(obj)
+
+
+class FlexLinearLayout(Layout):
+    LANDSCAPE = 0
+    PORTRAIT = 1
+    def __init__(self, context, id, ratios):
+        self.__horizontal = False
+
+        super().__init__(context, id, ratios)
+
+    def orientation(self, orient):
+        self.__horizontal = orient == FlexLinearLayout.LANDSCAPE
+    
+    def on_layout(self):
+        self.__apply_ratio()
+        super().on_layout()
+    
+    def _adjust_to_fit(self):
+        sum_abs = BoundExpression()
+        i = 0
+        for r in self._rs:
+            if r.absolute():
+                sum_abs += r
+            else:
+                i += 1
+
+        sum_abs /= i
+        for i, r in enumerate(self._rs):
+            if not r.absolute():
+                self._rs[i] -= sum_abs
+
+    def __apply_ratio(self):
+        if self.__horizontal:
+            self.__adjust_horizontal()
+        else:
+            self.__adjust_vertical()
+        
+    def __adjust_horizontal(self):
+        acc = BoundExpression()
+        for r, cell in zip(self._rs, self.children()):
+            cell._dyn_size.dyn_y().set_pair(1, 0)
+            cell._dyn_size.dyn_x().set(r)
+
+            cell.set_alignment(Alignment.LEFT)
+            cell._local_pos.dyn_y().set_pair(0, 0)
+            cell._local_pos.dyn_x().set(acc)
+
+            acc += r
+
+    def __adjust_vertical(self):
+        acc = BoundExpression()
+        for r, cell in zip(self._rs, self.children()):
+            cell._dyn_size.dyn_x().set_pair(1, 0)
+            cell._dyn_size.dyn_y().set(r)
+
+            cell.set_alignment(Alignment.TOP | Alignment.CENTER)
+            cell._local_pos.dyn_x().set_pair(0, 0)
+            cell._local_pos.dyn_y().set(acc)
+
+            acc += r
+
+
+class TuiPanel(TuiContainerObject):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+
+        self.__use_border = False
+        self.__use_shadow = False
+        self.__shadow_param = (0, 0)
+
+        self.__swin = TuiStackWindow(self)
+        self.__shad = TuiStackWindow(self)
+
+        self.__swin.set_background(ColorScope.PANEL)
+        self.__shad.set_background(ColorScope.SHADOW)
+
+    def canvas(self):
+        return (self, self.__swin.window())
+
+    def drop_shadow(self, off_y, off_x):
+        self.__shadow_param = (off_y, off_x)
+        self.__use_shadow = not (off_y == off_x and off_y == 0)
+
+    def border(self, _b):
+        self.__use_border = _b
+
+    def bkgd_override(self, scope):
+        self.__swin.set_background(scope)
+
+    def on_layout(self):
+        super().on_layout()
+
+        self.__swin.hide()
+
+        h, w = self._size.y(), self._size.x()
+        y, x = self._pos.y(), self._pos.x()
+        self.__swin.set_geometric(h, w, y, x)
+        
+        if self.__use_shadow:
+            sy, sx = self.__shadow_param
+            self.__shad.set_geometric(h, w, y + sy, x + sx)
+
+    def on_destory(self):
+        super().on_destory()
+        self.__swin.hide()
+        self.__shad.hide()
+
+    def on_draw(self):
+        win = self.__swin.window()
+        win.erase()
+
+        if self.__use_border:
+            win.border()
+        
+        if self.__use_shadow:
+            self.__shad.show()
+        else:
+            self.__shad.hide()
+
+        self.__swin.show()
+        self.__swin.send_front()
+
+        super().on_draw()
+
+        win.touchwin()
+
+class TuiLabel(TuiWidget):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+        self._text = "TuiLabel"
+        self._wrapped = []
+
+        self.__auto_fit = True
+        self.__trunc = False
+        self.__dopad = False
+        self.__highlight = False
+        self.__color_scope = -1
+
+    def __try_fit_text(self, txt):
+        if self.__auto_fit:
+            self._dyn_size.dyn_x().set_pair(0, len(txt))
+            self._dyn_size.dyn_y().set_pair(0, 1)
+
+    def __pad_text(self):
+        for i, t in enumerate(self._wrapped):
+            self._wrapped[i] = str.rjust(t, self._size.x())
+    
+    def set_text(self, text):
+        self._text = text
+        self.__try_fit_text(text)
+
+    def override_color(self, color = -1):
+        self.__color_scope = color
+
+    def auto_fit(self, _b):
+        self.__auto_fit = _b
+
+    def truncate(self, _b):
+        self.__trunc = _b
+
+    def hightlight(self, _b):
+        self.__highlight = _b
+
+    def pad_around(self, _b):
+        self.__dopad = _b
+
+    def on_layout(self):
+        txt = self._text
+        if self.__dopad:
+            txt = f" {txt} "
+            self.__try_fit_text(txt)
+        
+        super().on_layout()
+
+        if len(txt) <= self._size.x():
+            self._wrapped = [txt]
+            self.__pad_text()
+            return
+
+        if not self.__trunc:
+            txt = txt[:self._size.x() - 1]
+            self._wrapped = [txt]
+            self.__pad_text()
+            return
+
+        self._wrapped = textwrap.wrap(txt, self._size.x())
+        self.__pad_text()
+
+    def on_draw(self):
+        _, win = self.canvas()
+        y, x = self._pos.yx()
+        
+        if self.__color_scope != -1:
+            color = curses.color_pair(self.__color_scope)
+        elif self.__highlight:
+            color = curses.color_pair(ColorScope.TEXT_HI)
+        else:
+            color = curses.color_pair(ColorScope.TEXT)
+
+        for i, t in enumerate(self._wrapped):
+            addstr_safe(self, win, y + i, x, t, color)
+
+
+class TuiTextBlock(TuiWidget):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+        self.__lines = []
+        self.__wrapped = []
+        self.__fit_to_height = False
+    
+    def set_text(self, text):
+        text = textwrap.dedent(text)
+        self.__lines = text.split('\n')
+        if self.__fit_to_height:
+            self._dyn_size.dyn_y().set_pair(0, 0)
+
+    def height_auto_fit(self, yes):
+        self.__fit_to_height = yes
+
+    def on_layout(self):
+        super().on_layout()
+
+        self.__wrapped.clear()
+        for t in self.__lines:
+            if not t:
+                self.__wrapped.append(t)
+                continue
+            wrap = textwrap.wrap(t, self._size.x())
+            self.__wrapped += wrap
+
+        if self._dyn_size.dyn_y().nullity():
+            h = len(self.__wrapped)
+            self._dyn_size.dyn_y().set_pair(0, h)
+
+            # redo layouting
+            super().on_layout()
+
+    def on_draw(self):
+        _, win = self.canvas()
+        y, x = self._pos.yx()
+        
+        color = curses.color_pair(ColorScope.TEXT)
+        for i, t in enumerate(self.__wrapped):
+            addstr_safe(self, win, y + i, x, t, color)
+
+
+class TuiTextBox(TuiWidget):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+        self.__box = TuiStackWindow(self)
+        self.__box.set_background(ColorScope.PANEL)
+        self.__textb = textpad.Textbox(self.__box.window(), True)
+        self.__textb.stripspaces = True
+        self.__str = ""
+        self.__scheduled_edit = False
+
+        self._context.focus_group().register(self, 0)
+
+    def __validate(self, x):
+        if x == 10 or x == 9:
+            return 7
+        return x
+    
+    def on_layout(self):
+        super().on_layout()
+
+        co, _ = self.canvas()
+        h, w = self._size.yx()
+        y, x = self._pos.yx()
+        cy, cx = co._pos.yx()
+        y, x = y + cy, x + cx
+
+        self.__box.hide()
+        self.__box.set_geometric(1, w - 1, y + h // 2, x + 1)
+
+    def on_draw(self):
+        self.__box.show()
+        self.__box.send_front()
+        
+        _, cwin = self.canvas()
+
+        h, w = self._size.yx()
+        y, x = self._pos.yx()
+        textpad.rectangle(cwin, y, x, y + h - 1, x+w)
+
+        win = self.__box.window()
+        win.touchwin()
+
+    def __edit(self):
+        self.__str = self.__textb.edit(lambda x: self.__validate(x))
+        self.session().schedule(EventType.E_CHFOCUS)
+        self.__scheduled_edit = False
+
+    def get_text(self):
+        return self.__str
+    
+    def on_focused(self):
+        self.__box.set_background(ColorScope.BOX)
+        if not self.__scheduled_edit:
+            # edit will block, defer to next update cycle
+            self.session().schedule_task(self.__edit)
+            self.__scheduled_edit = True
+
+    def on_focus_lost(self):
+        self.__box.set_background(ColorScope.PANEL)
+        
+
+class SimpleList(TuiWidget):
+    class Item:
+        def __init__(self) -> None:
+            pass
+        def get_text(self):
+            return "list_item"
+        def on_selected(self):
+            pass
+        def on_key_pressed(self, key):
+            pass
+
+    def __init__(self, context, id):
+        super().__init__(context, id)
+        self.__items = []
+        self.__selected = 0
+        self.__on_sel_confirm_cb = None
+        self.__on_sel_change_cb = None
+
+        self._context.focus_group().register(self)
+
+    def set_onselected_cb(self, cb):
+        self.__on_sel_confirm_cb = cb
+
+    def set_onselection_change_cb(self, cb):
+        self.__on_sel_change_cb = cb
+
+    def count(self):
+        return len(self.__items)
+    
+    def index(self):
+        return self.__selected
+
+    def add_item(self, item):
+        self.__items.append(item)
+
+    def clear(self):
+        self.__items.clear()
+
+    def on_layout(self):
+        super().on_layout()
+        self.__selected = min(self.__selected, len(self.__items))
+        self._size.resetY(len(self.__items) + 1)
+
+    def on_draw(self):
+        _, win = self.canvas()
+        w = self._size.x()
+
+        for i, item in enumerate(self.__items):
+            color = curses.color_pair(ColorScope.TEXT)
+            if i == self.__selected:
+                if self._focused:
+                    color = curses.color_pair(ColorScope.SELECT)
+                else:
+                    color = curses.color_pair(ColorScope.BOX)
+            
+            txt = str.ljust(item.get_text(), w)
+            txt = txt[:w]
+            addstr_safe(self, win, i, 0, txt, color)
+
+    def on_event(self, ev_type, ev_arg):
+        if not EventType.key_press(ev_type):
+            return
+        
+        if len(self.__items) == 0:
+            return
+
+        sel = self.__items[self.__selected]
+
+        if ev_arg == 10:
+            sel.on_selected()
+            
+            if self.__on_sel_confirm_cb:
+                self.__on_sel_confirm_cb(self, self.__selected, sel)
+            return
+        
+        sel.on_key_pressed(ev_arg)
+        
+        if (ev_arg != curses.KEY_DOWN and 
+            ev_arg != curses.KEY_UP):
+            return
+
+        prev = self.__selected
+        if ev_arg == curses.KEY_DOWN:
+            self.__selected += 1
+        else:
+            self.__selected -= 1
+
+        self.__selected = max(self.__selected, 0)
+        self.__selected = self.__selected % len(self.__items)
+        
+        if self.__on_sel_change_cb:
+            self.__on_sel_change_cb(self, prev, self.__selected)
+
+
+class TuiButton(TuiLabel):
+    def __init__(self, context, id):
+        super().__init__(context, id)
+        self.__onclick = None
+
+        context.focus_group().register(self)
+
+    def set_text(self, text):
+        return super().set_text(f"<{text}>")
+
+    def set_click_callback(self, cb):
+        self.__onclick = cb
+
+    def hightlight(self, _b):
+        raise NotImplemented()
+    
+    def on_draw(self):
+        _, win = self.canvas()
+        y, x = self._pos.yx()
+        
+        if self._focused:
+            color = curses.color_pair(ColorScope.SELECT)
+        else:
+            color = curses.color_pair(ColorScope.TEXT)
+
+        addstr_safe(self, win, y, x, self._wrapped[0], color)
+    
+    def on_event(self, ev_type, ev_arg):
+        if not EventType.focused_only(ev_type):
+            return
+        if not EventType.key_press(ev_type):
+            return
+        
+        if ev_arg == ord('\n') and self.__onclick:
+            self.__onclick(self)
+
+
+class TuiSession:
+    def __init__(self) -> None:
+        self.stdsc = curses.initscr()
+        curses.start_color()
+
+        curses.noecho()
+        curses.cbreak()
+
+        self.__context_stack = []
+        self.__sched_events = []
+
+        ws = self.window_size()
+        self.__win = curses.newwin(*ws)
+        self.__winbg = curses.newwin(*ws)
+        self.__panbg = cpanel.new_panel(self.__winbg)
+
+        self.__winbg.bkgd(' ', curses.color_pair(ColorScope.WIN))
+
+        self.__win.timeout(50)
+        self.__win.keypad(True)
+
+    def window_size(self):
+        return self.stdsc.getmaxyx()
+
+    def set_color(self, scope, fg, bg):
+        curses.init_pair(scope, int(fg), int(bg))
+
+    def schedule_redraw(self):
+        self.schedule(EventType.E_REDRAW)
+
+    def schedule_task(self, task):
+        self.schedule(EventType.E_REDRAW)
+        self.schedule(EventType.E_TASK, task)
+
+    def schedule(self, event, arg = None):
+        if len(self.__sched_events) > 0:
+            if self.__sched_events[-1] == event:
+                return
+    
+        self.__sched_events.append((event, arg))
+
+    def push_context(self, tuictx):
+        tuictx.prepare()
+        self.__context_stack.append(tuictx)
+        self.schedule(EventType.E_REDRAW)
+
+        curses.curs_set(self.active().curser_mode())
+
+    def pop_context(self):
+        if len(self.__context_stack) == 1:
+            return
+        
+        ctx = self.__context_stack.pop()
+        ctx.on_destory()
+        self.schedule(EventType.E_REDRAW)
+
+        curses.curs_set(self.active().curser_mode())
+
+    def active(self):
+        return self.__context_stack[-1]
+    
+    def event_loop(self):
+        if len(self.__context_stack) == 0:
+            raise RuntimeError("no tui context to display")
+        
+        while True:
+            key = self.__win.getch()
+            if key != -1:
+                self.schedule(EventType.E_KEY, key)
+            
+            if len(self.__sched_events) == 0:
+                continue
+
+            evt, arg = self.__sched_events.pop(0)
+            if evt == EventType.E_REDRAW:
+                self.__redraw()
+            elif evt == EventType.E_QUIT:
+                self.__notify_quit()
+                break
+
+            self.active().dispatch_event(evt, arg)
+        
+    def __notify_quit(self):
+        while len(self.__context_stack) == 0:
+            ctx = self.__context_stack.pop()
+            ctx.dispatch_event(EventType.E_QUIT, None)
+
+    def __redraw(self):
+        self.stdsc.erase()
+        self.__win.erase()
+        
+        self.active().redraw(self.__win)
+
+        self.__panbg.bottom()
+        self.__win.touchwin()
+        self.__winbg.touchwin()
+
+        self.__win.refresh()
+        self.__winbg.refresh()
+
+        cpanel.update_panels()
+        curses.doupdate()
+
+class TuiFocusGroup:
+    def __init__(self) -> None:
+        self.__grp = []
+        self.__id = 0
+        self.__sel = 0
+        self.__focused = None
+    
+    def register(self, tui_obj, pos=-1):
+        if pos == -1:
+            self.__grp.append((self.__id, tui_obj))
+        else:
+            self.__grp.insert(pos, (self.__id, tui_obj))
+        self.__id += 1
+        return self.__id - 1
+
+    def navigate_focus(self, dir = 1):
+        self.__sel = (self.__sel + dir) % len(self.__grp)
+        f = None if not len(self.__grp) else self.__grp[self.__sel][1]
+        if f and f != self.__focused:
+            if self.__focused:
+                self.__focused.on_focus_lost()
+            f.on_focused()
+        self.__focused = f
+
+    def focused(self):
+        return self.__focused
+
+class TuiContext(TuiObject):
+    def __init__(self, session: TuiSession):
+        super().__init__(self, "context")
+        self.__root = None
+        self.__sobj = None
+        self.__session = session
+
+        self.__win = None
+
+        y, x = self.__session.window_size()
+        self._size.reset(x, y)
+        self.set_parent(None)
+
+        self.__focus_group = TuiFocusGroup()
+        self.__curser_mode = 0
+
+    def set_curser_mode(self, mode):
+        self.__curser_mode = mode
+
+    def curser_mode(self):
+        return self.__curser_mode
+
+    def set_root(self, root):
+        self.__root = root
+        self.__root.set_parent(self)
+    
+    def set_state(self, obj):
+        self.__sobj = obj
+
+    def state(self):
+        return self.__sobj
+
+    def prepare(self):
+        self.__root.on_create()
+
+    def on_destory(self):
+        self.__root.on_destory()
+
+    def canvas(self):
+        return (self, self.__win)
+    
+    def session(self):
+        return self.__session
+    
+    def dispatch_event(self, evt, arg):
+        if evt == EventType.E_REDRAW:
+            self.__focus_group.navigate_focus(0)
+        elif evt == EventType.E_CHFOCUS:
+            self.__focus_group.navigate_focus(1)
+            self.__session.schedule(EventType.E_REDRAW)
+            return
+        elif evt == EventType.E_TASK:
+            arg()
+        elif evt == EventType.E_QUIT:
+            self.__root.on_quit()
+        elif evt == EventType.E_KEY:
+            self._handle_key_event(arg)
+        else:
+            self.__root.on_event(evt, arg)
+
+        focused = self.__focus_group.focused()
+        if focused:
+            focused.on_event(evt | EventType.E_M_FOCUS, arg)
+
+    def redraw(self, win):
+        self.__win = win
+        self.on_layout()
+        self.on_draw()
+
+    def on_layout(self):
+        self.__root.on_layout()
+
+    def on_draw(self):
+        self.__root.on_draw()
+
+    def focus_group(self):
+        return self.__focus_group
+    
+    def _handle_key_event(self, key):
+        if key == ord('\t') or key == curses.KEY_RIGHT:
+            self.__focus_group.navigate_focus()
+        elif key == curses.KEY_LEFT:
+            self.__focus_group.navigate_focus(-1)
+        else:
+            self.__root.on_event(EventType.E_KEY, key)
+        
+        if self.__focus_group.focused():
+            self.__session.schedule(EventType.E_REDRAW)
\ No newline at end of file
diff --git a/lunaix-os/scripts/build-tools/integration/lunamenu.py b/lunaix-os/scripts/build-tools/integration/lunamenu.py
new file mode 100644 (file)
index 0000000..c8d0730
--- /dev/null
@@ -0,0 +1,457 @@
+from lcfg.api import RenderContext
+from lcfg.types import (
+    PrimitiveType,
+    MultipleChoiceType
+)
+
+import subprocess
+import curses
+import textwrap
+import integration.libtui as tui
+import integration.libmenu as menu
+
+from integration.libtui import ColorScope, TuiColor, Alignment, EventType
+from integration.libmenu import Dialogue, ListView, show_dialog
+
+__git_repo_info = None
+__tainted = False
+
+def mark_tainted():
+    global __tainted
+    __tainted = True
+
+def unmark_tainted():
+    global __tainted
+    __tainted = False
+
+def get_git_hash():
+    try:
+        hsh = subprocess.check_output([
+                    'git', 'rev-parse', '--short', 'HEAD'
+                ]).decode('ascii').strip()
+        branch = subprocess.check_output([
+                    'git', 'branch', '--show-current'
+                ]).decode('ascii').strip()
+        return f"{branch}@{hsh}"
+    except:
+        return None
+    
+def get_git_info():
+    global __git_repo_info
+    return __git_repo_info
+    
+def do_save(session):
+    show_dialog(session, "Notice", "Configuration saved")
+    unmark_tainted()
+
+def do_exit(session):
+    global __tainted
+    if not __tainted:
+        session.schedule(EventType.E_QUIT)
+        return
+    
+    quit = QuitDialogue(session)
+    quit.show()
+
+class MainMenuContext(tui.TuiContext):
+    def __init__(self, session, view_title):
+        super().__init__(session)
+
+        self.__title = view_title
+        
+        self.__prepare_layout()
+
+    def __prepare_layout(self):
+        
+        root = tui.TuiPanel(self, "main_panel")
+        root.set_size("*-10", "*-5")
+        root.set_alignment(Alignment.CENTER)
+        root.drop_shadow(1, 2)
+        root.border(True)
+
+        layout = tui.FlexLinearLayout(self, "layout", "6,*,5")
+        layout.orientation(tui.FlexLinearLayout.PORTRAIT)
+        layout.set_size("*", "*")
+        layout.set_padding(1, 1, 1, 1)
+
+        listv = ListView(self, "list_view")
+        listv.set_size("70", "*")
+        listv.set_alignment(Alignment.CENTER)
+
+        hint = tui.TuiTextBlock(self, "hint")
+        hint.set_size(w="*")
+        hint.set_local_pos("0.1*", 0)
+        hint.height_auto_fit(True)
+        hint.set_text(
+            "Use <UP>/<DOWN>/<ENTER> to select from list\n"
+            "Use <TAB>/<RIGHT>/<LEFT> to change focus\n"
+            "<H>: show help (if applicable), <BACKSPACE>: back previous level"
+        )
+        hint.set_alignment(Alignment.CENTER | Alignment.LEFT)
+
+        suffix = ""
+        btns_defs = [
+            { 
+                "text": "Save", 
+                "onclick": lambda x: do_save(self.session())
+            },
+            { 
+                "text": "Exit", 
+                "onclick": lambda x: do_exit(self.session())
+            }
+        ]
+
+        repo_info = get_git_info()
+
+        if self.__title:
+            suffix += f" - {self.__title}"
+            btns_defs.insert(1, { 
+                "text": "Back", 
+                "onclick": lambda x: self.session().pop_context() 
+            })
+
+        btns = menu.create_buttons(self, btns_defs, sizes="50,*")
+
+        layout.set_cell(0, hint)
+        layout.set_cell(1, listv)
+        layout.set_cell(2, btns)
+
+        t = menu.create_title(self, "Lunaix Kernel Configuration" + suffix)
+        t2 = menu.create_title(self, repo_info)
+        t2.set_alignment(Alignment.BOT | Alignment.RIGHT)
+
+        root.add(t)
+        root.add(t2)
+        root.add(layout)
+
+        self.set_root(root)
+        self.__menu_list = listv
+
+    def menu(self):
+        return self.__menu_list
+
+    def _handle_key_event(self, key):
+        if key == curses.KEY_BACKSPACE or key == 8:
+            self.session().pop_context()
+        elif key == 27:
+            do_exit(self.session())
+            return
+        
+        super()._handle_key_event(key)
+
+class ItemType:
+    Expandable = 0
+    Switch = 1
+    Choice = 2
+    Other = 3
+    def __init__(self, node, expandable) -> None:
+        self.__node = node
+
+        if expandable:
+            self.__type = ItemType.Expandable
+            return
+        
+        self.__type = ItemType.Other
+        self.__primitive = False
+        type_provider = node.get_type()
+
+        if isinstance(type_provider, PrimitiveType):
+            self.__primitive = True
+
+            if isinstance(type_provider, MultipleChoiceType):
+                self.__type = ItemType.Choice
+            elif type_provider._type == bool:
+                self.__type = ItemType.Switch
+        
+        self.__provider = type_provider
+
+    def get_formatter(self):
+        if self.__type == ItemType.Expandable:
+            return "%s ---->"
+        
+        v = self.__node.get_value()
+
+        if self.is_switch():
+            mark = "*" if v else " "
+            return f"[{mark}] %s"
+        
+        if self.is_choice() or isinstance(v, int):
+            return f"({v}) %s"
+        
+        return "%s"
+    
+    def expandable(self):
+        return self.__type == ItemType.Expandable
+    
+    def is_switch(self):
+        return self.__type == ItemType.Switch
+    
+    def is_choice(self):
+        return self.__type == ItemType.Choice
+    
+    def read_only(self):
+        return not self.expandable() and self.__node.read_only()
+    
+    def provider(self):
+        return self.__provider
+
+class MultiChoiceItem(tui.SimpleList.Item):
+    def __init__(self, value, get_val) -> None:
+        super().__init__()
+        self.__val = value
+        self.__getval = get_val
+
+    def get_text(self):
+        marker = "*" if self.__getval() == self.__val else " "
+        return f"  ({marker}) {self.__val}"
+    
+    def value(self):
+        return self.__val
+    
+class LunaConfigItem(tui.SimpleList.Item):
+    def __init__(self, session, node, name, expand_cb = None):
+        super().__init__()
+        self.__node = node
+        self.__type = ItemType(node, expand_cb is not None)
+        self.__name = name
+        self.__expand_cb = expand_cb
+        self.__session = session
+    
+    def get_text(self):
+        fmt = self.__type.get_formatter()
+        if self.__type.read_only():
+            fmt += "*"
+        return f"   {fmt%(self.__name)}"
+    
+    def on_selected(self):
+        if self.__type.read_only():
+            show_dialog(
+                self.__session, 
+                f"Read-only: \"{self.__name}\"", 
+                f"Value defined in this field:\n\n'{self.__node.get_value()}'")
+            return
+        
+        if self.__type.expandable():
+            view = CollectionView(self.__session, self.__node, self.__name)
+            view.set_reloader(self.__expand_cb)
+            view.show()
+            return
+        
+        if self.__type.is_switch():
+            v = self.__node.get_value()
+            self.change_value(not v)
+        else:
+            dia = ValueEditDialogue(self.__session, self)
+            dia.show()
+
+        self.__session.schedule(EventType.E_REDRAW)
+
+    def name(self):
+        return self.__name
+    
+    def node(self):
+        return self.__node
+    
+    def type(self):
+        return self.__type
+    
+    def change_value(self, val):
+        try:
+            self.__node.set_value(val)
+        except:
+            show_dialog(
+                self.__session, "Invalid value",
+                f"Value: '{val}' does not match the type")
+            return False
+        
+        mark_tainted()
+        CollectionView.reload_active(self.__session)
+        return True
+    
+    def on_key_pressed(self, key):
+        if (key & ~0b100000) != ord('H'):
+            return
+        
+        h = self.__node.help_prompt()
+        if not self.__type.expandable():
+            h = "\n".join([
+                h, "", "--------",
+                "Supported Values:",
+                textwrap.indent(str(self.__type.provider()), "  ")
+            ])
+
+        dia = HelpDialogue(self.__session, f"Help: '{self.__name}'", h)
+        dia.show()
+    
+class CollectionView(RenderContext):
+    def __init__(self, session, node, label = None) -> None:
+        super().__init__()
+        
+        ctx = MainMenuContext(session, label)
+        self.__node = node
+        self.__tui_ctx = ctx
+        self.__listv = ctx.menu()
+        self.__session = session
+        self.__reloader = lambda x: node.render(x)
+
+        ctx.set_state(self)
+
+    def set_reloader(self, cb):
+        self.__reloader = cb
+
+    def add_expandable(self, label, node, on_expand_cb):
+        item = LunaConfigItem(self.__session, node, label, on_expand_cb)
+        self.__listv.add_item(item)
+
+    def add_field(self, label, node):
+        item = LunaConfigItem(self.__session, node, label)
+        self.__listv.add_item(item)
+
+    def show(self):
+        self.reload()
+        self.__session.push_context(self.__tui_ctx)
+
+    def reload(self):
+        self.__listv.clear()
+        self.__reloader(self)
+        self.__session.schedule(EventType.E_REDRAW)
+
+    @staticmethod
+    def reload_active(session):
+        state = session.active().state()
+        if isinstance(state, CollectionView):
+            state.reload()
+
+class ValueEditDialogue(menu.Dialogue):
+    def __init__(self, session, item: LunaConfigItem):
+        name = item.name()
+        title = f"Edit \"{name}\""
+        super().__init__(session, title, None, False, 
+                         "Confirm", "Cancle")
+        
+        self.__item = item
+        self.__value = item.node().get_value()
+
+        self.decide_content()
+
+    def __get_val(self):
+        return self.__value
+
+    def decide_content(self):
+        if not self.__item.type().is_choice():
+            self.set_input_dialogue(True)
+            return
+        
+        listv = ListView(self.context(), "choices")
+        listv.set_size("0.8*", "*")
+        listv.set_alignment(Alignment.CENTER)
+        listv.set_onselected_cb(self.__on_selected)
+
+        for t in self.__item.type().provider()._type:
+            listv.add_item(MultiChoiceItem(t, self.__get_val))
+        
+        self.set_content(listv)
+        self.set_size()
+    
+    def __on_selected(self, listv, index, item):
+        self.__value = item.value()
+    
+    def _ok_onclick(self):
+        if self._textbox:
+            self.__value = self._textbox.get_text()
+
+        if self.__item.change_value(self.__value):
+            super()._ok_onclick()
+
+class QuitDialogue(menu.Dialogue):
+    def __init__(self, session):
+        super().__init__(session, 
+                         "Quit ?", "Unsaved changes, sure to quit?", False, 
+                         "Quit Anyway", "No", "Save and Quit")
+        
+    def _ok_onclick(self):
+        self.session().schedule(EventType.E_QUIT)
+
+    def _abort_onclick(self):
+        unmark_tainted()
+        self._ok_onclick()
+
+
+class HelpDialogue(menu.Dialogue):
+    def __init__(self, session, title="", content=""):
+        super().__init__(session, title, None, no_btn=None)
+
+        self.__content = content
+        self.__scroll_y = 0
+        self.set_local_pos(0, -2)
+
+    def prepare(self):
+        tb = tui.TuiTextBlock(self._context, "content")
+        tb.set_size(w="70")
+        tb.set_text(self.__content)
+        tb.height_auto_fit(True)
+        self.__tb = tb
+        
+        self.__scroll = tui.TuiScrollable(self._context, "scroll")
+        self.__scroll.set_size("65", "*")
+        self.__scroll.set_alignment(Alignment.CENTER)
+        self.__scroll.set_content(tb)
+
+        self.set_size(w="75")
+        self.set_content(self.__scroll)
+        
+        super().prepare()
+
+    def _handle_key_event(self, key):
+        if key == curses.KEY_UP:
+            self.__scroll_y = max(self.__scroll_y - 1, 0)
+            self.__scroll.set_scrollY(self.__scroll_y)
+        elif key == curses.KEY_DOWN:
+            y = self.__tb._size.y()
+            self.__scroll_y = min(self.__scroll_y + 1, y)
+            self.__scroll.set_scrollY(self.__scroll_y)
+        super()._handle_key_event(key)
+
+class TerminalSizeCheckFailed(Exception):
+    def __init__(self, *args: object) -> None:
+        super().__init__(*args)
+
+def main(_, root_node):
+    global __git_repo_info
+
+    __git_repo_info = get_git_hash()
+
+    session = tui.TuiSession()
+
+    h, w = session.window_size()
+    if h < 30 or w < 85:
+        raise TerminalSizeCheckFailed((90, 40), (w, h))
+
+    base_background = TuiColor.white.bright()
+    session.set_color(ColorScope.WIN,   
+                        TuiColor.black, TuiColor.blue)
+    session.set_color(ColorScope.PANEL, 
+                        TuiColor.black, base_background)
+    session.set_color(ColorScope.TEXT,  
+                        TuiColor.black, base_background)
+    session.set_color(ColorScope.TEXT_HI,  
+                        TuiColor.magenta, base_background)
+    session.set_color(ColorScope.SHADOW, 
+                        TuiColor.black, TuiColor.black)
+    session.set_color(ColorScope.SELECT, 
+                        TuiColor.white, TuiColor.black.bright())
+    session.set_color(ColorScope.HINT, 
+                        TuiColor.cyan, base_background)
+    session.set_color(ColorScope.BOX, 
+                        TuiColor.black, TuiColor.white)
+
+    main_view = CollectionView(session, root_node)
+    main_view.show()
+
+    session.event_loop()
+
+def menuconfig(root_node):
+    global __tainted
+    curses.wrapper(main, root_node)
+
+    return not __tainted
\ No newline at end of file
index 123f6a926d45522356cc7abf3a1f632666ddb609..8fc373de9074b51d156058824974ac08b6af16c5 100644 (file)
@@ -70,8 +70,8 @@ class MultipleChoiceType(PrimitiveType):
         return None in self._type
     
     def __str__(self) -> str:
-        accepted = [f"  {t}" for t in self._type]
+        accepted = [f"  {t}" for t in self._type]
         return "\n".join([
-            "choose one: \n",
+            "choose one:",
             *accepted
         ])
index e3c6908672b0c148c83bc404b13ae84cae316510..ce3be825f5b9779cf1a188d62d51634d0dbb93cc 100755 (executable)
@@ -8,6 +8,7 @@ from integration.config_io import CHeaderConfigProvider
 from integration.lbuild_bridge import LConfigProvider
 from integration.render_ishell import InteractiveShell
 from integration.build_gen import MakefileBuildGen, install_lbuild_functions
+from integration.lunamenu import menuconfig, TerminalSizeCheckFailed
 
 import lcfg.types as lcfg_type
 import lcfg.builtins as builtin
@@ -37,12 +38,23 @@ def prepare_lconfig_env(out_dir):
 
 def do_config(opt, lcfg_env):
     redo_config = not exists(opt.config_save) or opt.force
-    if not redo_config:
+    if not redo_config or opt.quiet:
         return
+
+    try:
+        clean_quit = menuconfig(lcfg_env)
+    except TerminalSizeCheckFailed as e:
+        least = e.args[0]
+        current = e.args[1]
+        print(
+            f"Your terminal size: {current} is less than minimum requirement of {least}.\n"
+            "menuconfig will not function properly, switch to prompt based.\n")
+
+        shell = InteractiveShell(lcfg_env)
+        clean_quit = shell.render_loop()
     
-    shell = InteractiveShell(lcfg_env)
-    if not shell.render_loop():
-        print("Configuration aborted.")
+    if not clean_quit:
+        print("Configuration aborted. Nothing has been saved.")
         exit(-1)
 
 def do_buildfile_gen(opts, lcfg_env):
@@ -71,6 +83,7 @@ def do_buildfile_gen(opts, lcfg_env):
 def main():
     parser = ArgumentParser()
     parser.add_argument("--config", action="store_true", default=False)
+    parser.add_argument("--quiet", action="store_true", default=False)
     parser.add_argument("--lconfig-file", default="LConfig")
     parser.add_argument("--config-save", default=".config.json")
     parser.add_argument("--force", action="store_true", default=False)
index f2808e53d2911e44d25e3bed3d8956ecf1066943..f12881afc8360c9a5faaefb1d9f58541fd798702 100755 (executable)
@@ -3,8 +3,10 @@
 import subprocess, time, os, re, argparse, json
 from pathlib import PurePosixPath
 import logging
+import uuid
 
 logger = logging.getLogger("auto_qemu")
+logging.basicConfig(level=logging.INFO)
 
 g_lookup = {}
 
@@ -51,12 +53,89 @@ def get_config(opt, path, default=None, required=False):
 def join_attrs(attrs):
     return ",".join(attrs)
 
-def parse_protocol(opt):
-    protocol = get_config(opt, "protocol", "telnet")
-    addr     = get_config(opt, "addr", ":12345")
-    logfile  = get_config(opt, "logfile")
+def get_uniq():
+    return uuid.uuid4().hex[:8]
 
-    return (f"{protocol}:{addr}", logfile)
+def map_bool(b):
+    return "on" if b else "off"
+    
+
+
+#################################
+# IO Backend Definitions  
+#
+
+class IOBackend:
+    def __init__(self, opt, id_prefix="io") -> None:
+        self._type = get_config(opt, "type", required=True)
+        self._logfile = get_config(opt, "logfile")
+        self._id = f"{id_prefix}.{get_uniq()}"
+
+    def get_options(self):
+        opts = []
+        if self._logfile:
+            opts.append(f"logfile={self._logfile}")
+        return opts
+    
+    def to_cmdline(self):
+        return join_attrs([
+            self._type, f"id={self._id}", *self.get_options()
+        ])
+    
+    def name(self):
+        return self._id
+
+class FileIOBackend(IOBackend):
+    def __init__(self, opt) -> None:
+        super().__init__(opt)
+        self.__path = get_config(opt, "path", required=True)
+
+    def get_options(self):
+        opts = [
+            f"path={self.__path}"
+        ]
+        return opts + super().get_options()
+
+class SocketIOBackend(IOBackend):
+    def __init__(self, opt) -> None:
+        super().__init__(opt)
+        self.__protocol = self._type
+        self._type = "socket"
+        self.__host = get_config(opt, "host", default="localhost")
+        self.__port = get_config(opt, "port", required=True)
+        self.__server = bool(get_config(opt, "server", True))
+        self.__wait = bool(get_config(opt, "wait", True))
+
+    def get_options(self):
+        opts = [
+            f"host={self.__host}",
+            f"port={self.__port}",
+            f"server={map_bool(self.__server)}",
+            f"wait={map_bool(self.__wait)}",
+        ]
+        if self.__protocol == "telnet":
+            opts.append("telnet=on")
+        if self.__protocol == "ws":
+            opts.append("websocket=on")
+        return opts + super().get_options()
+    
+def select_backend(opt):
+    bopt = get_config(opt, "io", required=True)
+    backend_type = get_config(bopt, "type", required=True)
+    
+    if backend_type in ["telnet", "ws", "tcp"]:
+        return SocketIOBackend(bopt)
+    
+    if backend_type in ["file", "pipe", "serial", "parallel"]:
+        return FileIOBackend(bopt)
+    
+    return IOBackend(bopt)
+
+
+
+#################################
+# QEMU Emulated Device Definitions
+#
 
 class QEMUPeripherals:
     def __init__(self, name, opt) -> None:
@@ -66,39 +145,71 @@ class QEMUPeripherals:
     def get_qemu_opts(self) -> list:
         pass
 
-class BasicSerialDevice(QEMUPeripherals):
+class ISASerialDevice(QEMUPeripherals):
     def __init__(self, opt) -> None:
-        super().__init__("serial", opt)
+        super().__init__("isa-serial", opt)
 
     def get_qemu_opts(self):
-        link, logfile = parse_protocol(self._opt)
+        chardev = select_backend(self._opt)
+
+        cmds = [ 
+            "isa-serial", 
+           f"id=com.{get_uniq()}", 
+           f"chardev={chardev.name()}" 
+        ]
 
-        cmds = [ link, "server", "nowait" ]
-        if logfile:
-            cmds.append(f"logfile={logfile}")
-        return [ "-serial", join_attrs(cmds) ]
+        return [ 
+            "-chardev", chardev.to_cmdline(),
+            "-device", join_attrs(cmds)
+         ]
     
 class PCISerialDevice(QEMUPeripherals):
     def __init__(self, opt) -> None:
         super().__init__("pci-serial", opt)
 
     def get_qemu_opts(self):
-        uniq = hex(self.__hash__())[2:]
-        name = f"chrdev.{uniq}"
-        cmds = [ "pci-serial", f"id=uart.{uniq}", f"chardev={name}" ]
-        chrdev = [ "file", f"id={name}" ]
-        
-        logfile = get_config(self._opt, "logfile", required=True)
-        chrdev.append(f"path={logfile}")
+        chardev = select_backend(self._opt)
+
+        cmds = [ 
+            "pci-serial", 
+           f"id=uart.{get_uniq()}", 
+           f"chardev={chardev.name()}" 
+        ]
 
         return [ 
-            "-chardev", join_attrs(chrdev),
+            "-chardev", chardev.to_cmdline(),
             "-device", join_attrs(cmds)
          ]
     
 class AHCIBus(QEMUPeripherals):
     def __init__(self, opt) -> None:
         super().__init__("ahci", opt)
+        
+    def __create_disklet(self, index, bus, opt):
+        d_type = get_config(opt, "type",   default="ide-hd")
+        d_img  = get_config(opt, "img",    required=True)
+        d_ro   = get_config(opt, "ro",     default=False)
+        d_fmt  = get_config(opt, "format", default="raw")
+        d_id   = f"disk_{index}"
+
+        if not os.path.exists(d_img):
+            logger.warning(f"AHCI bus: {d_img} not exists, skipped")
+            return []
+        
+        return [
+            "-drive", join_attrs([
+                f"id={d_id}",
+                f"file={d_img}",
+                f"readonly={'on' if d_ro else 'off'}",
+                f"if=none",
+                f"format={d_fmt}"
+            ]),
+            "-device", join_attrs([
+                d_type,
+                f"drive={d_id}",
+                f"bus={bus}.{index}"
+            ])
+        ]
 
     def get_qemu_opts(self):
         opt = self._opt
@@ -106,31 +217,9 @@ class AHCIBus(QEMUPeripherals):
         name = name.strip().replace(" ", "_")
         cmds = [ "-device", f"ahci,id={name}" ]
 
-        for i, disk in enumerate(get_config(opt, "disks", default=[])):
-            d_type = get_config(disk, "type",   default="ide-hd")
-            d_img  = get_config(disk, "img",    required=True)
-            d_ro   = get_config(disk, "ro",     default=False)
-            d_fmt  = get_config(disk, "format", default="raw")
-            d_id   = f"disk_{i}"
-
-            if not os.path.exists(d_img):
-                logger.warning(f"AHCI bus: {d_img} not exists, disk skipped")
-                continue
-            
-            cmds += [
-                "-drive", join_attrs([
-                    f"id={d_id},"
-                    f"file={d_img}",
-                    f"readonly={'on' if d_ro else 'off'}",
-                    f"if=none",
-                    f"format={d_fmt}"
-                ]),
-                "-device", join_attrs([
-                    d_type,
-                    f"drive={d_id}",
-                    f"bus={name}.{i}"
-                ])
-            ]
+        disklets = get_config(opt, "disks", default=[])
+        for i, disk in enumerate(disklets):
+            cmds += self.__create_disklet(i, name, disk)
         
         return cmds
     
@@ -148,36 +237,49 @@ class QEMUMonitor(QEMUPeripherals):
         super().__init__("monitor", opt)
 
     def get_qemu_opts(self):
-        link, logfile = parse_protocol(self._opt)
+        
+        chardev = select_backend(self._opt)
 
         return [
-            "-monitor", join_attrs([
-                link,
-                "server",
-                "nowait", 
-                f"logfile={logfile}"
+            "-chardev", chardev.to_cmdline(),
+            "-mon", join_attrs([
+                chardev.name(),
+                "mode=readline",
             ])
         ]
 
-class QEMUExec:
-    devices = {
-        "basic_serial": BasicSerialDevice,
+class QEMUDevices:
+    __devs = {
+        "isa-serial": ISASerialDevice,
         "ahci": AHCIBus,
         "rtc": RTCDevice,
         "hmp": QEMUMonitor,
         "pci-serial": PCISerialDevice
     }
 
+    @staticmethod
+    def get(name):
+        if name not in QEMUDevices.__devs:
+            raise Exception(f"device class: {name} is not defined")
+        return QEMUDevices.__devs[name]
+
+
+
+#################################
+# QEMU Machine Definitions
+#
+
+class QEMUExec:
+    
+
     def __init__(self, options) -> None:
         self._opt = options
         self._devices = []
 
         for dev in get_config(options, "devices", default=[]):
             dev_class = get_config(dev, "class")
-            if dev_class not in QEMUExec.devices:
-                raise Exception(f"device class: {dev_class} is not defined")
-            
-            self._devices.append(QEMUExec.devices[dev_class](dev))
+            device = QEMUDevices.get(dev_class)
+            self._devices.append(device(dev))
     
     def get_qemu_exec_name(self):
         pass
@@ -217,7 +319,7 @@ class QEMUExec:
     def add_peripheral(self, peripheral):
         self._devices.append(peripheral)
 
-    def start(self, qemu_dir_override=""):
+    def start(self, qemu_dir_override="", dryrun=False, extras=[]):
         qemu_path = self.get_qemu_exec_name()
         qemu_path = os.path.join(qemu_dir_override, qemu_path)
         cmds = [
@@ -230,7 +332,12 @@ class QEMUExec:
         for dev in self._devices:
             cmds += dev.get_qemu_opts()
 
+        cmds += extras
         print(" ".join(cmds), "\n")
+
+        if dryrun:
+            logger.info("[DRY RUN] QEMU not invoked")
+            return
         
         handle = subprocess.Popen(cmds)
         
@@ -257,9 +364,10 @@ def main():
 
     arg.add_argument("config_file")
     arg.add_argument("--qemu-dir", default="")
+    arg.add_argument("--dry", action='store_true')
     arg.add_argument("-v", "--values", action='append', default=[])
 
-    arg_opt = arg.parse_args()
+    arg_opt, extras = arg.parse_known_args()
 
     opts = {}
     with open(arg_opt.config_file, 'r') as f:
@@ -277,7 +385,8 @@ def main():
     else:
         raise Exception(f"undefined arch: {arch}")
     
-    q.start(arg_opt.qemu_dir)
+    extras = [ x for x in extras if x != '--']
+    q.start(arg_opt.qemu_dir, arg_opt.dry, extras)
 
 if __name__ == "__main__":
     main()
\ No newline at end of file
index 023dfece1821dae79f0944f3faa04f532d85b0ca..60b292e780949cd3b0af72ee63bb3706827e2ab1 100644 (file)
     },
     "devices": [
         {
-            "class": "basic_serial",
-            "protocol": "telnet",
-            "addr": ":12345",
-            "logfile": "lunaix_ttyS0.log"
+            "class": "isa-serial",
+            "io": {
+                "type": "telnet",
+                "port": "12345",
+                "logfile": "lunaix_ttyS0.log"
+            }
         },
         {
             "class": "pci-serial",
-            "logfile": "ttyPCI0.log"
+            "io": {
+                "type": "null",
+                "logfile": "ttypci1.log"
+            }
         },
         {
             "class": "pci-serial",
-            "logfile": "ttyPCI1.log"
+            "io": {
+                "type": "null",
+                "logfile": "ttypci2.log"
+            }
         },
         {
             "class": "rtc",
         },
         {
             "class": "hmp",
-            "protocol": "telnet",
-            "addr": ":$QMPORT",
-            "logfile": "qm.log"
+            "io": {
+                "type": "telnet",
+                "port": "$QMPORT",
+                "logfile": "qm.log"
+            }
         }
     ]
 }
\ No newline at end of file
index ea38d57f77c808e14f4bb7dd4aafd25720443bd3..3f44fe28f96955a57a108166f362173d9023ac6d 100644 (file)
@@ -1,4 +1,4 @@
-@Term
+@Term("Architecture")
 def arch():
     """
         set the ISA target
index 54f72608929aff5c4a59a52062bcadd861c4d4c6..a69500f2f728949c2b4c9ec3dfd1e8e853034421 100644 (file)
@@ -1,5 +1,8 @@
 include utils.mkinc
 include toolchain.mkinc
+
+LCONFIG_FLAGS := --quiet
+
 include lunabuild.mkinc
 
 include $(lbuild_mkinc)