python-arch-wiki 0.1.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.
- python_arch_wiki/__init__.py +0 -0
- python_arch_wiki/__main__.py +46 -0
- python_arch_wiki/menu.py +341 -0
- python_arch_wiki/toc.py +468 -0
- python_arch_wiki-0.1.0.dist-info/METADATA +109 -0
- python_arch_wiki-0.1.0.dist-info/RECORD +9 -0
- python_arch_wiki-0.1.0.dist-info/WHEEL +4 -0
- python_arch_wiki-0.1.0.dist-info/entry_points.txt +3 -0
- python_arch_wiki-0.1.0.dist-info/licenses/LICENSE +674 -0
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from autocommand import autocommand
|
|
5
|
+
from platformdirs import user_log_dir
|
|
6
|
+
|
|
7
|
+
from .menu import CursesMenu
|
|
8
|
+
from .toc import Toc
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(args, link_url) -> None:
|
|
12
|
+
toc = Toc(link_url=link_url)
|
|
13
|
+
if args:
|
|
14
|
+
toc.display_contents(args)
|
|
15
|
+
else:
|
|
16
|
+
with CursesMenu(toc) as menu:
|
|
17
|
+
result = None
|
|
18
|
+
while result is None:
|
|
19
|
+
menu.display_menu()
|
|
20
|
+
result = menu.handle_input()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def setup_logging(verbose=False) -> None:
|
|
24
|
+
app_name = "python-arch-wiki"
|
|
25
|
+
log_dir = Path(user_log_dir(app_name))
|
|
26
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
log_file = log_dir / f"{app_name}.log"
|
|
28
|
+
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
filename=log_file,
|
|
31
|
+
filemode="a",
|
|
32
|
+
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
level=logging.DEBUG if verbose else logging.WARNING,
|
|
35
|
+
force=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@autocommand(__name__)
|
|
40
|
+
def main(link_url=False, verbose=False, *article) -> None:
|
|
41
|
+
setup_logging(verbose)
|
|
42
|
+
run(article, link_url)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
python_arch_wiki/menu.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
from collections import deque
|
|
7
|
+
from curses.textpad import Textbox, rectangle
|
|
8
|
+
from subprocess import run
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EditCancelledError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CursesMenu:
|
|
18
|
+
def __init__(self, menu) -> None:
|
|
19
|
+
self._oldsignal = None
|
|
20
|
+
self._start_curses()
|
|
21
|
+
self.menu = menu
|
|
22
|
+
self.contents = [
|
|
23
|
+
[line] if isinstance(line, str) else line for line in self.menu
|
|
24
|
+
]
|
|
25
|
+
self._selected = 0
|
|
26
|
+
self._start_idx = 0
|
|
27
|
+
self._saved_state = deque(maxlen=2)
|
|
28
|
+
|
|
29
|
+
def _start_curses(self) -> None:
|
|
30
|
+
try:
|
|
31
|
+
self._stdscr = curses.initscr()
|
|
32
|
+
try:
|
|
33
|
+
self._width, self._height = os.get_terminal_size()
|
|
34
|
+
self._oldsignal = signal.signal(
|
|
35
|
+
signal.SIGWINCH, self._signal_win_resize
|
|
36
|
+
)
|
|
37
|
+
except OSError:
|
|
38
|
+
self._height, self._width = self._stdscr.getmaxyx()
|
|
39
|
+
curses.noecho()
|
|
40
|
+
curses.cbreak()
|
|
41
|
+
self._stdscr.keypad(True)
|
|
42
|
+
curses.curs_set(0)
|
|
43
|
+
curses.start_color()
|
|
44
|
+
curses.use_default_colors()
|
|
45
|
+
curses.init_pair(10, 1, -1)
|
|
46
|
+
curses.mousemask(curses.ALL_MOUSE_EVENTS)
|
|
47
|
+
curses.set_escdelay(50)
|
|
48
|
+
|
|
49
|
+
except curses.error as e:
|
|
50
|
+
run("reset", check=False)
|
|
51
|
+
sys.exit(f"{e}")
|
|
52
|
+
|
|
53
|
+
def _end_curses(self) -> None:
|
|
54
|
+
if self._oldsignal is not None:
|
|
55
|
+
signal.signal(signal.SIGWINCH, self._oldsignal)
|
|
56
|
+
self._stdscr.keypad(False)
|
|
57
|
+
curses.nocbreak()
|
|
58
|
+
curses.echo()
|
|
59
|
+
curses.curs_set(1)
|
|
60
|
+
curses.endwin()
|
|
61
|
+
|
|
62
|
+
def __enter__(self):
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
66
|
+
self._end_curses()
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def display_menu(self) -> None:
|
|
70
|
+
"""Display menu."""
|
|
71
|
+
for i, item in enumerate(
|
|
72
|
+
self.contents[self._start_idx : self._start_idx + self._height],
|
|
73
|
+
start=self._start_idx,
|
|
74
|
+
):
|
|
75
|
+
if i == self._selected:
|
|
76
|
+
self._stdscr.addstr(
|
|
77
|
+
i - self._start_idx, 0, f"> {item[-1]}", curses.A_BOLD
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
self._stdscr.addstr(i - self._start_idx, 0, f" {item[-1]}")
|
|
81
|
+
self._stdscr.clrtoeol()
|
|
82
|
+
|
|
83
|
+
self._stdscr.clrtobot()
|
|
84
|
+
self._stdscr.refresh()
|
|
85
|
+
|
|
86
|
+
def _adjust_idx(self) -> None:
|
|
87
|
+
assert self._selected >= 0, "Selected can not be negative"
|
|
88
|
+
contents_len = len(self.contents)
|
|
89
|
+
if contents_len > 0:
|
|
90
|
+
self._selected = min(self._selected, contents_len - 1)
|
|
91
|
+
|
|
92
|
+
# window is bigger than the whole menu contents
|
|
93
|
+
if self._height >= contents_len:
|
|
94
|
+
self._start_idx = 0
|
|
95
|
+
|
|
96
|
+
# selected item is at the bottom of the screen
|
|
97
|
+
if self._selected >= self._start_idx + self._height:
|
|
98
|
+
self._start_idx += 1
|
|
99
|
+
|
|
100
|
+
# adjust high limit
|
|
101
|
+
self._start_idx = min(
|
|
102
|
+
self._start_idx, self._selected, contents_len - self._height
|
|
103
|
+
)
|
|
104
|
+
# adjust low limit
|
|
105
|
+
self._start_idx = max(
|
|
106
|
+
0, self._start_idx, self._selected - self._height
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _previous(self) -> None:
|
|
110
|
+
if self._selected > 0:
|
|
111
|
+
self._selected -= 1
|
|
112
|
+
|
|
113
|
+
self._adjust_idx()
|
|
114
|
+
|
|
115
|
+
def _next(self) -> None:
|
|
116
|
+
if self._selected < len(self.contents) - 1:
|
|
117
|
+
self._selected += 1
|
|
118
|
+
|
|
119
|
+
self._adjust_idx()
|
|
120
|
+
|
|
121
|
+
def _fold(self, up_level: bool = False) -> None:
|
|
122
|
+
"""(Un)fold submenu."""
|
|
123
|
+
section = self.contents[self._selected][0]
|
|
124
|
+
|
|
125
|
+
if up_level:
|
|
126
|
+
if len(section) > 1:
|
|
127
|
+
self.menu.fold(section[:-1])
|
|
128
|
+
|
|
129
|
+
for i, sect in enumerate(self.contents):
|
|
130
|
+
if sect[0] == section[:-1]:
|
|
131
|
+
self._selected = i
|
|
132
|
+
|
|
133
|
+
else:
|
|
134
|
+
self.menu.fold(section)
|
|
135
|
+
|
|
136
|
+
if len(self._saved_state) > 0:
|
|
137
|
+
self._restore_state()
|
|
138
|
+
else:
|
|
139
|
+
self.contents = [
|
|
140
|
+
[line] if isinstance(line, str) else line
|
|
141
|
+
for line in self.menu
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
self._adjust_idx()
|
|
145
|
+
|
|
146
|
+
def _get_contents(self) -> None:
|
|
147
|
+
"""Get contents of menu item."""
|
|
148
|
+
selected = self.contents[self._selected][0]
|
|
149
|
+
|
|
150
|
+
if isinstance(selected, tuple):
|
|
151
|
+
self._save_state()
|
|
152
|
+
self.contents = self.menu.get_submenu(selected)
|
|
153
|
+
|
|
154
|
+
else:
|
|
155
|
+
self._end_curses()
|
|
156
|
+
self.menu.display_contents(selected)
|
|
157
|
+
self._start_curses()
|
|
158
|
+
|
|
159
|
+
def handle_input(self) -> int | None:
|
|
160
|
+
"""Handle keyboard and mouse events."""
|
|
161
|
+
key = self._stdscr.getch()
|
|
162
|
+
|
|
163
|
+
# Up arrow, Ctrl-p, Shift-TAB, k
|
|
164
|
+
if key in [curses.KEY_UP, 16, curses.KEY_BTAB, ord("k")]:
|
|
165
|
+
self._previous()
|
|
166
|
+
|
|
167
|
+
# Down arrow, Ctrl-n, TAB, j
|
|
168
|
+
elif key in [curses.KEY_DOWN, 14, ord("\t"), ord("j")]:
|
|
169
|
+
self._next()
|
|
170
|
+
|
|
171
|
+
elif key == ord("H"):
|
|
172
|
+
self._selected = self._start_idx
|
|
173
|
+
|
|
174
|
+
elif key == ord("M"):
|
|
175
|
+
stop = min(self._start_idx + self._height, len(self.contents))
|
|
176
|
+
self._selected = self._start_idx + (stop - self._start_idx) // 2
|
|
177
|
+
|
|
178
|
+
elif key == ord("L"):
|
|
179
|
+
self._selected = (
|
|
180
|
+
min(self._start_idx + self._height, len(self.contents)) - 1
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Enter
|
|
184
|
+
elif key == ord("\n"):
|
|
185
|
+
self._get_contents()
|
|
186
|
+
|
|
187
|
+
# Space or l
|
|
188
|
+
elif key in [ord(" "), ord("l")]:
|
|
189
|
+
self._fold()
|
|
190
|
+
|
|
191
|
+
# u, h, Escape
|
|
192
|
+
elif key in [ord("u"), ord("h"), 27]:
|
|
193
|
+
self._fold(up_level=True)
|
|
194
|
+
|
|
195
|
+
# Ctrl-d, q
|
|
196
|
+
elif key in [4, ord("q")]:
|
|
197
|
+
return 1
|
|
198
|
+
|
|
199
|
+
# slash or question
|
|
200
|
+
elif key in [ord("/"), ord("?")]:
|
|
201
|
+
self._search()
|
|
202
|
+
|
|
203
|
+
elif key == curses.KEY_MOUSE:
|
|
204
|
+
try:
|
|
205
|
+
_, _, y, _, bstate = curses.getmouse()
|
|
206
|
+
# Left click
|
|
207
|
+
if (
|
|
208
|
+
bstate & curses.BUTTON1_CLICKED
|
|
209
|
+
and y + self._start_idx < len(self.contents)
|
|
210
|
+
):
|
|
211
|
+
self._selected = y + self._start_idx
|
|
212
|
+
|
|
213
|
+
# Double click
|
|
214
|
+
elif (
|
|
215
|
+
bstate & curses.BUTTON1_DOUBLE_CLICKED
|
|
216
|
+
and y + self._start_idx < len(self.contents)
|
|
217
|
+
):
|
|
218
|
+
self._selected = y + self._start_idx
|
|
219
|
+
self._get_contents()
|
|
220
|
+
|
|
221
|
+
# Right click
|
|
222
|
+
elif (
|
|
223
|
+
bstate & curses.BUTTON3_CLICKED
|
|
224
|
+
and y + self._start_idx < len(self.contents)
|
|
225
|
+
):
|
|
226
|
+
self._selected = y + self._start_idx
|
|
227
|
+
self._fold()
|
|
228
|
+
|
|
229
|
+
# Mouse wheel up
|
|
230
|
+
elif bstate & curses.BUTTON4_PRESSED:
|
|
231
|
+
self._previous()
|
|
232
|
+
|
|
233
|
+
# Mouse wheel down
|
|
234
|
+
elif bstate & curses.BUTTON5_PRESSED:
|
|
235
|
+
self._next()
|
|
236
|
+
|
|
237
|
+
except curses.error:
|
|
238
|
+
# Display msg in the middle of the screen
|
|
239
|
+
msg = " Error handling mouse event. "
|
|
240
|
+
border = " " * len(msg)
|
|
241
|
+
line = self._height // 2
|
|
242
|
+
column = (self._width - len(msg)) // 2
|
|
243
|
+
self._stdscr.addstr(line - 1, column, border)
|
|
244
|
+
self._stdscr.addstr(
|
|
245
|
+
line,
|
|
246
|
+
column,
|
|
247
|
+
msg,
|
|
248
|
+
curses.color_pair(10),
|
|
249
|
+
)
|
|
250
|
+
self._stdscr.addstr(line + 1, column, border)
|
|
251
|
+
|
|
252
|
+
def _signal_win_resize(self, signum, stack_frame) -> None:
|
|
253
|
+
"""Handle SIGWINCH signal (resize window)."""
|
|
254
|
+
self._width, self._height = os.get_terminal_size()
|
|
255
|
+
curses.resizeterm(self._height, self._width)
|
|
256
|
+
logger.debug(
|
|
257
|
+
f"_signal_win_resize: {self._width=}; {self._height=}; {curses.COLS=}; {curses.LINES=}"
|
|
258
|
+
)
|
|
259
|
+
self._adjust_idx()
|
|
260
|
+
|
|
261
|
+
cursor_state = curses.curs_set(0)
|
|
262
|
+
# visible cursor == search box -> cancel search
|
|
263
|
+
if cursor_state == 1:
|
|
264
|
+
raise EditCancelledError
|
|
265
|
+
|
|
266
|
+
self.display_menu()
|
|
267
|
+
|
|
268
|
+
def _save_state(self):
|
|
269
|
+
self._saved_state.append(
|
|
270
|
+
(self._start_idx, self._selected, self.contents.copy())
|
|
271
|
+
)
|
|
272
|
+
self._start_idx = 0
|
|
273
|
+
self._selected = 0
|
|
274
|
+
|
|
275
|
+
def _restore_state(self):
|
|
276
|
+
if len(self._saved_state) > 0:
|
|
277
|
+
self._start_idx, self._selected, self.contents = (
|
|
278
|
+
self._saved_state.pop()
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def validator(ch):
|
|
283
|
+
if ch == 27:
|
|
284
|
+
raise EditCancelledError
|
|
285
|
+
return ch
|
|
286
|
+
|
|
287
|
+
def _search(self) -> None:
|
|
288
|
+
try:
|
|
289
|
+
max_x, max_y = os.get_terminal_size()
|
|
290
|
+
except OSError:
|
|
291
|
+
max_y, max_x = self._stdscr.getmaxyx()
|
|
292
|
+
|
|
293
|
+
uly, ulx = max_y // 2, max_x // 4
|
|
294
|
+
height, width = 1, max_x // 2
|
|
295
|
+
prompt = " Search: "
|
|
296
|
+
plen = len(prompt)
|
|
297
|
+
self._stdscr.addstr(uly, ulx, prompt)
|
|
298
|
+
|
|
299
|
+
search_win = curses.newwin(height, width - plen, uly, ulx + plen)
|
|
300
|
+
rectangle(
|
|
301
|
+
self._stdscr, uly - 1, ulx - 1, uly + height, ulx + width + 1
|
|
302
|
+
)
|
|
303
|
+
search_win.clear()
|
|
304
|
+
self._stdscr.refresh()
|
|
305
|
+
|
|
306
|
+
box = Textbox(search_win)
|
|
307
|
+
curses.curs_set(1)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
box.edit(self.validator)
|
|
311
|
+
except EditCancelledError:
|
|
312
|
+
self.display_menu()
|
|
313
|
+
return
|
|
314
|
+
finally:
|
|
315
|
+
curses.curs_set(0)
|
|
316
|
+
|
|
317
|
+
search_term = box.gather()
|
|
318
|
+
result = self.menu.search(search_term)
|
|
319
|
+
logger.debug("search: %s\n%s", search_term, result)
|
|
320
|
+
|
|
321
|
+
if result:
|
|
322
|
+
self._save_state()
|
|
323
|
+
self.contents = result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main() -> None:
|
|
327
|
+
menu_items = []
|
|
328
|
+
for i in range(50):
|
|
329
|
+
menu_items.append(f"Option_{i}")
|
|
330
|
+
|
|
331
|
+
result = None
|
|
332
|
+
with CursesMenu(menu_items) as menu:
|
|
333
|
+
while not result:
|
|
334
|
+
menu.display_menu()
|
|
335
|
+
result = menu.handle_input()
|
|
336
|
+
|
|
337
|
+
print(result)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
main()
|