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.
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()
@@ -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()