optimize the menuconfig redrawing
[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_panel.top()
542         self.__content.on_draw()
543
544         wminy, wminx = self._pos.yx()
545         wmaxy, wmaxx = self._size.yx()
546         wmaxy, wmaxx = wmaxy + wminy, wmaxx + wminx
547
548         self.__pad.refresh(*self.__spos.yx(), 
549                             wminy, wminx, wmaxy - 1, wmaxx - 1)
550
551
552 class Layout(TuiContainerObject):
553     class Cell(TuiObject):
554         def __init__(self, context):
555             super().__init__(context, "cell")
556             self.__obj = None
557
558         def set_obj(self, obj):
559             self.__obj = obj
560             self.__obj.set_parent(self)
561
562         def on_create(self):
563             if self.__obj:
564                 self.__obj.on_create()
565
566         def on_destory(self):
567             if self.__obj:
568                 self.__obj.on_destory()
569
570         def on_quit(self):
571             if self.__obj:
572                 self.__obj.on_quit()
573
574         def on_layout(self):
575             super().on_layout()
576             if self.__obj:
577                 self.__obj.do_layout()
578
579         def on_draw(self):
580             if self.__obj:
581                 self.__obj.do_draw()
582
583         def on_event(self, ev_type, ev_arg):
584             if self.__obj:
585                 self.__obj.on_event(ev_type, ev_arg)
586     
587     def __init__(self, context, id, ratios):
588         super().__init__(context, id)
589
590         rs = [BoundExpression(r) for r in ratios.split(',')]
591         self._rs = BoundExpression.normalise(*rs)
592
593         for _ in range(len(self._rs)):
594             cell = Layout.Cell(self._context)
595             super().add(cell)
596
597         self._adjust_to_fit()
598
599     def _adjust_to_fit(self):
600         pass
601
602     def add(self, child):
603         raise RuntimeError("invalid operation")
604     
605     def set_cell(self, i, obj):
606         if i > len(self._children):
607             raise ValueError(f"cell #{i} out of bound")
608         
609         self._children[i].set_obj(obj)
610
611
612 class FlexLinearLayout(Layout):
613     LANDSCAPE = 0
614     PORTRAIT = 1
615     def __init__(self, context, id, ratios):
616         self.__horizontal = False
617
618         super().__init__(context, id, ratios)
619
620     def orientation(self, orient):
621         self.__horizontal = orient == FlexLinearLayout.LANDSCAPE
622     
623     def on_layout(self):
624         self.__apply_ratio()
625         super().on_layout()
626     
627     def _adjust_to_fit(self):
628         sum_abs = BoundExpression()
629         i = 0
630         for r in self._rs:
631             if r.absolute():
632                 sum_abs += r
633             else:
634                 i += 1
635
636         sum_abs /= i
637         for i, r in enumerate(self._rs):
638             if not r.absolute():
639                 self._rs[i] -= sum_abs
640
641     def __apply_ratio(self):
642         if self.__horizontal:
643             self.__adjust_horizontal()
644         else:
645             self.__adjust_vertical()
646         
647     def __adjust_horizontal(self):
648         acc = BoundExpression()
649         for r, cell in zip(self._rs, self.children()):
650             cell._dyn_size.dyn_y().set_pair(1, 0)
651             cell._dyn_size.dyn_x().set(r)
652
653             cell.set_alignment(Alignment.LEFT)
654             cell._local_pos.dyn_y().set_pair(0, 0)
655             cell._local_pos.dyn_x().set(acc)
656
657             acc += r
658
659     def __adjust_vertical(self):
660         acc = BoundExpression()
661         for r, cell in zip(self._rs, self.children()):
662             cell._dyn_size.dyn_x().set_pair(1, 0)
663             cell._dyn_size.dyn_y().set(r)
664
665             cell.set_alignment(Alignment.TOP | Alignment.CENTER)
666             cell._local_pos.dyn_x().set_pair(0, 0)
667             cell._local_pos.dyn_y().set(acc)
668
669             acc += r
670
671
672 class TuiPanel(TuiContainerObject):
673     def __init__(self, context, id):
674         super().__init__(context, id)
675
676         self.__use_border = False
677         self.__use_shadow = False
678         self.__shadow_param = (0, 0)
679
680         self.__swin = TuiStackWindow(self)
681         self.__shad = TuiStackWindow(self)
682
683         self.__swin.set_background(ColorScope.PANEL)
684         self.__shad.set_background(ColorScope.SHADOW)
685
686     def canvas(self):
687         return (self, self.__swin.window())
688
689     def drop_shadow(self, off_y, off_x):
690         self.__shadow_param = (off_y, off_x)
691         self.__use_shadow = not (off_y == off_x and off_y == 0)
692
693     def border(self, _b):
694         self.__use_border = _b
695
696     def bkgd_override(self, scope):
697         self.__swin.set_background(scope)
698
699     def on_layout(self):
700         super().on_layout()
701
702         self.__swin.hide()
703
704         h, w = self._size.y(), self._size.x()
705         y, x = self._pos.y(), self._pos.x()
706         self.__swin.set_geometric(h, w, y, x)
707         
708         if self.__use_shadow:
709             sy, sx = self.__shadow_param
710             self.__shad.set_geometric(h, w, y + sy, x + sx)
711
712     def on_destory(self):
713         super().on_destory()
714         self.__swin.hide()
715         self.__shad.hide()
716
717     def on_draw(self):
718         win = self.__swin.window()
719         win.noutrefresh()
720
721         if self.__use_border:
722             win.border()
723         
724         if self.__use_shadow:
725             self.__shad.show()
726         else:
727             self.__shad.hide()
728
729         self.__swin.show()
730         self.__swin.send_front()
731
732         super().on_draw()
733
734 class TuiLabel(TuiWidget):
735     def __init__(self, context, id):
736         super().__init__(context, id)
737         self._text = "TuiLabel"
738         self._wrapped = []
739
740         self.__auto_fit = True
741         self.__trunc = False
742         self.__dopad = False
743         self.__highlight = False
744         self.__color_scope = -1
745
746     def __try_fit_text(self, txt):
747         if self.__auto_fit:
748             self._dyn_size.dyn_x().set_pair(0, len(txt))
749             self._dyn_size.dyn_y().set_pair(0, 1)
750
751     def __pad_text(self):
752         for i, t in enumerate(self._wrapped):
753             self._wrapped[i] = str.rjust(t, self._size.x())
754     
755     def set_text(self, text):
756         self._text = text
757         self.__try_fit_text(text)
758
759     def override_color(self, color = -1):
760         self.__color_scope = color
761
762     def auto_fit(self, _b):
763         self.__auto_fit = _b
764
765     def truncate(self, _b):
766         self.__trunc = _b
767
768     def hightlight(self, _b):
769         self.__highlight = _b
770
771     def pad_around(self, _b):
772         self.__dopad = _b
773
774     def on_layout(self):
775         txt = self._text
776         if self.__dopad:
777             txt = f" {txt} "
778             self.__try_fit_text(txt)
779         
780         super().on_layout()
781
782         if len(txt) <= self._size.x():
783             self._wrapped = [txt]
784             self.__pad_text()
785             return
786
787         if not self.__trunc:
788             txt = txt[:self._size.x() - 1]
789             self._wrapped = [txt]
790             self.__pad_text()
791             return
792
793         self._wrapped = textwrap.wrap(txt, self._size.x())
794         self.__pad_text()
795
796     def on_draw(self):
797         _, win = self.canvas()
798         y, x = self._pos.yx()
799         
800         if self.__color_scope != -1:
801             color = curses.color_pair(self.__color_scope)
802         elif self.__highlight:
803             color = curses.color_pair(ColorScope.TEXT_HI)
804         else:
805             color = curses.color_pair(ColorScope.TEXT)
806
807         for i, t in enumerate(self._wrapped):
808             addstr_safe(self, win, y + i, x, t, color)
809
810
811 class TuiTextBlock(TuiWidget):
812     def __init__(self, context, id):
813         super().__init__(context, id)
814         self.__lines = []
815         self.__wrapped = []
816         self.__fit_to_height = False
817     
818     def set_text(self, text):
819         text = textwrap.dedent(text)
820         self.__lines = text.split('\n')
821         if self.__fit_to_height:
822             self._dyn_size.dyn_y().set_pair(0, 0)
823
824     def height_auto_fit(self, yes):
825         self.__fit_to_height = yes
826
827     def on_layout(self):
828         super().on_layout()
829
830         self.__wrapped.clear()
831         for t in self.__lines:
832             if not t:
833                 self.__wrapped.append(t)
834                 continue
835             wrap = textwrap.wrap(t, self._size.x())
836             self.__wrapped += wrap
837
838         if self._dyn_size.dyn_y().nullity():
839             h = len(self.__wrapped)
840             self._dyn_size.dyn_y().set_pair(0, h)
841
842             # redo layouting
843             super().on_layout()
844
845     def on_draw(self):
846         _, win = self.canvas()
847         y, x = self._pos.yx()
848         
849         color = curses.color_pair(ColorScope.TEXT)
850         for i, t in enumerate(self.__wrapped):
851             addstr_safe(self, win, y + i, x, t, color)
852
853
854 class TuiTextBox(TuiWidget):
855     def __init__(self, context, id):
856         super().__init__(context, id)
857         self.__box = TuiStackWindow(self)
858         self.__box.set_background(ColorScope.PANEL)
859         self.__textb = textpad.Textbox(self.__box.window(), True)
860         self.__textb.stripspaces = True
861         self.__str = ""
862         self.__scheduled_edit = False
863
864         self._context.focus_group().register(self, 0)
865
866     def __validate(self, x):
867         if x == 10 or x == 9:
868             return 7
869         return x
870     
871     def on_layout(self):
872         super().on_layout()
873
874         co, _ = self.canvas()
875         h, w = self._size.yx()
876         y, x = self._pos.yx()
877         cy, cx = co._pos.yx()
878         y, x = y + cy, x + cx
879
880         self.__box.hide()
881         self.__box.set_geometric(1, w - 1, y + h // 2, x + 1)
882
883     def on_draw(self):
884         self.__box.show()
885         self.__box.send_front()
886         
887         _, cwin = self.canvas()
888
889         h, w = self._size.yx()
890         y, x = self._pos.yx()
891         textpad.rectangle(cwin, y, x, y + h - 1, x+w)
892
893         win = self.__box.window()
894         win.touchwin()
895
896     def __edit(self):
897         self.__str = self.__textb.edit(lambda x: self.__validate(x))
898         self.session().schedule(EventType.E_CHFOCUS)
899         self.__scheduled_edit = False
900
901     def get_text(self):
902         return self.__str
903     
904     def on_focused(self):
905         self.__box.set_background(ColorScope.BOX)
906         if not self.__scheduled_edit:
907             # edit will block, defer to next update cycle
908             self.session().schedule_task(self.__edit)
909             self.__scheduled_edit = True
910
911     def on_focus_lost(self):
912         self.__box.set_background(ColorScope.PANEL)
913         
914
915 class SimpleList(TuiWidget):
916     class Item:
917         def __init__(self) -> None:
918             pass
919         def get_text(self):
920             return "list_item"
921         def on_selected(self):
922             pass
923         def on_key_pressed(self, key):
924             pass
925
926     def __init__(self, context, id):
927         super().__init__(context, id)
928         self.__items = []
929         self.__selected = 0
930         self.__on_sel_confirm_cb = None
931         self.__on_sel_change_cb = None
932
933         self._context.focus_group().register(self)
934
935     def set_onselected_cb(self, cb):
936         self.__on_sel_confirm_cb = cb
937
938     def set_onselection_change_cb(self, cb):
939         self.__on_sel_change_cb = cb
940
941     def count(self):
942         return len(self.__items)
943     
944     def index(self):
945         return self.__selected
946
947     def add_item(self, item):
948         self.__items.append(item)
949
950     def clear(self):
951         self.__items.clear()
952
953     def on_layout(self):
954         super().on_layout()
955         self.__selected = min(self.__selected, len(self.__items))
956         self._size.resetY(len(self.__items) + 1)
957
958     def on_draw(self):
959         _, win = self.canvas()
960         w = self._size.x()
961
962         for i, item in enumerate(self.__items):
963             color = curses.color_pair(ColorScope.TEXT)
964             if i == self.__selected:
965                 if self._focused:
966                     color = curses.color_pair(ColorScope.SELECT)
967                 else:
968                     color = curses.color_pair(ColorScope.BOX)
969             
970             txt = str.ljust(item.get_text(), w)
971             txt = txt[:w]
972             addstr_safe(self, win, i, 0, txt, color)
973
974     def on_event(self, ev_type, ev_arg):
975         if not EventType.key_press(ev_type):
976             return
977         
978         if len(self.__items) == 0:
979             return
980
981         sel = self.__items[self.__selected]
982
983         if ev_arg == 10:
984             sel.on_selected()
985             
986             if self.__on_sel_confirm_cb:
987                 self.__on_sel_confirm_cb(self, self.__selected, sel)
988             return
989         
990         sel.on_key_pressed(ev_arg)
991         
992         if (ev_arg != curses.KEY_DOWN and 
993             ev_arg != curses.KEY_UP):
994             return
995
996         prev = self.__selected
997         if ev_arg == curses.KEY_DOWN:
998             self.__selected += 1
999         else:
1000             self.__selected -= 1
1001
1002         self.__selected = max(self.__selected, 0)
1003         self.__selected = self.__selected % len(self.__items)
1004         
1005         if self.__on_sel_change_cb:
1006             self.__on_sel_change_cb(self, prev, self.__selected)
1007
1008
1009 class TuiButton(TuiLabel):
1010     def __init__(self, context, id):
1011         super().__init__(context, id)
1012         self.__onclick = None
1013
1014         context.focus_group().register(self)
1015
1016     def set_text(self, text):
1017         return super().set_text(f"<{text}>")
1018
1019     def set_click_callback(self, cb):
1020         self.__onclick = cb
1021
1022     def hightlight(self, _b):
1023         raise NotImplemented()
1024     
1025     def on_draw(self):
1026         _, win = self.canvas()
1027         y, x = self._pos.yx()
1028         
1029         if self._focused:
1030             color = curses.color_pair(ColorScope.SELECT)
1031         else:
1032             color = curses.color_pair(ColorScope.TEXT)
1033
1034         addstr_safe(self, win, y, x, self._wrapped[0], color)
1035     
1036     def on_event(self, ev_type, ev_arg):
1037         if not EventType.focused_only(ev_type):
1038             return
1039         if not EventType.key_press(ev_type):
1040             return
1041         
1042         if ev_arg == ord('\n') and self.__onclick:
1043             self.__onclick(self)
1044
1045
1046 class TuiSession:
1047     def __init__(self) -> None:
1048         self.stdsc = curses.initscr()
1049         curses.start_color()
1050
1051         curses.noecho()
1052         curses.cbreak()
1053
1054         self.__context_stack = []
1055         self.__sched_events = []
1056
1057         ws = self.window_size()
1058         self.__win = curses.newwin(*ws)
1059         self.__winbg = curses.newwin(*ws)
1060         self.__panbg = cpanel.new_panel(self.__winbg)
1061
1062         self.__winbg.bkgd(' ', curses.color_pair(ColorScope.WIN))
1063
1064         self.__win.timeout(50)
1065         self.__win.keypad(True)
1066
1067     def window_size(self):
1068         return self.stdsc.getmaxyx()
1069
1070     def set_color(self, scope, fg, bg):
1071         curses.init_pair(scope, int(fg), int(bg))
1072
1073     def schedule_redraw(self):
1074         self.schedule(EventType.E_REDRAW)
1075
1076     def schedule_task(self, task):
1077         self.schedule(EventType.E_REDRAW)
1078         self.schedule(EventType.E_TASK, task)
1079
1080     def schedule(self, event, arg = None):
1081         if len(self.__sched_events) > 0:
1082             if self.__sched_events[-1] == event:
1083                 return
1084     
1085         self.__sched_events.append((event, arg))
1086
1087     def push_context(self, tuictx):
1088         tuictx.prepare()
1089         self.__context_stack.append(tuictx)
1090         self.schedule(EventType.E_REDRAW)
1091
1092         curses.curs_set(self.active().curser_mode())
1093
1094     def pop_context(self):
1095         if len(self.__context_stack) == 1:
1096             return
1097         
1098         ctx = self.__context_stack.pop()
1099         ctx.on_destory()
1100         self.schedule(EventType.E_REDRAW)
1101
1102         curses.curs_set(self.active().curser_mode())
1103
1104     def active(self):
1105         return self.__context_stack[-1]
1106     
1107     def event_loop(self):
1108         if len(self.__context_stack) == 0:
1109             raise RuntimeError("no tui context to display")
1110         
1111         while True:
1112             key = self.__win.getch()
1113             if key != -1:
1114                 self.schedule(EventType.E_KEY, key)
1115             
1116             if len(self.__sched_events) == 0:
1117                 continue
1118
1119             evt, arg = self.__sched_events.pop(0)
1120             if evt == EventType.E_REDRAW:
1121                 self.__redraw()
1122             elif evt == EventType.E_QUIT:
1123                 self.__notify_quit()
1124                 break
1125
1126             self.active().dispatch_event(evt, arg)
1127         
1128     def __notify_quit(self):
1129         while len(self.__context_stack) == 0:
1130             ctx = self.__context_stack.pop()
1131             ctx.dispatch_event(EventType.E_QUIT, None)
1132
1133     def __redraw(self):
1134         self.__win.noutrefresh()
1135         
1136         self.active().redraw(self.__win)
1137
1138         self.__panbg.bottom()
1139
1140         cpanel.update_panels()
1141         curses.doupdate()
1142
1143 class TuiFocusGroup:
1144     def __init__(self) -> None:
1145         self.__grp = []
1146         self.__id = 0
1147         self.__sel = 0
1148         self.__focused = None
1149     
1150     def register(self, tui_obj, pos=-1):
1151         if pos == -1:
1152             self.__grp.append((self.__id, tui_obj))
1153         else:
1154             self.__grp.insert(pos, (self.__id, tui_obj))
1155         self.__id += 1
1156         return self.__id - 1
1157
1158     def navigate_focus(self, dir = 1):
1159         self.__sel = (self.__sel + dir) % len(self.__grp)
1160         f = None if not len(self.__grp) else self.__grp[self.__sel][1]
1161         if f and f != self.__focused:
1162             if self.__focused:
1163                 self.__focused.on_focus_lost()
1164             f.on_focused()
1165         self.__focused = f
1166
1167     def focused(self):
1168         return self.__focused
1169
1170 class TuiContext(TuiObject):
1171     def __init__(self, session: TuiSession):
1172         super().__init__(self, "context")
1173         self.__root = None
1174         self.__sobj = None
1175         self.__session = session
1176
1177         self.__win = None
1178
1179         y, x = self.__session.window_size()
1180         self._size.reset(x, y)
1181         self.set_parent(None)
1182
1183         self.__focus_group = TuiFocusGroup()
1184         self.__curser_mode = 0
1185
1186     def set_curser_mode(self, mode):
1187         self.__curser_mode = mode
1188
1189     def curser_mode(self):
1190         return self.__curser_mode
1191
1192     def set_root(self, root):
1193         self.__root = root
1194         self.__root.set_parent(self)
1195     
1196     def set_state(self, obj):
1197         self.__sobj = obj
1198
1199     def state(self):
1200         return self.__sobj
1201
1202     def prepare(self):
1203         self.__root.on_create()
1204
1205     def on_destory(self):
1206         self.__root.on_destory()
1207
1208     def canvas(self):
1209         return (self, self.__win)
1210     
1211     def session(self):
1212         return self.__session
1213     
1214     def dispatch_event(self, evt, arg):
1215         if evt == EventType.E_REDRAW:
1216             self.__focus_group.navigate_focus(0)
1217         elif evt == EventType.E_CHFOCUS:
1218             self.__focus_group.navigate_focus(1)
1219             self.__session.schedule(EventType.E_REDRAW)
1220             return
1221         elif evt == EventType.E_TASK:
1222             arg()
1223         elif evt == EventType.E_QUIT:
1224             self.__root.on_quit()
1225         elif evt == EventType.E_KEY:
1226             self._handle_key_event(arg)
1227         else:
1228             self.__root.on_event(evt, arg)
1229
1230         focused = self.__focus_group.focused()
1231         if focused:
1232             focused.on_event(evt | EventType.E_M_FOCUS, arg)
1233
1234     def redraw(self, win):
1235         self.__win = win
1236         self.on_layout()
1237         self.on_draw()
1238
1239     def on_layout(self):
1240         self.__root.on_layout()
1241
1242     def on_draw(self):
1243         self.__root.on_draw()
1244
1245     def focus_group(self):
1246         return self.__focus_group
1247     
1248     def _handle_key_event(self, key):
1249         if key == ord('\t') or key == curses.KEY_RIGHT:
1250             self.__focus_group.navigate_focus()
1251         elif key == curses.KEY_LEFT:
1252             self.__focus_group.navigate_focus(-1)
1253         else:
1254             self.__root.on_event(EventType.E_KEY, key)
1255         
1256         if self.__focus_group.focused():
1257             self.__session.schedule(EventType.E_REDRAW)