python-interactive-courses 1.0.0__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.
- pyics/__init__.py +0 -0
- pyics/app/actions.py +179 -0
- pyics/app/cli.py +335 -0
- pyics/app/key_listener.py +133 -0
- pyics/app/prompts.py +439 -0
- pyics/app/pyics.py +454 -0
- pyics/data/__init__.py +4 -0
- pyics/data/course.py +531 -0
- pyics/data/task.py +744 -0
- pyics/distribution/__init__.py +5 -0
- pyics/distribution/archive.py +667 -0
- pyics/distribution/dependencies.py +452 -0
- pyics/distribution/fetch.py +128 -0
- pyics/distribution/pull.py +376 -0
- pyics/distribution/slugs.py +24 -0
- pyics/distribution/sources.py +561 -0
- pyics/notebook/__init__.py +1 -0
- pyics/notebook/cells.py +184 -0
- pyics/notebook/loader.py +590 -0
- pyics/notebook/sections.py +189 -0
- pyics/plugins.py +90 -0
- pyics/scripts/console.py +86 -0
- pyics/scripts/create_course.py +301 -0
- pyics/scripts/create_task.py +850 -0
- pyics/scripts/edit_course.py +133 -0
- pyics/scripts/edit_task.py +109 -0
- pyics/scripts/main.py +118 -0
- pyics/scripts/pack.py +198 -0
- pyics/scripts/pull.py +123 -0
- pyics/scripts/sources.py +118 -0
- pyics/tests/__init__.py +3 -0
- pyics/tests/capture.py +114 -0
- pyics/tests/check_specs.py +528 -0
- pyics/tests/check_structure.py +769 -0
- pyics/tests/compare.py +184 -0
- pyics/tests/pytest_integration.py +228 -0
- pyics/tests/results.py +145 -0
- pyics/tests/tests.py +440 -0
- pyics/tests/trace.py +156 -0
- pyics/util/discovery.py +92 -0
- pyics/util/json_handling.py +182 -0
- pyics/util/logging.py +39 -0
- pyics/util/paths.py +38 -0
- pyics/util/terminal.py +57 -0
- python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
- python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
- python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
- python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
- python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
pyics/__init__.py
ADDED
|
File without changes
|
pyics/app/actions.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from pyics.util.logging import get_logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Action(ABC):
|
|
8
|
+
"""A parameterless, kwargs-bound unit of work."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, name: str, **kwargs):
|
|
11
|
+
"""
|
|
12
|
+
Compose an Action object. Note that an Action object should be callable without
|
|
13
|
+
parameters (refer to how kwargs are used).
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
name: str
|
|
18
|
+
Name of the Action
|
|
19
|
+
**kwargs
|
|
20
|
+
kwargs to encode call parameters to specific values.
|
|
21
|
+
"""
|
|
22
|
+
self.name = name
|
|
23
|
+
self.kwargs = kwargs
|
|
24
|
+
|
|
25
|
+
def __call__(self):
|
|
26
|
+
get_logger(__name__).info(f"Triggered {self.name} action.")
|
|
27
|
+
self.exec(**self.kwargs)
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def exec(**kwargs) -> None: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class KeyAction(Action):
|
|
35
|
+
"""An Action bound to a hotkey"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: str, key: str, ref: Optional[str] = None, **kwargs):
|
|
38
|
+
"""
|
|
39
|
+
Compose a KeyAction object. Note that an Action object should be callable without
|
|
40
|
+
parameters (refer to how kwargs are used).
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
name: str
|
|
45
|
+
Name of the KeyAction
|
|
46
|
+
key: str
|
|
47
|
+
Hotkey associated with the action
|
|
48
|
+
ref: str (optional)
|
|
49
|
+
Optional key reference, in case the actual key-press is unwieldy.
|
|
50
|
+
**kwargs
|
|
51
|
+
kwargs to encode call parameters to specific values.
|
|
52
|
+
"""
|
|
53
|
+
super().__init__(name, **kwargs)
|
|
54
|
+
self.key = key
|
|
55
|
+
self.ref = ref if ref else self.key
|
|
56
|
+
|
|
57
|
+
def __call__(self) -> None:
|
|
58
|
+
self.exec(**self.kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Quit(KeyAction):
|
|
62
|
+
"""
|
|
63
|
+
Quit application on ctrl-c press
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, name: str = "Quit", key: str = "\x03", ref="ctrl-c", **kwargs):
|
|
67
|
+
"""
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
cli: CLI (kwarg)
|
|
71
|
+
CLI object
|
|
72
|
+
pyics: PyICs (kwarg)
|
|
73
|
+
PyICs object
|
|
74
|
+
"""
|
|
75
|
+
super().__init__(name, key, ref=ref, **kwargs)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def exec(**kwargs):
|
|
79
|
+
cli = kwargs["cli"]
|
|
80
|
+
pyics = kwargs["pyics"]
|
|
81
|
+
|
|
82
|
+
cli.running = False
|
|
83
|
+
pyics.running = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Next(KeyAction):
|
|
87
|
+
"""Move to the next task."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, name: str = "Next", key: str = "n", **kwargs):
|
|
90
|
+
super().__init__(name, key, **kwargs)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def exec(**kwargs) -> None:
|
|
94
|
+
if (course := kwargs["pyics"].current_course) is not None:
|
|
95
|
+
course.next()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Previous(KeyAction):
|
|
99
|
+
"""Go back to the previous task."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, name: str = "Previous", key: str = "p", **kwargs):
|
|
102
|
+
super().__init__(name, key, **kwargs)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def exec(**kwargs) -> None:
|
|
106
|
+
if (course := kwargs["pyics"].current_course) is not None:
|
|
107
|
+
course.previous()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Courses(KeyAction):
|
|
111
|
+
"""Leave the current course for the course selector."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, name: str = "Courses", key: str = "c", **kwargs):
|
|
114
|
+
super().__init__(name, key, **kwargs)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def exec(**kwargs) -> None:
|
|
118
|
+
kwargs["pyics"].leave_course()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ResetTask(KeyAction):
|
|
122
|
+
"""Reset the current task."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, name: str = "Reset Task", key: str = "r", **kwargs):
|
|
125
|
+
super().__init__(name, key, **kwargs)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def exec(**kwargs) -> None:
|
|
129
|
+
"""TODO"""
|
|
130
|
+
...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ToggleTrace(KeyAction):
|
|
134
|
+
"""Turn tracing the current task's failed tests with snoop on or off."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, name: str = "Trace", key: str = "t", **kwargs):
|
|
137
|
+
super().__init__(name, key, **kwargs)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def exec(**kwargs) -> None:
|
|
141
|
+
if (course := kwargs["pyics"].current_course) is not None:
|
|
142
|
+
course.toggle_tracing()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ScrollUp(KeyAction):
|
|
146
|
+
"""Scroll the main content viewport up."""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self, name: str = "Scroll", key: str = "UP", ref: str = "\u2191/\u2193", **kwargs
|
|
150
|
+
):
|
|
151
|
+
super().__init__(name, key, ref=ref, **kwargs)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def exec(**kwargs) -> None:
|
|
155
|
+
kwargs["cli"].scroll(-1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ScrollDown(KeyAction):
|
|
159
|
+
"""Scroll the main content viewport down."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self, name: str = "Scroll", key: str = "DOWN", ref: str = "\u2191/\u2193", **kwargs
|
|
163
|
+
):
|
|
164
|
+
super().__init__(name, key, ref=ref, **kwargs)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def exec(**kwargs) -> None:
|
|
168
|
+
kwargs["cli"].scroll(1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Hint(KeyAction):
|
|
172
|
+
"""Reveal the current task's next hint."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, name: str = "Hint", key: str = "h", **kwargs):
|
|
175
|
+
super().__init__(name, key, **kwargs)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def exec(**kwargs) -> None:
|
|
179
|
+
kwargs["pyics"].reveal_hint()
|
pyics/app/cli.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pyfiglet import Figlet
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.align import Align
|
|
10
|
+
from rich.cells import cell_len
|
|
11
|
+
from rich.console import Console, ConsoleDimensions, Group, RenderableType
|
|
12
|
+
from rich.padding import Padding
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.progress import BarColumn, Progress, TextColumn
|
|
15
|
+
from rich.segment import Segment
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text, TextType
|
|
18
|
+
|
|
19
|
+
# the escape codes not used here are re-exported, as they used to be defined here
|
|
20
|
+
from pyics.util.terminal import ( # noqa: F401
|
|
21
|
+
ALT_SCREEN_ENTER,
|
|
22
|
+
ALT_SCREEN_LEAVE,
|
|
23
|
+
HIDE_CURSOR,
|
|
24
|
+
LINE_CLEAR,
|
|
25
|
+
LINE_UP,
|
|
26
|
+
SHOW_CURSOR,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Private-use codepoint that input widgets (see pyics.app.prompts) embed in the main content
|
|
30
|
+
# to mark where the terminal cursor goes. Stripped before the frame is written.
|
|
31
|
+
CURSOR_MARKER = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CLI:
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.running = False
|
|
37
|
+
self._thread: threading.Thread | None = None
|
|
38
|
+
# escape codes are written on Windows, too (see ``pyics.util.terminal``)
|
|
39
|
+
self.console = Console(legacy_windows=False)
|
|
40
|
+
self.progress = Progress(
|
|
41
|
+
TextColumn("[progress.description]{task.description}"),
|
|
42
|
+
BarColumn(
|
|
43
|
+
bar_width=None,
|
|
44
|
+
finished_style="blue",
|
|
45
|
+
pulse_style="blue",
|
|
46
|
+
complete_style="blue",
|
|
47
|
+
),
|
|
48
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
49
|
+
expand=True,
|
|
50
|
+
)
|
|
51
|
+
self.task_id = self.progress.add_task("Progress", total=1)
|
|
52
|
+
self.hotkeys = {}
|
|
53
|
+
|
|
54
|
+
# main-content viewport scroll state, in lines
|
|
55
|
+
self.scroll_offset = 0
|
|
56
|
+
self._max_scroll_offset = 0
|
|
57
|
+
# scroll a CURSOR_MARKER into view on the next draw; only set when the content
|
|
58
|
+
# changes, so manual scrolling isn't undone by the next redraw
|
|
59
|
+
self._follow_cursor = False
|
|
60
|
+
|
|
61
|
+
# whether the terminal cursor is shown at a CURSOR_MARKER in the main content
|
|
62
|
+
self.cursor_visible = True
|
|
63
|
+
|
|
64
|
+
# the terminal size the last frame was drawn for, to redraw it once that changes
|
|
65
|
+
self._drawn_size: ConsoleDimensions | None = None
|
|
66
|
+
|
|
67
|
+
self.update_info("Ready")
|
|
68
|
+
self.update_main_content(
|
|
69
|
+
Text("Main program outputs will appear here...", style="dim"), "Main Output"
|
|
70
|
+
)
|
|
71
|
+
self.update_progress(0.0)
|
|
72
|
+
|
|
73
|
+
def __call__(self, *args: Any, **kwds: Any) -> Any:
|
|
74
|
+
"""
|
|
75
|
+
Start up the cli Daemon.
|
|
76
|
+
"""
|
|
77
|
+
self.running = True
|
|
78
|
+
self._thread = threading.Thread(target=self.run, daemon=True)
|
|
79
|
+
self._thread.start()
|
|
80
|
+
|
|
81
|
+
def stop(self) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Stop the cli Daemon, waiting for a frame that is being drawn, so that nothing is
|
|
84
|
+
drawn once this returns (e.g. after leaving the alternate screen).
|
|
85
|
+
"""
|
|
86
|
+
self.running = False
|
|
87
|
+
if self._thread is not None and self._thread is not threading.current_thread():
|
|
88
|
+
self._thread.join(timeout=1)
|
|
89
|
+
|
|
90
|
+
def generate_header(self) -> Panel:
|
|
91
|
+
"""
|
|
92
|
+
Construct the header from the info-text attribute.
|
|
93
|
+
"""
|
|
94
|
+
figlet_text = Figlet(font="big").renderText("PyICs").strip("\n")
|
|
95
|
+
|
|
96
|
+
table = Table(
|
|
97
|
+
expand=True, box=box.MINIMAL, show_header=False, style="blue", padding=(0, 2)
|
|
98
|
+
)
|
|
99
|
+
table.add_column(
|
|
100
|
+
vertical="middle",
|
|
101
|
+
min_width=(len_figlet := len(figlet_text.split("\n")[0])),
|
|
102
|
+
max_width=len_figlet,
|
|
103
|
+
)
|
|
104
|
+
table.add_column(vertical="middle", ratio=1)
|
|
105
|
+
table.add_row(
|
|
106
|
+
Text(figlet_text, style="blue", no_wrap=True),
|
|
107
|
+
Text.from_markup(self.info_text, style="white", emoji=False),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return Panel(
|
|
111
|
+
table,
|
|
112
|
+
title=f"[blue]PyICs[white] {importlib.metadata.version('pyics')}",
|
|
113
|
+
title_align="left",
|
|
114
|
+
border_style="blue",
|
|
115
|
+
box=box.ROUNDED,
|
|
116
|
+
padding=(0, 0),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def generate_footer(self) -> Panel:
|
|
120
|
+
"""
|
|
121
|
+
Construct the footer from the hotkey and progress bar attributes.
|
|
122
|
+
"""
|
|
123
|
+
# wrapped between hotkeys only, so a hotkey isn't separated from its action
|
|
124
|
+
width = self.console.width - 4 # the panel's borders and padding
|
|
125
|
+
lines: list[Text] = []
|
|
126
|
+
for key, action in self.hotkeys.items():
|
|
127
|
+
hotkey = Text.assemble((f"<{key}>", "bold cyan"), f" {action}")
|
|
128
|
+
if lines and lines[-1].cell_len + 3 + hotkey.cell_len <= width:
|
|
129
|
+
lines[-1].append(" | ", style="dim")
|
|
130
|
+
lines[-1].append_text(hotkey)
|
|
131
|
+
else:
|
|
132
|
+
lines.append(hotkey)
|
|
133
|
+
hotkeys_text = Text("\n", justify="center").join(lines)
|
|
134
|
+
|
|
135
|
+
self.progress.update(self.task_id, completed=self.percentage)
|
|
136
|
+
|
|
137
|
+
group = Group(self.progress, Align.center(hotkeys_text))
|
|
138
|
+
|
|
139
|
+
return Panel(group, border_style="blue", box=box.ROUNDED)
|
|
140
|
+
|
|
141
|
+
def update_main_content(self, renderable: RenderableType, title: TextType = ""):
|
|
142
|
+
"""
|
|
143
|
+
Update the content of the main panel.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
renderable: RenderableType
|
|
148
|
+
New content to put inside the main panel.
|
|
149
|
+
title: TextType (optional)
|
|
150
|
+
Set a frame title for the main content. If a title other than "" (default)
|
|
151
|
+
is provided, adds top padding to renderable.
|
|
152
|
+
"""
|
|
153
|
+
self.main_content = Padding(renderable, (0 if title == "" else 1, 0, 0, 0))
|
|
154
|
+
self.main_title = title
|
|
155
|
+
self._follow_cursor = True
|
|
156
|
+
self._needs_redraw = True
|
|
157
|
+
|
|
158
|
+
def add_to_main_content(self, renderable: RenderableType) -> None:
|
|
159
|
+
"""
|
|
160
|
+
_Add_ renderable below current main content.
|
|
161
|
+
|
|
162
|
+
**Important:** anything added to the main content this way will be overwritten
|
|
163
|
+
the next time a `ùpdate_main_content`` method call occurs.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
renderable: RenderableType
|
|
168
|
+
New content to _add_ to the main content.
|
|
169
|
+
"""
|
|
170
|
+
self.main_content = Group(self.main_content, Padding(renderable, (1, 0, 0, 0)))
|
|
171
|
+
self._needs_redraw = True
|
|
172
|
+
|
|
173
|
+
def update_progress(self, percentage: float):
|
|
174
|
+
"""
|
|
175
|
+
Update the progress displayed in the progress bar in the footer.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
percentage: float
|
|
180
|
+
New progress percentage (as a fraction!)
|
|
181
|
+
"""
|
|
182
|
+
self.percentage = percentage
|
|
183
|
+
self._needs_redraw = True
|
|
184
|
+
|
|
185
|
+
def update_hotkeys(self, hotkeys: dict):
|
|
186
|
+
"""
|
|
187
|
+
Update the hotkeys displayed in the footer. This intentionally _replaces_ the dict,
|
|
188
|
+
because that way it is easy for the controller to both remove and add or update
|
|
189
|
+
components.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
hotkeys: dict
|
|
194
|
+
Hotkey dict.
|
|
195
|
+
"""
|
|
196
|
+
self.hotkeys = hotkeys
|
|
197
|
+
self._needs_redraw = True
|
|
198
|
+
|
|
199
|
+
def update_info(self, text: str):
|
|
200
|
+
"""
|
|
201
|
+
Update the info_text. The info text is shown in the right part of the header panel.
|
|
202
|
+
|
|
203
|
+
Parameters
|
|
204
|
+
----------
|
|
205
|
+
text: str
|
|
206
|
+
The new info-text to draw. Supports ``rich`` markup, so plain text that may
|
|
207
|
+
contain square brackets must be escaped (``rich.markup.escape``).
|
|
208
|
+
"""
|
|
209
|
+
self.info_text = text
|
|
210
|
+
self._needs_redraw = True
|
|
211
|
+
|
|
212
|
+
def scroll(self, delta: int) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Move the main-content viewport by ``delta`` lines, clamped to the scrollable range.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
delta: int
|
|
219
|
+
Number of lines to scroll by. Positive scrolls down, negative scrolls up.
|
|
220
|
+
"""
|
|
221
|
+
self.scroll_offset = max(
|
|
222
|
+
0, min(self.scroll_offset + delta, self._max_scroll_offset)
|
|
223
|
+
)
|
|
224
|
+
self._needs_redraw = True
|
|
225
|
+
|
|
226
|
+
def _render_lines(self, renderable: RenderableType) -> list[list[Segment]]:
|
|
227
|
+
"""Render a renderable to its lines, uncropped by the console's current height."""
|
|
228
|
+
options = self.console.options.update(height=None)
|
|
229
|
+
return self.console.render_lines(renderable, options, pad=False)
|
|
230
|
+
|
|
231
|
+
def _locate_and_strip_cursor(
|
|
232
|
+
self, lines: list[list[Segment]]
|
|
233
|
+
) -> tuple[int, int] | None:
|
|
234
|
+
"""Strip CURSOR_MARKER from the rendered ``lines`` (in place) and return its
|
|
235
|
+
0-indexed ``(row, col)``, or ``None`` if absent."""
|
|
236
|
+
for row, line in enumerate(lines):
|
|
237
|
+
for i, seg in enumerate(line):
|
|
238
|
+
idx = seg.text.find(CURSOR_MARKER)
|
|
239
|
+
if idx == -1:
|
|
240
|
+
continue
|
|
241
|
+
col = cell_len("".join(s.text for s in line[:i])) + cell_len(seg.text[:idx])
|
|
242
|
+
line[i] = seg._replace(text=seg.text[:idx] + seg.text[idx + 1 :])
|
|
243
|
+
return row, col
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def _lines_to_text(self, lines: list[list[Segment]]) -> str:
|
|
247
|
+
"""Join rendered lines back into a single ANSI-encoded string."""
|
|
248
|
+
segments: list[Segment] = []
|
|
249
|
+
for i, line in enumerate(lines):
|
|
250
|
+
if i:
|
|
251
|
+
segments.append(Segment.line())
|
|
252
|
+
segments.extend(line)
|
|
253
|
+
return self.console._render_buffer(segments)
|
|
254
|
+
|
|
255
|
+
def draw_interface(self):
|
|
256
|
+
"""
|
|
257
|
+
Draw the PyICs interface. Header and footer are pinned to the top/bottom of the
|
|
258
|
+
terminal; only the main content scrolls, windowed to the space left between them.
|
|
259
|
+
"""
|
|
260
|
+
# cleared before anything is read, so changes made while this frame is drawn
|
|
261
|
+
# (e.g. from other threads) are drawn by the next one
|
|
262
|
+
self._needs_redraw = False
|
|
263
|
+
follow_cursor, self._follow_cursor = self._follow_cursor, False
|
|
264
|
+
# likewise, a frame drawn while the terminal is resized is drawn again
|
|
265
|
+
self._drawn_size = self.console.size
|
|
266
|
+
header_panel = self.generate_header()
|
|
267
|
+
footer_panel = self.generate_footer()
|
|
268
|
+
|
|
269
|
+
main_panel = Panel(
|
|
270
|
+
self.main_content,
|
|
271
|
+
title=self.main_title, # should display task-id/task-number later
|
|
272
|
+
title_align="left",
|
|
273
|
+
box=box.SIMPLE,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
term_height = self._drawn_size.height
|
|
277
|
+
|
|
278
|
+
header_lines = self._render_lines(header_panel)
|
|
279
|
+
footer_lines = self._render_lines(footer_panel)
|
|
280
|
+
viewport_height = max(0, term_height - len(header_lines) - len(footer_lines))
|
|
281
|
+
|
|
282
|
+
main_lines = self._render_lines(main_panel)
|
|
283
|
+
cursor_pos = self._locate_and_strip_cursor(main_lines)
|
|
284
|
+
|
|
285
|
+
self._max_scroll_offset = max(0, len(main_lines) - viewport_height)
|
|
286
|
+
if cursor_pos is not None and follow_cursor:
|
|
287
|
+
row, _ = cursor_pos
|
|
288
|
+
if row < self.scroll_offset:
|
|
289
|
+
# scroll all the way up if that shows the cursor, to keep the title visible
|
|
290
|
+
self.scroll_offset = 0 if row < viewport_height else row
|
|
291
|
+
elif row >= self.scroll_offset + viewport_height:
|
|
292
|
+
self.scroll_offset = row - viewport_height + 1
|
|
293
|
+
self.scroll_offset = max(0, min(self.scroll_offset, self._max_scroll_offset))
|
|
294
|
+
visible_main_lines = main_lines[
|
|
295
|
+
self.scroll_offset : self.scroll_offset + viewport_height
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
sys.stdout.write("\033[H\033[2J")
|
|
299
|
+
sys.stdout.write(self._lines_to_text(header_lines))
|
|
300
|
+
sys.stdout.write("\n")
|
|
301
|
+
sys.stdout.write(self._lines_to_text(visible_main_lines))
|
|
302
|
+
sys.stdout.write(f"\033[{term_height - len(footer_lines) + 1};1H")
|
|
303
|
+
sys.stdout.write(self._lines_to_text(footer_lines))
|
|
304
|
+
|
|
305
|
+
if cursor_pos is not None and (
|
|
306
|
+
self.scroll_offset <= cursor_pos[0] < self.scroll_offset + viewport_height
|
|
307
|
+
):
|
|
308
|
+
row, col = cursor_pos
|
|
309
|
+
abs_row = len(header_lines) + 1 + (row - self.scroll_offset)
|
|
310
|
+
sys.stdout.write(f"\033[{abs_row};{1 + col}H")
|
|
311
|
+
sys.stdout.write(SHOW_CURSOR if self.cursor_visible else HIDE_CURSOR)
|
|
312
|
+
else:
|
|
313
|
+
sys.stdout.write(HIDE_CURSOR)
|
|
314
|
+
|
|
315
|
+
sys.stdout.flush()
|
|
316
|
+
|
|
317
|
+
def run(self):
|
|
318
|
+
"""
|
|
319
|
+
Main CLI loop. While running, checks if a redraw is necessary (either updated
|
|
320
|
+
content or window-resize), and redraws the interface when necessary.
|
|
321
|
+
"""
|
|
322
|
+
sys.stdout.write(HIDE_CURSOR)
|
|
323
|
+
sys.stdout.flush()
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
while self.running:
|
|
327
|
+
# the size is polled, as only Unix signals a resize
|
|
328
|
+
if self._needs_redraw or self.console.size != self._drawn_size:
|
|
329
|
+
self.draw_interface()
|
|
330
|
+
|
|
331
|
+
time.sleep(0.05)
|
|
332
|
+
|
|
333
|
+
finally:
|
|
334
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
335
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import queue
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import ExitStack
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.input import Input, create_input
|
|
9
|
+
from prompt_toolkit.key_binding import KeyPress
|
|
10
|
+
from prompt_toolkit.keys import Keys
|
|
11
|
+
|
|
12
|
+
# Tokens of keys that don't type a character. Terminals also send the arrow keys (or the
|
|
13
|
+
# scroll keys) for mouse-wheel scrolling while in the alt-screen buffer (xterm "alternate
|
|
14
|
+
# scroll mode"), so this doubles as mouse-wheel support.
|
|
15
|
+
_KEY_TOKENS: dict[str, str] = {
|
|
16
|
+
Keys.Up: "UP",
|
|
17
|
+
Keys.Down: "DOWN",
|
|
18
|
+
Keys.Left: "LEFT",
|
|
19
|
+
Keys.Right: "RIGHT",
|
|
20
|
+
Keys.ScrollUp: "UP",
|
|
21
|
+
Keys.ScrollDown: "DOWN",
|
|
22
|
+
Keys.Escape: "\x1b",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _terminal_input() -> Input:
|
|
27
|
+
"""The input of the terminal that stdin is, for the platform (see
|
|
28
|
+
``prompt_toolkit.input``). Raises if stdin isn't a terminal."""
|
|
29
|
+
if sys.stdin is None or not sys.stdin.isatty():
|
|
30
|
+
raise OSError("stdin is not a terminal")
|
|
31
|
+
return create_input()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _tokens(key_press: KeyPress) -> list[str]:
|
|
35
|
+
"""
|
|
36
|
+
The tokens ``key_press`` is queued as: one of ``_KEY_TOKENS``, or else the text the
|
|
37
|
+
terminal sent for it, e.g. ``"a"``, or the control character of enter or ctrl-c.
|
|
38
|
+
Other keys that don't type a character (e.g. delete) are sent as escape sequences,
|
|
39
|
+
which are queued as single tokens of several characters.
|
|
40
|
+
"""
|
|
41
|
+
if key_press.key == Keys.BracketedPaste:
|
|
42
|
+
# e.g. pasted text, as Windows detects it: typed character by character
|
|
43
|
+
return list(key_press.data)
|
|
44
|
+
token = _KEY_TOKENS.get(key_press.key, key_press.data)
|
|
45
|
+
return [token] if token else []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class KeyListener(queue.Queue):
|
|
49
|
+
"""
|
|
50
|
+
Queues the keys pressed while in this context as tokens (see ``_tokens``). The
|
|
51
|
+
terminal is in raw mode in the meantime: keys aren't echoed, and ctrl-c is a key
|
|
52
|
+
rather than a signal.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
56
|
+
super().__init__(*args, **kwargs)
|
|
57
|
+
self._raw_mode_ready = threading.Event()
|
|
58
|
+
self._stop_event = threading.Event()
|
|
59
|
+
# why keys can't be read, raised by __enter__
|
|
60
|
+
self._error: Exception | None = None
|
|
61
|
+
self.thread: threading.Thread | None = None
|
|
62
|
+
|
|
63
|
+
def _key_listener(self) -> None:
|
|
64
|
+
"""
|
|
65
|
+
KeyListener main functionality.
|
|
66
|
+
"""
|
|
67
|
+
with ExitStack() as stack:
|
|
68
|
+
try:
|
|
69
|
+
keyboard = _terminal_input()
|
|
70
|
+
stack.enter_context(keyboard.raw_mode())
|
|
71
|
+
except Exception as err: # noqa: BLE001 - raised by __enter__
|
|
72
|
+
self._error = err
|
|
73
|
+
return
|
|
74
|
+
finally:
|
|
75
|
+
# Unblock __enter__ only once raw mode is actually in effect (or setup
|
|
76
|
+
# failed), so nothing else can race the terminal into a stale mode.
|
|
77
|
+
self._raw_mode_ready.set()
|
|
78
|
+
|
|
79
|
+
while not self._stop_event.is_set():
|
|
80
|
+
key_presses = keyboard.read_keys()
|
|
81
|
+
if not key_presses:
|
|
82
|
+
time.sleep(0.01)
|
|
83
|
+
# a lone esc can't be told from the start of an escape sequence
|
|
84
|
+
# (e.g. an arrow key) until nothing follows it
|
|
85
|
+
key_presses = keyboard.read_keys() or keyboard.flush_keys()
|
|
86
|
+
for key_press in key_presses:
|
|
87
|
+
for token in _tokens(key_press):
|
|
88
|
+
self.put(token)
|
|
89
|
+
|
|
90
|
+
def stop(self) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Signal the reader thread to stop and block until it has actually exited (and cooked
|
|
93
|
+
terminal mode has been restored), so callers can rely on that being true the instant
|
|
94
|
+
this returns.
|
|
95
|
+
"""
|
|
96
|
+
self._stop_event.set()
|
|
97
|
+
if self.thread is not None:
|
|
98
|
+
self.thread.join(timeout=1.0)
|
|
99
|
+
|
|
100
|
+
def __enter__(self):
|
|
101
|
+
self.thread = threading.Thread(target=self._key_listener, daemon=True)
|
|
102
|
+
self.thread.start()
|
|
103
|
+
self._raw_mode_ready.wait()
|
|
104
|
+
if self._error is not None:
|
|
105
|
+
raise self._error
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, exc_type, exc, tb):
|
|
109
|
+
self.stop()
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def hey_listen(q_param: str) -> Callable:
|
|
113
|
+
"""
|
|
114
|
+
KeyListener nested decorator. I.e., this static method decorates the decorator
|
|
115
|
+
(so that we can use the ``q_param`` parameter)
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
q_param: str
|
|
120
|
+
The parameter name, under which the queue is passed to the decorated function.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def dec(func: Callable) -> Callable:
|
|
124
|
+
def mod(*args, **kwargs):
|
|
125
|
+
with KeyListener() as q:
|
|
126
|
+
func(*args, **kwargs, **{q_param: q})
|
|
127
|
+
|
|
128
|
+
return mod
|
|
129
|
+
|
|
130
|
+
return dec
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
hey_listen = KeyListener.hey_listen
|