Menuconfig Implementation and auto-qemu refactoring (#44)
[lunaix-os.git] / lunaix-os / scripts / build-tools / integration / libtui.py
1 #
2 # libtui - TUI framework using ncurses
3 #  (c) 2024 Lunaixsky
4
5 # I sware, this is the last time I ever touch 
6 #  any sort of the GUI messes.
7 #
8
9 import curses
10 import re
11 import curses.panel as cpanel
12 import curses.textpad as textpad
13 import textwrap
14
15 def __invoke_fn(obj, fn, *args):
16     try:
17         fn(*args)
18     except:
19         _id = obj._id if obj else "<root>"
20         raise Exception(_id, str(fn), args)
21
22 def resize_safe(obj, co, y, x):
23     __invoke_fn(obj, co.resize, y, x)
24
25 def move_safe(obj, co, y, x):
26     __invoke_fn(obj, co.move, y, x)
27     
28 def addstr_safe(obj, co, y, x, str, *args):
29     __invoke_fn(obj, co.addstr, y, x, str, *args)
30
31 class _TuiColor:
32     def __init__(self, v) -> None:
33         self.__v = v
34     def __int__(self):
35         return self.__v
36     def bright(self):
37         return self.__v + 8
38
39 class TuiColor:
40     black     = _TuiColor(curses.COLOR_BLACK)
41     red       = _TuiColor(curses.COLOR_RED)
42     green     = _TuiColor(curses.COLOR_GREEN)
43     yellow    = _TuiColor(curses.COLOR_YELLOW)
44     blue      = _TuiColor(curses.COLOR_BLUE)
45     magenta   = _TuiColor(curses.COLOR_MAGENTA)
46     cyan      = _TuiColor(curses.COLOR_CYAN)
47     white     = _TuiColor(curses.COLOR_WHITE)
48
49 class Alignment:
50     LEFT    = 0b000001
51     RIGHT   = 0b000010
52     CENTER  = 0b000100
53     TOP     = 0b001000
54     BOT     = 0b010000
55     ABS     = 0b000000
56     REL     = 0b100000
57
58 class ColorScope:
59     WIN       = 1
60     PANEL     = 2
61     TEXT      = 3
62     TEXT_HI   = 4
63     SHADOW    = 5
64     SELECT    = 6
65     HINT      = 7
66     BOX       = 8
67
68 class EventType:
69     E_KEY = 0
70     E_REDRAW = 1
71     E_QUIT = 2
72     E_TASK = 3
73     E_CHFOCUS = 4
74     E_M_FOCUS = 0b10000000
75
76     def focused_only(t):
77         return (t & EventType.E_M_FOCUS)
78     
79     def value(t):
80         return t & ~EventType.E_M_FOCUS
81     
82     def key_press(t):
83         return (t & ~EventType.E_M_FOCUS) == EventType.E_KEY
84
85 class Matchers:
86     RelSize = re.compile(r"(?P<mult>[0-9]+(?:\.[0-9]+)?)?\*(?P<add>[+-][0-9]+)?")
87
88 class BoundExpression:
89     def __init__(self, expr = None):
90         self._mult = 0
91         self._add  = 0
92
93         if expr:
94             self.update(expr)
95
96     def set_pair(self, mult, add):
97         self._mult = mult
98         self._add  = add
99
100     def set(self, expr):
101         self._mult = expr._mult
102         self._add  = expr._add
103         
104     def update(self, expr):
105         if isinstance(expr, int):
106             m = None
107         else:
108             m = Matchers.RelSize.match(expr)
109
110         if m:
111             g = m.groupdict()
112             mult = 1 if not g["mult"] else float(g["mult"])
113             add = 0 if not g["add"] else int(g["add"])
114             self._mult = mult
115             self._add  = add
116         else:
117             self.set_pair(0, int(expr))
118
119     def calc(self, ref_val):
120         return int(self._mult * ref_val + self._add)
121     
122     def absolute(self):
123         return self._mult == 0
124     
125     def nullity(self):
126         return self._mult == 0 and self._add == 0
127     
128     def scale_mult(self, scalar):
129         self._mult *= scalar
130         return self
131     
132     @staticmethod
133     def normalise(*exprs):
134         v = BoundExpression()
135         for e in exprs:
136             v += e
137         return [e.scale_mult(1 / v._mult) for e in exprs]
138
139     def __add__(self, b):
140         v = BoundExpression()
141         v.set(self)
142         v._mult += b._mult
143         v._add  += b._add
144         return v
145
146     def __sub__(self, b):
147         v = BoundExpression()
148         v.set(self)
149         v._mult -= b._mult
150         v._add  -= b._add
151         return v
152     
153     def __iadd__(self, b):
154         self._mult += b._mult
155         self._add  += b._add
156         return self
157
158     def __isub__(self, b):
159         self._mult -= b._mult
160         self._add  -= b._add
161         return self
162     
163     def __rmul__(self, scalar):
164         v = BoundExpression()
165         v.set(self)
166         v._mult *= scalar
167         v._add *= scalar
168         return v
169
170     def __truediv__(self, scalar):
171         v = BoundExpression()
172         v.set(self)
173         v._mult /= float(scalar)
174         v._add /= scalar
175         return v
176     
177 class DynamicBound:
178     def __init__(self):
179         self.__x = BoundExpression()
180         self.__y = BoundExpression()
181     
182     def dyn_x(self):
183         return self.__x
184
185     def dyn_y(self):
186         return self.__y
187     
188     def resolve(self, ref_w, ref_h):
189         return (self.__y.calc(ref_h), self.__x.calc(ref_w))
190     
191     def set(self, dyn_bound):
192         self.__x.set(dyn_bound.dyn_x())
193         self.__y.set(dyn_bound.dyn_y())
194
195 class Bound:
196     def __init__(self) -> None:
197         self.__x = 0
198         self.__y = 0
199
200     def shrinkX(self, dx):
201         self.__x -= dx
202     def shrinkY(self, dy):
203         self.__y -= dy
204
205     def growX(self, dx):
206         self.__x += dx
207     def growY(self, dy):
208         self.__y += dy
209
210     def resetX(self, x):
211         self.__x = x
212     def resetY(self, y):
213         self.__y = y
214
215     def update(self, dynsz, ref_bound):
216         y, x = dynsz.resolve(ref_bound.x(), ref_bound.y())
217         self.__x = x
218         self.__y = y
219
220     def reset(self, x, y):
221         self.__x, self.__y = x, y
222
223     def x(self):
224         return self.__x
225     
226     def y(self):
227         return self.__y
228     
229     def yx(self, scale = 1):
230         return int(self.__y * scale), int(self.__x * scale)
231
232 class TuiStackWindow:
233     def __init__(self, obj) -> None:
234         self.__obj = obj
235         self.__win = curses.newwin(0, 0)
236         self.__pan = cpanel.new_panel(self.__win)
237         self.__pan.hide()
238
239     def resize(self, h, w):
240         resize_safe(self.__obj, self.__win, h, w)
241     
242     def relocate(self, y, x):
243         move_safe(self.__obj, self.__pan, y, x)
244
245     def set_geometric(self, h, w, y, x):
246         resize_safe(self.__obj, self.__win, h, w)
247         move_safe(self.__obj, self.__pan, y, x)
248
249     def set_background(self, color_scope):
250         self.__win.bkgd(' ', curses.color_pair(color_scope))
251
252     def show(self):
253         self.__pan.show()
254
255     def hide(self):
256         self.__pan.hide()
257     
258     def send_back(self):
259         self.__pan.bottom()
260
261     def send_front(self):
262         self.__pan.top()
263
264     def window(self):
265         return self.__win
266
267 class SpatialObject:
268     def __init__(self) -> None:
269         self._local_pos = DynamicBound()
270         self._pos = Bound()
271         self._dyn_size = DynamicBound()
272         self._size = Bound()
273         self._margin = (0, 0, 0, 0)
274         self._padding = (0, 0, 0, 0)
275         self._align = Alignment.TOP | Alignment.LEFT
276
277     def set_local_pos(self, x, y):
278         self._local_pos.dyn_x().update(x)
279         self._local_pos.dyn_y().update(y)
280
281     def set_alignment(self, align):
282         self._align = align
283
284     def set_size(self, w = None, h = None):
285         if w:
286             self._dyn_size.dyn_x().update(w)
287         if h:
288             self._dyn_size.dyn_y().update(h)
289
290     def set_margin(self, top, right, bottom, left):
291         self._margin = (top, right, bottom, left)
292
293     def set_padding(self, top, right, bottom, left):
294         self._padding = (top, right, bottom, left)
295
296     def reset(self):
297         self._pos.reset(0, 0)
298         self._size.reset(0, 0)
299
300     def deduce_spatial(self, constrain):
301         self.reset()
302         self.__satisfy_bound(constrain)
303         self.__satisfy_alignment(constrain)
304         self.__satisfy_margin(constrain)
305         self.__satisfy_padding(constrain)
306
307         self.__to_corner_pos(constrain)
308
309     def __satisfy_alignment(self, constrain):
310         local_pos = self._local_pos
311         cbound = constrain._size
312         size = self._size
313
314         cy, cx = cbound.yx()
315         ry, rx = local_pos.resolve(cx, cy)
316         ay, ax = size.yx(0.5)
317         
318         if self._align & Alignment.CENTER:
319             ax = cx // 2
320             ay = cy // 2
321         
322         if self._align & Alignment.BOT:
323             ay = min(cy - ay, cy - 1)
324             ry = -ry
325         elif self._align & Alignment.TOP:
326             ay = size.y() // 2
327
328         if self._align & Alignment.RIGHT:
329             ax = cx - ax
330             rx = -rx
331         elif self._align & Alignment.LEFT:
332             ax = size.x() // 2
333
334         self._pos.reset(ax + rx, ay + ry)
335
336     def __satisfy_margin(self, constrain):
337         tm, lm, bm, rm = self._margin
338         
339         self._pos.growX(rm - lm)
340         self._pos.growY(bm - tm)
341
342     def __satisfy_padding(self, constrain):
343         csize = constrain._size
344         ch, cw = csize.yx()
345         h, w = self._size.yx(0.5)
346         y, x = self._pos.yx()
347
348         tp, lp, bp, rp = self._padding
349
350         if not (tp or lp or bp or rp):
351             return
352
353         dtp = min(y - h, tp) - tp
354         dbp = min(ch - (y + h), bp) - bp
355
356         dlp = min(x - w, lp) - lp
357         drp = min(cw - (x + w), rp) - rp
358
359         self._size.growX(drp + dlp)
360         self._size.growY(dtp + dbp)
361
362     def __satisfy_bound(self, constrain):
363         self._size.update(self._dyn_size, constrain._size)
364
365     def __to_corner_pos(self, constrain):
366         h, w = self._size.yx(0.5)
367         g_pos = constrain._pos
368
369         self._pos.shrinkX(w)
370         self._pos.shrinkY(h)
371
372         self._pos.growX(g_pos.x())
373         self._pos.growY(g_pos.y())
374         
375
376 class TuiObject(SpatialObject):
377     def __init__(self, context, id):
378         super().__init__()
379         self._id = id
380         self._context = context
381         self._parent = None
382         self._visible = True
383         self._focused = False
384
385     def set_parent(self, parent):
386         self._parent = parent
387
388     def canvas(self):
389         if self._parent:
390             return self._parent.canvas()
391         return (self, self._context.window())
392     
393     def context(self):
394         return self._context
395     
396     def session(self):
397         return self._context.session()
398
399     def on_create(self):
400         pass
401
402     def on_destory(self):
403         pass
404
405     def on_quit(self):
406         pass
407
408     def on_layout(self):
409         if self._parent:
410             self.deduce_spatial(self._parent)
411
412     def on_draw(self):
413         pass
414
415     def on_event(self, ev_type, ev_arg):
416         pass
417
418     def on_focused(self):
419         self._focused = True
420
421     def on_focus_lost(self):
422         self._focused = False
423
424     def set_visbility(self, visible):
425         self._visible = visible
426
427     def do_draw(self):
428         if self._visible:
429             self.on_draw()
430     
431     def do_layout(self):
432         self.on_layout()
433
434 class TuiWidget(TuiObject):
435     def __init__(self, context, id):
436         super().__init__(context, id)
437     
438     def on_layout(self):
439         super().on_layout()
440         
441         co, _ = self.canvas()
442
443         y, x = co._pos.yx()
444         self._pos.shrinkX(x)
445         self._pos.shrinkY(y)
446
447 class TuiContainerObject(TuiObject):
448     def __init__(self, context, id):
449         super().__init__(context, id)
450         self._children = []
451
452     def add(self, child):
453         child.set_parent(self)
454         self._children.append(child)
455
456     def children(self):
457         return self._children
458
459     def on_create(self):
460         super().on_create()
461         for child in self._children:
462             child.on_create()
463
464     def on_destory(self):
465         super().on_destory()
466         for child in self._children:
467             child.on_destory()
468     
469     def on_quit(self):
470         super().on_quit()
471         for child in self._children:
472             child.on_quit()
473
474     def on_layout(self):
475         super().on_layout()
476         for child in self._children:
477             child.do_layout()
478
479     def on_draw(self):
480         super().on_draw()
481         for child in self._children:
482             child.do_draw()
483
484     def on_event(self, ev_type, ev_arg):
485         super().on_event(ev_type, ev_arg)
486         for child in self._children:
487             child.on_event(ev_type, ev_arg)
488
489
490 class TuiScrollable(TuiObject):
491     def __init__(self, context, id):
492         super().__init__(context, id)
493         self.__spos = Bound()
494
495         self.__pad = curses.newpad(1, 1)
496         self.__pad.bkgd(' ', curses.color_pair(ColorScope.PANEL))
497         self.__pad_panel = cpanel.new_panel(self.__pad)
498         self.__content = None
499
500     def canvas(self):
501         return (self, self.__pad)
502
503     def set_content(self, content):
504         self.__content = content
505         self.__content.set_parent(self)
506
507     def set_scrollY(self, y):
508         self.__spos.resetY(y)
509
510     def set_scrollX(self, x):
511         self.__spos.resetX(x)
512
513     def reached_last(self):
514         off = self.__spos.y() + self._size.y()
515         return off >= self.__content._size.y()
516     
517     def reached_top(self):
518         return self.__spos.y() < self._size.y()
519
520     def on_layout(self):
521         super().on_layout()
522
523         if not self.__content:
524             return
525         
526         self.__content.on_layout()
527                 
528         h, w = self._size.yx()
529         ch, cw = self.__content._size.yx()
530         sh, sw = max(ch, h), max(cw, w)
531
532         self.__spos.resetX(min(self.__spos.x(), max(cw, w) - w))
533         self.__spos.resetY(min(self.__spos.y(), max(ch, h) - h))
534
535         resize_safe(self, self.__pad, sh, sw)
536
537     def on_draw(self):
538         if not self.__content:
539             return
540         
541         self.__pad.erase()
542
543         self.__pad_panel.top()
544         self.__content.on_draw()
545
546         wminy, wminx = self._pos.yx()
547         wmaxy, wmaxx = self._size.yx()
548         wmaxy, wmaxx = wmaxy + wminy, wmaxx + wminx
549         self.__pad.touchwin()
550         self.__pad.refresh(*self.__spos.yx(), 
551                             wminy, wminx, wmaxy - 1, wmaxx - 1)
552
553
554 class Layout(TuiContainerObject):
555     class Cell(TuiObject):
556         def __init__(self, context):
557             super().__init__(context, "cell")
558             self.__obj = None
559
560         def set_obj(self, obj):
561             self.__obj = obj
562             self.__obj.set_parent(self)
563
564         def on_create(self):
565             if self.__obj:
566                 self.__obj.on_create()
567
568         def on_destory(self):
569             if self.__obj:
570                 self.__obj.on_destory()
571
572         def on_quit(self):
573             if self.__obj:
574                 self.__obj.on_quit()
575
576         def on_layout(self):
577             super().on_layout()
578             if self.__obj:
579                 self.__obj.do_layout()
580
581         def on_draw(self):
582             if self.__obj:
583                 self.__obj.do_draw()
584
585         def on_event(self, ev_type, ev_arg):
586             if self.__obj:
587                 self.__obj.on_event(ev_type, ev_arg)
588     
589     def __init__(self, context, id, ratios):
590         super().__init__(context, id)
591
592         rs = [BoundExpression(r) for r in ratios.split(',')]
593         self._rs = BoundExpression.normalise(*rs)
594
595         for _ in range(len(self._rs)):
596             cell = Layout.Cell(self._context)
597             super().add(cell)
598
599         self._adjust_to_fit()
600
601     def _adjust_to_fit(self):
602         pass
603
604     def add(self, child):
605         raise RuntimeError("invalid operation")
606     
607     def set_cell(self, i, obj):
608         if i > len(self._children):
609             raise ValueError(f"cell #{i} out of bound")
610         
611         self._children[i].set_obj(obj)
612
613
614 class FlexLinearLayout(Layout):
615     LANDSCAPE = 0
616     PORTRAIT = 1
617     def __init__(self, context, id, ratios):
618         self.__horizontal = False
619
620         super().__init__(context, id, ratios)
621
622     def orientation(self, orient):
623         self.__horizontal = orient == FlexLinearLayout.LANDSCAPE
624     
625     def on_layout(self):
626         self.__apply_ratio()
627         super().on_layout()
628     
629     def _adjust_to_fit(self):
630         sum_abs = BoundExpression()
631         i = 0
632         for r in self._rs:
633             if r.absolute():
634                 sum_abs += r
635             else:
636                 i += 1
637
638         sum_abs /= i
639         for i, r in enumerate(self._rs):
640             if not r.absolute():
641                 self._rs[i] -= sum_abs
642
643     def __apply_ratio(self):
644         if self.__horizontal:
645             self.__adjust_horizontal()
646         else:
647             self.__adjust_vertical()
648         
649     def __adjust_horizontal(self):
650         acc = BoundExpression()
651         for r, cell in zip(self._rs, self.children()):
652             cell._dyn_size.dyn_y().set_pair(1, 0)
653             cell._dyn_size.dyn_x().set(r)
654
655             cell.set_alignment(Alignment.LEFT)
656             cell._local_pos.dyn_y().set_pair(0, 0)
657             cell._local_pos.dyn_x().set(acc)
658
659             acc += r
660
661     def __adjust_vertical(self):
662         acc = BoundExpression()
663         for r, cell in zip(self._rs, self.children()):
664             cell._dyn_size.dyn_x().set_pair(1, 0)
665             cell._dyn_size.dyn_y().set(r)
666
667             cell.set_alignment(Alignment.TOP | Alignment.CENTER)
668             cell._local_pos.dyn_x().set_pair(0, 0)
669             cell._local_pos.dyn_y().set(acc)
670
671             acc += r
672
673
674 class TuiPanel(TuiContainerObject):
675     def __init__(self, context, id):
676         super().__init__(context, id)
677
678         self.__use_border = False
679         self.__use_shadow = False
680         self.__shadow_param = (0, 0)
681
682         self.__swin = TuiStackWindow(self)
683         self.__shad = TuiStackWindow(self)
684
685         self.__swin.set_background(ColorScope.PANEL)
686         self.__shad.set_background(ColorScope.SHADOW)
687
688     def canvas(self):
689         return (self, self.__swin.window())
690
691     def drop_shadow(self, off_y, off_x):
692         self.__shadow_param = (off_y, off_x)
693         self.__use_shadow = not (off_y == off_x and off_y == 0)
694
695     def border(self, _b):
696         self.__use_border = _b
697
698     def bkgd_override(self, scope):
699         self.__swin.set_background(scope)
700
701     def on_layout(self):
702         super().on_layout()
703
704         self.__swin.hide()
705
706         h, w = self._size.y(), self._size.x()
707         y, x = self._pos.y(), self._pos.x()
708         self.__swin.set_geometric(h, w, y, x)
709         
710         if self.__use_shadow:
711             sy, sx = self.__shadow_param
712             self.__shad.set_geometric(h, w, y + sy, x + sx)
713
714     def on_destory(self):
715         super().on_destory()
716         self.__swin.hide()
717         self.__shad.hide()
718
719     def on_draw(self):
720         win = self.__swin.window()
721         win.erase()
722
723         if self.__use_border:
724             win.border()
725         
726         if self.__use_shadow:
727             self.__shad.show()
728         else:
729             self.__shad.hide()
730
731         self.__swin.show()
732         self.__swin.send_front()
733
734         super().on_draw()
735
736         win.touchwin()
737
738 class TuiLabel(TuiWidget):
739     def __init__(self, context, id):
740         super().__init__(context, id)
741         self._text = "TuiLabel"
742         self._wrapped = []
743
744         self.__auto_fit = True
745         self.__trunc = False
746         self.__dopad = False
747         self.__highlight = False
748         self.__color_scope = -1
749
750     def __try_fit_text(self, txt):
751         if self.__auto_fit:
752             self._dyn_size.dyn_x().set_pair(0, len(txt))
753             self._dyn_size.dyn_y().set_pair(0, 1)
754
755     def __pad_text(self):
756         for i, t in enumerate(self._wrapped):
757             self._wrapped[i] = str.rjust(t, self._size.x())
758     
759     def set_text(self, text):
760         self._text = text
761         self.__try_fit_text(text)
762
763     def override_color(self, color = -1):
764         self.__color_scope = color
765
766     def auto_fit(self, _b):
767         self.__auto_fit = _b
768
769     def truncate(self, _b):
770         self.__trunc = _b
771
772     def hightlight(self, _b):
773         self.__highlight = _b
774
775     def pad_around(self, _b):
776         self.__dopad = _b
777
778     def on_layout(self):
779         txt = self._text
780         if self.__dopad:
781             txt = f" {txt} "
782             self.__try_fit_text(txt)
783         
784         super().on_layout()
785
786         if len(txt) <= self._size.x():
787             self._wrapped = [txt]
788             self.__pad_text()
789             return
790
791         if not self.__trunc:
792             txt = txt[:self._size.x() - 1]
793             self._wrapped = [txt]
794             self.__pad_text()
795             return
796
797         self._wrapped = textwrap.wrap(txt, self._size.x())
798         self.__pad_text()
799
800     def on_draw(self):
801         _, win = self.canvas()
802         y, x = self._pos.yx()
803         
804         if self.__color_scope != -1:
805             color = curses.color_pair(self.__color_scope)
806         elif self.__highlight:
807             color = curses.color_pair(ColorScope.TEXT_HI)
808         else:
809             color = curses.color_pair(ColorScope.TEXT)
810
811         for i, t in enumerate(self._wrapped):
812             addstr_safe(self, win, y + i, x, t, color)
813
814
815 class TuiTextBlock(TuiWidget):
816     def __init__(self, context, id):
817         super().__init__(context, id)
818         self.__lines = []
819         self.__wrapped = []
820         self.__fit_to_height = False
821     
822     def set_text(self, text):
823         text = textwrap.dedent(text)
824         self.__lines = text.split('\n')
825         if self.__fit_to_height:
826             self._dyn_size.dyn_y().set_pair(0, 0)
827
828     def height_auto_fit(self, yes):
829         self.__fit_to_height = yes
830
831     def on_layout(self):
832         super().on_layout()
833
834         self.__wrapped.clear()
835         for t in self.__lines:
836             if not t:
837                 self.__wrapped.append(t)
838                 continue
839             wrap = textwrap.wrap(t, self._size.x())
840             self.__wrapped += wrap
841
842         if self._dyn_size.dyn_y().nullity():
843             h = len(self.__wrapped)
844             self._dyn_size.dyn_y().set_pair(0, h)
845
846             # redo layouting
847             super().on_layout()
848
849     def on_draw(self):
850         _, win = self.canvas()
851         y, x = self._pos.yx()
852         
853         color = curses.color_pair(ColorScope.TEXT)
854         for i, t in enumerate(self.__wrapped):
855             addstr_safe(self, win, y + i, x, t, color)
856
857
858 class TuiTextBox(TuiWidget):
859     def __init__(self, context, id):
860         super().__init__(context, id)
861         self.__box = TuiStackWindow(self)
862         self.__box.set_background(ColorScope.PANEL)
863         self.__textb = textpad.Textbox(self.__box.window(), True)
864         self.__textb.stripspaces = True
865         self.__str = ""
866         self.__scheduled_edit = False
867
868         self._context.focus_group().register(self, 0)
869
870     def __validate(self, x):
871         if x == 10 or x == 9:
872             return 7
873         return x
874     
875     def on_layout(self):
876         super().on_layout()
877
878         co, _ = self.canvas()
879         h, w = self._size.yx()
880         y, x = self._pos.yx()
881         cy, cx = co._pos.yx()
882         y, x = y + cy, x + cx
883
884         self.__box.hide()
885         self.__box.set_geometric(1, w - 1, y + h // 2, x + 1)
886
887     def on_draw(self):
888         self.__box.show()
889         self.__box.send_front()
890         
891         _, cwin = self.canvas()
892
893         h, w = self._size.yx()
894         y, x = self._pos.yx()
895         textpad.rectangle(cwin, y, x, y + h - 1, x+w)
896
897         win = self.__box.window()
898         win.touchwin()
899
900     def __edit(self):
901         self.__str = self.__textb.edit(lambda x: self.__validate(x))
902         self.session().schedule(EventType.E_CHFOCUS)
903         self.__scheduled_edit = False
904
905     def get_text(self):
906         return self.__str
907     
908     def on_focused(self):
909         self.__box.set_background(ColorScope.BOX)
910         if not self.__scheduled_edit:
911             # edit will block, defer to next update cycle
912             self.session().schedule_task(self.__edit)
913             self.__scheduled_edit = True
914
915     def on_focus_lost(self):
916         self.__box.set_background(ColorScope.PANEL)
917         
918
919 class SimpleList(TuiWidget):
920     class Item:
921         def __init__(self) -> None:
922             pass
923         def get_text(self):
924             return "list_item"
925         def on_selected(self):
926             pass
927         def on_key_pressed(self, key):
928             pass
929
930     def __init__(self, context, id):
931         super().__init__(context, id)
932         self.__items = []
933         self.__selected = 0
934         self.__on_sel_confirm_cb = None
935         self.__on_sel_change_cb = None
936
937         self._context.focus_group().register(self)
938
939     def set_onselected_cb(self, cb):
940         self.__on_sel_confirm_cb = cb
941
942     def set_onselection_change_cb(self, cb):
943         self.__on_sel_change_cb = cb
944
945     def count(self):
946         return len(self.__items)
947     
948     def index(self):
949         return self.__selected
950
951     def add_item(self, item):
952         self.__items.append(item)
953
954     def clear(self):
955         self.__items.clear()
956
957     def on_layout(self):
958         super().on_layout()
959         self.__selected = min(self.__selected, len(self.__items))
960         self._size.resetY(len(self.__items) + 1)
961
962     def on_draw(self):
963         _, win = self.canvas()
964         w = self._size.x()
965
966         for i, item in enumerate(self.__items):
967             color = curses.color_pair(ColorScope.TEXT)
968             if i == self.__selected:
969                 if self._focused:
970                     color = curses.color_pair(ColorScope.SELECT)
971                 else:
972                     color = curses.color_pair(ColorScope.BOX)
973             
974             txt = str.ljust(item.get_text(), w)
975             txt = txt[:w]
976             addstr_safe(self, win, i, 0, txt, color)
977
978     def on_event(self, ev_type, ev_arg):
979         if not EventType.key_press(ev_type):
980             return
981         
982         if len(self.__items) == 0:
983             return
984
985         sel = self.__items[self.__selected]
986
987         if ev_arg == 10:
988             sel.on_selected()
989             
990             if self.__on_sel_confirm_cb:
991                 self.__on_sel_confirm_cb(self, self.__selected, sel)
992             return
993         
994         sel.on_key_pressed(ev_arg)
995         
996         if (ev_arg != curses.KEY_DOWN and 
997             ev_arg != curses.KEY_UP):
998             return
999
1000         prev = self.__selected
1001         if ev_arg == curses.KEY_DOWN:
1002             self.__selected += 1
1003         else:
1004             self.__selected -= 1
1005
1006         self.__selected = max(self.__selected, 0)
1007         self.__selected = self.__selected % len(self.__items)
1008         
1009         if self.__on_sel_change_cb:
1010             self.__on_sel_change_cb(self, prev, self.__selected)
1011
1012
1013 class TuiButton(TuiLabel):
1014     def __init__(self, context, id):
1015         super().__init__(context, id)
1016         self.__onclick = None
1017
1018         context.focus_group().register(self)
1019
1020     def set_text(self, text):
1021         return super().set_text(f"<{text}>")
1022
1023     def set_click_callback(self, cb):
1024         self.__onclick = cb
1025
1026     def hightlight(self, _b):
1027         raise NotImplemented()
1028     
1029     def on_draw(self):
1030         _, win = self.canvas()
1031         y, x = self._pos.yx()
1032         
1033         if self._focused:
1034             color = curses.color_pair(ColorScope.SELECT)
1035         else:
1036             color = curses.color_pair(ColorScope.TEXT)
1037
1038         addstr_safe(self, win, y, x, self._wrapped[0], color)
1039     
1040     def on_event(self, ev_type, ev_arg):
1041         if not EventType.focused_only(ev_type):
1042             return
1043         if not EventType.key_press(ev_type):
1044             return
1045         
1046         if ev_arg == ord('\n') and self.__onclick:
1047             self.__onclick(self)
1048
1049
1050 class TuiSession:
1051     def __init__(self) -> None:
1052         self.stdsc = curses.initscr()
1053         curses.start_color()
1054
1055         curses.noecho()
1056         curses.cbreak()
1057
1058         self.__context_stack = []
1059         self.__sched_events = []
1060
1061         ws = self.window_size()
1062         self.__win = curses.newwin(*ws)
1063         self.__winbg = curses.newwin(*ws)
1064         self.__panbg = cpanel.new_panel(self.__winbg)
1065
1066         self.__winbg.bkgd(' ', curses.color_pair(ColorScope.WIN))
1067
1068         self.__win.timeout(50)
1069         self.__win.keypad(True)
1070
1071     def window_size(self):
1072         return self.stdsc.getmaxyx()
1073
1074     def set_color(self, scope, fg, bg):
1075         curses.init_pair(scope, int(fg), int(bg))
1076
1077     def schedule_redraw(self):
1078         self.schedule(EventType.E_REDRAW)
1079
1080     def schedule_task(self, task):
1081         self.schedule(EventType.E_REDRAW)
1082         self.schedule(EventType.E_TASK, task)
1083
1084     def schedule(self, event, arg = None):
1085         if len(self.__sched_events) > 0:
1086             if self.__sched_events[-1] == event:
1087                 return
1088     
1089         self.__sched_events.append((event, arg))
1090
1091     def push_context(self, tuictx):
1092         tuictx.prepare()
1093         self.__context_stack.append(tuictx)
1094         self.schedule(EventType.E_REDRAW)
1095
1096         curses.curs_set(self.active().curser_mode())
1097
1098     def pop_context(self):
1099         if len(self.__context_stack) == 1:
1100             return
1101         
1102         ctx = self.__context_stack.pop()
1103         ctx.on_destory()
1104         self.schedule(EventType.E_REDRAW)
1105
1106         curses.curs_set(self.active().curser_mode())
1107
1108     def active(self):
1109         return self.__context_stack[-1]
1110     
1111     def event_loop(self):
1112         if len(self.__context_stack) == 0:
1113             raise RuntimeError("no tui context to display")
1114         
1115         while True:
1116             key = self.__win.getch()
1117             if key != -1:
1118                 self.schedule(EventType.E_KEY, key)
1119             
1120             if len(self.__sched_events) == 0:
1121                 continue
1122
1123             evt, arg = self.__sched_events.pop(0)
1124             if evt == EventType.E_REDRAW:
1125                 self.__redraw()
1126             elif evt == EventType.E_QUIT:
1127                 self.__notify_quit()
1128                 break
1129
1130             self.active().dispatch_event(evt, arg)
1131         
1132     def __notify_quit(self):
1133         while len(self.__context_stack) == 0:
1134             ctx = self.__context_stack.pop()
1135             ctx.dispatch_event(EventType.E_QUIT, None)
1136
1137     def __redraw(self):
1138         self.stdsc.erase()
1139         self.__win.erase()
1140         
1141         self.active().redraw(self.__win)
1142
1143         self.__panbg.bottom()
1144         self.__win.touchwin()
1145         self.__winbg.touchwin()
1146
1147         self.__win.refresh()
1148         self.__winbg.refresh()
1149
1150         cpanel.update_panels()
1151         curses.doupdate()
1152
1153 class TuiFocusGroup:
1154     def __init__(self) -> None:
1155         self.__grp = []
1156         self.__id = 0
1157         self.__sel = 0
1158         self.__focused = None
1159     
1160     def register(self, tui_obj, pos=-1):
1161         if pos == -1:
1162             self.__grp.append((self.__id, tui_obj))
1163         else:
1164             self.__grp.insert(pos, (self.__id, tui_obj))
1165         self.__id += 1
1166         return self.__id - 1
1167
1168     def navigate_focus(self, dir = 1):
1169         self.__sel = (self.__sel + dir) % len(self.__grp)
1170         f = None if not len(self.__grp) else self.__grp[self.__sel][1]
1171         if f and f != self.__focused:
1172             if self.__focused:
1173                 self.__focused.on_focus_lost()
1174             f.on_focused()
1175         self.__focused = f
1176
1177     def focused(self):
1178         return self.__focused
1179
1180 class TuiContext(TuiObject):
1181     def __init__(self, session: TuiSession):
1182         super().__init__(self, "context")
1183         self.__root = None
1184         self.__sobj = None
1185         self.__session = session
1186
1187         self.__win = None
1188
1189         y, x = self.__session.window_size()
1190         self._size.reset(x, y)
1191         self.set_parent(None)
1192
1193         self.__focus_group = TuiFocusGroup()
1194         self.__curser_mode = 0
1195
1196     def set_curser_mode(self, mode):
1197         self.__curser_mode = mode
1198
1199     def curser_mode(self):
1200         return self.__curser_mode
1201
1202     def set_root(self, root):
1203         self.__root = root
1204         self.__root.set_parent(self)
1205     
1206     def set_state(self, obj):
1207         self.__sobj = obj
1208
1209     def state(self):
1210         return self.__sobj
1211
1212     def prepare(self):
1213         self.__root.on_create()
1214
1215     def on_destory(self):
1216         self.__root.on_destory()
1217
1218     def canvas(self):
1219         return (self, self.__win)
1220     
1221     def session(self):
1222         return self.__session
1223     
1224     def dispatch_event(self, evt, arg):
1225         if evt == EventType.E_REDRAW:
1226             self.__focus_group.navigate_focus(0)
1227         elif evt == EventType.E_CHFOCUS:
1228             self.__focus_group.navigate_focus(1)
1229             self.__session.schedule(EventType.E_REDRAW)
1230             return
1231         elif evt == EventType.E_TASK:
1232             arg()
1233         elif evt == EventType.E_QUIT:
1234             self.__root.on_quit()
1235         elif evt == EventType.E_KEY:
1236             self._handle_key_event(arg)
1237         else:
1238             self.__root.on_event(evt, arg)
1239
1240         focused = self.__focus_group.focused()
1241         if focused:
1242             focused.on_event(evt | EventType.E_M_FOCUS, arg)
1243
1244     def redraw(self, win):
1245         self.__win = win
1246         self.on_layout()
1247         self.on_draw()
1248
1249     def on_layout(self):
1250         self.__root.on_layout()
1251
1252     def on_draw(self):
1253         self.__root.on_draw()
1254
1255     def focus_group(self):
1256         return self.__focus_group
1257     
1258     def _handle_key_event(self, key):
1259         if key == ord('\t') or key == curses.KEY_RIGHT:
1260             self.__focus_group.navigate_focus()
1261         elif key == curses.KEY_LEFT:
1262             self.__focus_group.navigate_focus(-1)
1263         else:
1264             self.__root.on_event(EventType.E_KEY, key)
1265         
1266         if self.__focus_group.focused():
1267             self.__session.schedule(EventType.E_REDRAW)