+#
+# 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