interactui 0.1.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interactui/__init__.py +7 -0
- interactui/app.py +93 -0
- interactui/common.py +29 -0
- interactui/component.py +27 -0
- interactui/examples/get_key.py +23 -0
- interactui/key.py +155 -0
- interactui/layout.py +180 -0
- interactui/py.typed +0 -0
- interactui/samples/__init__.py +1 -0
- interactui/samples/form.py +89 -0
- interactui/text.py +218 -0
- interactui/view/__init__.py +23 -0
- interactui/view/action.py +42 -0
- interactui/view/base.py +220 -0
- interactui/view/layout.py +36 -0
- interactui/view/multi_select.py +88 -0
- interactui/view/select.py +73 -0
- interactui/view/style.py +161 -0
- interactui/view/text_area.py +49 -0
- interactui/view/typing.py +6 -0
- interactui-0.1.0rc1.dist-info/METADATA +12 -0
- interactui-0.1.0rc1.dist-info/RECORD +23 -0
- interactui-0.1.0rc1.dist-info/WHEEL +4 -0
interactui/__init__.py
ADDED
interactui/app.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from functools import partial
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
from interactui.common import Callback, init_colors
|
|
11
|
+
from interactui.component import Component
|
|
12
|
+
from interactui.key import Key, KeyBinding, KeyBindingMap
|
|
13
|
+
from interactui.layout import Layout
|
|
14
|
+
from interactui.view import Center, FloatingView, Style, View
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class App(Component):
|
|
18
|
+
_key_bindings: ClassVar[list[KeyBinding]] = [
|
|
19
|
+
KeyBinding(Key("d", Key.CTRL), "Exit app", lambda _self, _key, app: app.exit(0)), # pyright: ignore[reportUnknownLambdaType]
|
|
20
|
+
KeyBinding(Key("k", Key.CTRL), "Show Key Bindings", lambda _self, _key, app: app.show_key_bindings()), # pyright: ignore[reportUnknownLambdaType]
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
super().__init__()
|
|
25
|
+
|
|
26
|
+
self._layout: Layout | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def layout(self) -> Layout:
|
|
30
|
+
if self._layout is None:
|
|
31
|
+
raise RuntimeError("Tried accessing an App's layout before calling run()")
|
|
32
|
+
|
|
33
|
+
return self._layout
|
|
34
|
+
|
|
35
|
+
def send_key(self, key: Key) -> None:
|
|
36
|
+
if (focused_view := self.layout.focused) is None:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
focused_view.handle_key(key, self)
|
|
40
|
+
|
|
41
|
+
def show_key_bindings(self) -> None:
|
|
42
|
+
self.layout.append(
|
|
43
|
+
Center(
|
|
44
|
+
FloatingView(
|
|
45
|
+
title="Key Bindings",
|
|
46
|
+
content=f"""\
|
|
47
|
+
- Global
|
|
48
|
+
{self.key_bindings.as_str()}
|
|
49
|
+
|
|
50
|
+
- Local
|
|
51
|
+
{KeyBindingMap(*FloatingView._key_bindings).as_str()}""", # pyright: ignore[reportPrivateUsage]
|
|
52
|
+
style=Style(border="rounded"),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _run(
|
|
58
|
+
self,
|
|
59
|
+
stdscr: curses.window,
|
|
60
|
+
layout: Sequence[View],
|
|
61
|
+
*,
|
|
62
|
+
on_append_view: Callback | None,
|
|
63
|
+
on_remove_view: Callback | None,
|
|
64
|
+
) -> None:
|
|
65
|
+
curses.curs_set(0)
|
|
66
|
+
init_colors()
|
|
67
|
+
|
|
68
|
+
self._layout = Layout(
|
|
69
|
+
layout,
|
|
70
|
+
stdscr,
|
|
71
|
+
on_append=None if on_append_view is None else partial(on_append_view, self),
|
|
72
|
+
on_remove=None if on_remove_view is None else partial(on_remove_view, self),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
self.layout.render()
|
|
77
|
+
self.layout.execute_actions(self)
|
|
78
|
+
|
|
79
|
+
key = self.layout.get_key()
|
|
80
|
+
|
|
81
|
+
if not self.handle_key(key, self):
|
|
82
|
+
if (focused_view := self.layout.focused) is not None:
|
|
83
|
+
focused_view.handle_key(key, self)
|
|
84
|
+
|
|
85
|
+
def run(
|
|
86
|
+
self, layout: Sequence[View], *, on_append_view: Callback | None = None, on_remove_view: Callback | None = None
|
|
87
|
+
) -> None:
|
|
88
|
+
os.environ.setdefault("ESCDELAY", "25")
|
|
89
|
+
|
|
90
|
+
curses.wrapper(self._run, layout, on_append_view=on_append_view, on_remove_view=on_remove_view)
|
|
91
|
+
|
|
92
|
+
def exit(self, code: int) -> None:
|
|
93
|
+
sys.exit(code)
|
interactui/common.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
from typing import TYPE_CHECKING, Callable, Literal
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from interactui.app import App
|
|
6
|
+
from interactui.view import View
|
|
7
|
+
|
|
8
|
+
type Color = Literal["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
|
|
9
|
+
type Callback = Callable[[App, View], None]
|
|
10
|
+
type LayoutCallback = Callable[[View], None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def init_colors() -> None:
|
|
14
|
+
curses.use_default_colors()
|
|
15
|
+
|
|
16
|
+
for fg in ("BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE"):
|
|
17
|
+
fg_color = getattr(curses, "COLOR_" + fg)
|
|
18
|
+
|
|
19
|
+
for bg in ("BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE"):
|
|
20
|
+
bg_color = getattr(curses, "COLOR_" + bg)
|
|
21
|
+
|
|
22
|
+
curses.init_pair((fg_color * 8) + bg_color + 1, fg_color, bg_color)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_color_pair(fg: Color, bg: Color = "black") -> int:
|
|
26
|
+
fg_color = getattr(curses, "COLOR_" + fg.upper())
|
|
27
|
+
bg_color = getattr(curses, "COLOR_" + bg.upper())
|
|
28
|
+
|
|
29
|
+
return curses.color_pair((fg_color * 8) + bg_color + 1)
|
interactui/component.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
4
|
+
|
|
5
|
+
from interactui.key import Key, KeyBinding, KeyBindingMap
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from interactui.app import App
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Component:
|
|
12
|
+
_key_bindings: ClassVar[list[KeyBinding]] = []
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.key_bindings: KeyBindingMap = KeyBindingMap(*self._key_bindings)
|
|
16
|
+
|
|
17
|
+
def handle_key(self, key: Key, app: App) -> bool:
|
|
18
|
+
if (kb := self.key_bindings.get(key)) is None:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
if isinstance(kb.callback, str):
|
|
22
|
+
getattr(self, f"handle_{kb.callback}")(key, app)
|
|
23
|
+
|
|
24
|
+
else:
|
|
25
|
+
kb.callback(self, key, app)
|
|
26
|
+
|
|
27
|
+
return True
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from interactui import App, KeyBinding
|
|
5
|
+
from interactui.key import Key
|
|
6
|
+
from interactui.view import Style, TextArea
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TextAreaInput(TextArea):
|
|
10
|
+
_key_bindings: ClassVar[list[KeyBinding]] = [KeyBinding(Key(Key.ALL), "Press any key", "key_press")]
|
|
11
|
+
|
|
12
|
+
def handle_key_press(self, key: Key, _: App) -> None:
|
|
13
|
+
self.set_content(f"Key pressed: {key.as_str()} | value={key.value} ({key.raw_value})")
|
|
14
|
+
self.render()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> int:
|
|
18
|
+
App().run([TextAreaInput("", style=Style(padding=1, margin=1, border="rounded"))])
|
|
19
|
+
return 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
sys.exit(main())
|
interactui/key.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
from typing import TYPE_CHECKING, Callable, ClassVar, Literal, NamedTuple, Self, override
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from interactui.app import App
|
|
8
|
+
from interactui.component import Component
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Key:
|
|
12
|
+
CTRL: ClassVar[int] = 1
|
|
13
|
+
ALT: ClassVar[int] = 2
|
|
14
|
+
|
|
15
|
+
ALL: ClassVar[int] = -1
|
|
16
|
+
|
|
17
|
+
Escape: ClassVar[Literal[27]] = 27
|
|
18
|
+
Enter: ClassVar[tuple[int, ...]] = (10, 13, curses.KEY_ENTER)
|
|
19
|
+
ArrowUp: ClassVar[int] = curses.KEY_UP
|
|
20
|
+
ArrowDown: ClassVar[int] = curses.KEY_DOWN
|
|
21
|
+
ArrowLeft: ClassVar[int] = curses.KEY_LEFT
|
|
22
|
+
ArrowRight: ClassVar[int] = curses.KEY_RIGHT
|
|
23
|
+
|
|
24
|
+
SPECIAL_KEYS: ClassVar[dict[int, tuple[str, int]]] = {
|
|
25
|
+
27: ("Esc", 27),
|
|
26
|
+
10: ("Enter", curses.KEY_ENTER),
|
|
27
|
+
13: ("Enter", curses.KEY_ENTER),
|
|
28
|
+
curses.KEY_ENTER: ("Enter", curses.KEY_ENTER),
|
|
29
|
+
curses.KEY_UP: ("Up arrow", curses.KEY_UP),
|
|
30
|
+
curses.KEY_DOWN: ("Down arrow", curses.KEY_DOWN),
|
|
31
|
+
curses.KEY_LEFT: ("Left arrow", curses.KEY_LEFT),
|
|
32
|
+
curses.KEY_RIGHT: ("Right arrow", curses.KEY_RIGHT),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def __init__(self, value: str | int | tuple[int, ...], modifiers: int = 0) -> None:
|
|
36
|
+
if isinstance(value, tuple):
|
|
37
|
+
value = value[-1]
|
|
38
|
+
|
|
39
|
+
assert isinstance(value, int) or len(value) == 1
|
|
40
|
+
|
|
41
|
+
self._value: str | int = value
|
|
42
|
+
self.modifiers: int = modifiers
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
def __eq__(self, value: object, /) -> bool:
|
|
46
|
+
if not isinstance(value, Key):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
return self._value == value._value and self.modifiers == value.modifiers
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
def __hash__(self) -> int:
|
|
53
|
+
return hash(self._value) + hash(self.modifiers)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def value(self) -> str:
|
|
57
|
+
if isinstance(self._value, str):
|
|
58
|
+
return self._value
|
|
59
|
+
|
|
60
|
+
return Key.SPECIAL_KEYS[self._value][0]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def raw_value(self) -> str | int:
|
|
64
|
+
return self._value
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_CTRL(self) -> bool:
|
|
68
|
+
return bool(self.modifiers & Key.CTRL)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_ALT(self) -> bool:
|
|
72
|
+
return bool(self.modifiers & Key.ALT)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_plain(self) -> bool:
|
|
76
|
+
return self.modifiers == 0
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"{type(self).__name__}<{self.as_str()}>"
|
|
81
|
+
|
|
82
|
+
def as_str(self) -> str:
|
|
83
|
+
repr_ = self.value
|
|
84
|
+
|
|
85
|
+
if self.is_ALT:
|
|
86
|
+
repr_ = "M-" + repr_
|
|
87
|
+
|
|
88
|
+
if self.is_CTRL:
|
|
89
|
+
repr_ = "C-" + repr_
|
|
90
|
+
|
|
91
|
+
return f"<{repr_}>"
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def get(cls, win: curses.window) -> Self:
|
|
95
|
+
win.keypad(True)
|
|
96
|
+
|
|
97
|
+
ch = win.getch()
|
|
98
|
+
|
|
99
|
+
if ch in cls.SPECIAL_KEYS and ch != 27:
|
|
100
|
+
return cls(cls.SPECIAL_KEYS[ch][1])
|
|
101
|
+
|
|
102
|
+
elif 0 < ch < 27:
|
|
103
|
+
return cls(chr(ch + 96), modifiers=Key.CTRL)
|
|
104
|
+
|
|
105
|
+
elif ch == 27: # ALT was pressed
|
|
106
|
+
win.nodelay(True)
|
|
107
|
+
ch2 = win.getch() # get the key pressed after ALT
|
|
108
|
+
win.nodelay(False)
|
|
109
|
+
|
|
110
|
+
if 0 < ch2 < 27:
|
|
111
|
+
return cls(chr(ch2 + 96), modifiers=Key.CTRL | Key.ALT)
|
|
112
|
+
|
|
113
|
+
elif ch2 == -1:
|
|
114
|
+
return cls(cls.Escape)
|
|
115
|
+
|
|
116
|
+
return cls(chr(ch2), modifiers=Key.ALT)
|
|
117
|
+
|
|
118
|
+
else:
|
|
119
|
+
return cls(chr(ch))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class KeyBinding(NamedTuple):
|
|
123
|
+
"""
|
|
124
|
+
A key-binding definition for a Component (App or View).
|
|
125
|
+
`callback` may be :
|
|
126
|
+
- a function to be called directly (arg: App)
|
|
127
|
+
- a string, in which case the Component's method with name `handle_<callback>` will be called (args: Key, App)
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
key: Key
|
|
131
|
+
binding: str
|
|
132
|
+
callback: Callable[[Component, Key, App], None] | str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class KeyBindingMap:
|
|
136
|
+
def __init__(self, *key_bindings: KeyBinding) -> None:
|
|
137
|
+
self.keybindings: dict[Key, KeyBinding] = {kb.key: kb for kb in key_bindings}
|
|
138
|
+
|
|
139
|
+
def __add__(self, key_bindings: list[KeyBinding]) -> KeyBindingMap:
|
|
140
|
+
return KeyBindingMap(*self.keybindings.values(), *key_bindings)
|
|
141
|
+
|
|
142
|
+
def get(self, key: Key) -> KeyBinding | None:
|
|
143
|
+
binding = self.keybindings.get(key)
|
|
144
|
+
if binding is None:
|
|
145
|
+
return self.keybindings.get(Key(Key.ALL))
|
|
146
|
+
|
|
147
|
+
return binding
|
|
148
|
+
|
|
149
|
+
def as_str(self) -> str:
|
|
150
|
+
width_key = max(map(len, (kb.key.value for kb in self.keybindings.values())))
|
|
151
|
+
width_binding = max(map(len, (kb.binding for kb in self.keybindings.values())))
|
|
152
|
+
|
|
153
|
+
return "\n".join(
|
|
154
|
+
f"{kb.key.as_str():<{width_key}} {kb.binding:<{width_binding}}" for kb in self.keybindings.values()
|
|
155
|
+
)
|
interactui/layout.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from interactui.common import LayoutCallback
|
|
8
|
+
from interactui.key import Key
|
|
9
|
+
from interactui.view import Action, FloatingView, View
|
|
10
|
+
from interactui.view.typing import ViewLike
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from interactui.app import App
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_height(height: int | str, max_height: int) -> int:
|
|
17
|
+
if isinstance(height, int):
|
|
18
|
+
return height
|
|
19
|
+
|
|
20
|
+
assert height.endswith("%")
|
|
21
|
+
return int(float(height[:-1]) * max_height)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_heights(views: Sequence[View], height: int) -> list[int]:
|
|
25
|
+
view_heights = [_get_height(view.style.height, height) for view in views]
|
|
26
|
+
|
|
27
|
+
leftover_height = height - sum(view_heights)
|
|
28
|
+
if leftover_height < 0:
|
|
29
|
+
msg = f"Cannot fit views in {height} lines"
|
|
30
|
+
raise ValueError(msg)
|
|
31
|
+
|
|
32
|
+
to_expand = sum(h == 0 for h in view_heights)
|
|
33
|
+
|
|
34
|
+
if to_expand == 0:
|
|
35
|
+
return view_heights
|
|
36
|
+
|
|
37
|
+
expansion_lines, r = divmod(leftover_height, to_expand)
|
|
38
|
+
if expansion_lines == 0:
|
|
39
|
+
msg = f"No lines left for {to_expand} expanded views"
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
|
|
42
|
+
expanded_heights = iter([expansion_lines + 1] * r + [expansion_lines] * (to_expand - r))
|
|
43
|
+
|
|
44
|
+
return [h if h != 0 else next(expanded_heights) for h in view_heights]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Layout:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
views: Sequence[View],
|
|
51
|
+
window: curses.window,
|
|
52
|
+
*,
|
|
53
|
+
on_append: LayoutCallback | None = None,
|
|
54
|
+
on_remove: LayoutCallback | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._window: curses.window = window
|
|
57
|
+
|
|
58
|
+
self._id_map: dict[int, int]
|
|
59
|
+
self.fixed: list[View]
|
|
60
|
+
|
|
61
|
+
self.fixed, self._id_map = self._place_views(views, window)
|
|
62
|
+
|
|
63
|
+
self.floating: FloatingView | None = None
|
|
64
|
+
|
|
65
|
+
self._focus: int | None = None if not len(self.fixed) else self.fixed[-1].get_id()
|
|
66
|
+
|
|
67
|
+
self.on_append: LayoutCallback | None = on_append
|
|
68
|
+
self.on_remove: LayoutCallback | None = on_remove
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def focused(self) -> View | None:
|
|
72
|
+
if self._focus is None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
index = self._id_map.get(self._focus)
|
|
76
|
+
|
|
77
|
+
if index is None:
|
|
78
|
+
assert self.floating is not None and self._focus == self.floating.get_id()
|
|
79
|
+
return self.floating
|
|
80
|
+
|
|
81
|
+
return self.fixed[index]
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _place_views(views: Sequence[View], window: curses.window) -> tuple[list[View], dict[int, int]]:
|
|
85
|
+
height, width = window.getmaxyx()
|
|
86
|
+
|
|
87
|
+
heights = _get_heights(views, height)
|
|
88
|
+
|
|
89
|
+
views_init: list[View] = []
|
|
90
|
+
id_map: dict[int, int] = {}
|
|
91
|
+
|
|
92
|
+
begin_y = 0
|
|
93
|
+
for id, (view, view_height) in enumerate(zip(views, heights)):
|
|
94
|
+
view.init(id, window.subwin(view_height, width, begin_y, 0))
|
|
95
|
+
begin_y += view_height
|
|
96
|
+
|
|
97
|
+
views_init.append(view)
|
|
98
|
+
id_map[id] = id
|
|
99
|
+
|
|
100
|
+
return views_init, id_map
|
|
101
|
+
|
|
102
|
+
def append(self, view: ViewLike) -> None:
|
|
103
|
+
if callable(view):
|
|
104
|
+
view = view(self._window)
|
|
105
|
+
|
|
106
|
+
if isinstance(view, FloatingView):
|
|
107
|
+
view.init(self.get_next_id(), self._window.subwin(*view.size, view.y, view.x))
|
|
108
|
+
self.floating = view
|
|
109
|
+
|
|
110
|
+
else:
|
|
111
|
+
self.fixed, self._id_map = self._place_views(self.fixed + [view], self._window)
|
|
112
|
+
|
|
113
|
+
self._focus = view.get_id()
|
|
114
|
+
self.render()
|
|
115
|
+
|
|
116
|
+
if callable(self.on_append):
|
|
117
|
+
self.on_append(view)
|
|
118
|
+
|
|
119
|
+
def remove(self, id: int) -> None:
|
|
120
|
+
index = self._id_map.get(id)
|
|
121
|
+
|
|
122
|
+
if index is None:
|
|
123
|
+
if self.floating is not None and id == self.floating.get_id():
|
|
124
|
+
view = self.floating
|
|
125
|
+
self.floating = None
|
|
126
|
+
|
|
127
|
+
else:
|
|
128
|
+
msg = f"Layout does not contain a view with id `{id}`"
|
|
129
|
+
raise ValueError(msg)
|
|
130
|
+
|
|
131
|
+
else:
|
|
132
|
+
view = self.fixed.pop(index)
|
|
133
|
+
self.fixed, self._id_map = self._place_views(self.fixed, self._window)
|
|
134
|
+
|
|
135
|
+
if self._focus == id:
|
|
136
|
+
if not len(self.fixed):
|
|
137
|
+
self._focus = None
|
|
138
|
+
|
|
139
|
+
else:
|
|
140
|
+
self._focus = self.fixed[-1].get_id()
|
|
141
|
+
|
|
142
|
+
view.reset()
|
|
143
|
+
self.render()
|
|
144
|
+
|
|
145
|
+
if callable(self.on_remove):
|
|
146
|
+
self.on_remove(view)
|
|
147
|
+
|
|
148
|
+
# def replace(self, id: ID, view: View) -> None:
|
|
149
|
+
# pass
|
|
150
|
+
|
|
151
|
+
def get_next_id(self) -> int:
|
|
152
|
+
ids = list(self._id_map.keys())
|
|
153
|
+
if (floating := self.floating) is not None:
|
|
154
|
+
ids.append(floating.get_id())
|
|
155
|
+
|
|
156
|
+
return max(ids) + 1
|
|
157
|
+
|
|
158
|
+
def get_key(self) -> Key:
|
|
159
|
+
window = self._window if self.focused is None else self.focused.get_windows()[1]
|
|
160
|
+
|
|
161
|
+
return Key.get(window)
|
|
162
|
+
|
|
163
|
+
def render(self) -> None:
|
|
164
|
+
for view in self.fixed:
|
|
165
|
+
view.render()
|
|
166
|
+
|
|
167
|
+
if (floating := self.floating) is not None:
|
|
168
|
+
floating.render()
|
|
169
|
+
|
|
170
|
+
def execute_actions(self, app: App) -> None:
|
|
171
|
+
for view in self.fixed:
|
|
172
|
+
if isinstance(view, Action):
|
|
173
|
+
res = view.execute(app)
|
|
174
|
+
self.remove(view.get_id())
|
|
175
|
+
|
|
176
|
+
if res.ok:
|
|
177
|
+
view.on_ok(app)
|
|
178
|
+
|
|
179
|
+
else:
|
|
180
|
+
view.on_err(app, res.reason)
|
interactui/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Protocol, override, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from interactui.app import App
|
|
8
|
+
from interactui.text import String
|
|
9
|
+
from interactui.view import HLine, View
|
|
10
|
+
from interactui.view.style import Style
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RecordItem:
|
|
15
|
+
item: String
|
|
16
|
+
details: str | None = None
|
|
17
|
+
data: Any = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Record(View):
|
|
21
|
+
def __init__(self, *, style: Style | None = None) -> None:
|
|
22
|
+
super().__init__(style=style)
|
|
23
|
+
self.records: list[RecordItem] = []
|
|
24
|
+
|
|
25
|
+
def append(self, record: RecordItem) -> None:
|
|
26
|
+
self.records.append(record)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def last(self) -> RecordItem:
|
|
30
|
+
return self.records[-1]
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def draw(self, window: curses.window) -> None:
|
|
34
|
+
y = 0
|
|
35
|
+
for item in self.records:
|
|
36
|
+
item.item.render(window, y, 0)
|
|
37
|
+
y += item.item.height
|
|
38
|
+
|
|
39
|
+
if item.details is not None:
|
|
40
|
+
details = String.parse(f"[└ {item.details};white dim italic]")
|
|
41
|
+
details.render(window, y, 1)
|
|
42
|
+
y += details.height
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class RecordableView(Protocol):
|
|
47
|
+
def get_record_item(self) -> RecordItem: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Form(App):
|
|
51
|
+
def __init__(self, questions: list[View], *, before: list[View] | None = None):
|
|
52
|
+
super().__init__()
|
|
53
|
+
|
|
54
|
+
self.before: list[View] = before or []
|
|
55
|
+
self.questions: list[View] = questions
|
|
56
|
+
self.index: int = -1
|
|
57
|
+
|
|
58
|
+
self.record: Record = Record(style={"padding": (1, 5)})
|
|
59
|
+
|
|
60
|
+
def next(self) -> View:
|
|
61
|
+
self.index += 1
|
|
62
|
+
|
|
63
|
+
if self.index >= len(self.questions):
|
|
64
|
+
raise StopIteration()
|
|
65
|
+
|
|
66
|
+
return self.questions[self.index]
|
|
67
|
+
|
|
68
|
+
def prev(self) -> View:
|
|
69
|
+
if self.index <= 0:
|
|
70
|
+
raise IndexError("Already at first question")
|
|
71
|
+
|
|
72
|
+
self.index -= 1
|
|
73
|
+
return self.questions[self.index]
|
|
74
|
+
|
|
75
|
+
def _on_remove_view(self, app: App, view: View) -> None:
|
|
76
|
+
if isinstance(view, RecordableView):
|
|
77
|
+
self.record.append(view.get_record_item())
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
app.layout.append(self.next())
|
|
81
|
+
|
|
82
|
+
except StopIteration:
|
|
83
|
+
app.exit(0)
|
|
84
|
+
|
|
85
|
+
@override
|
|
86
|
+
def run(self) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
87
|
+
return super().run(
|
|
88
|
+
self.before + [HLine(), self.record, HLine()] + [self.next()], on_remove_view=self._on_remove_view
|
|
89
|
+
)
|