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 ADDED
@@ -0,0 +1,7 @@
1
+ from interactui.app import App
2
+ from interactui.key import KeyBinding
3
+ from interactui.layout import Layout
4
+ from interactui.text import String
5
+ from interactui.view import View
6
+
7
+ __all__ = ["App", "KeyBinding", "Layout", "String", "View"]
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)
@@ -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
+ )