File: pl.py 1 #!/usr/bin/python 2 3 # The MIT License (MIT) 4 # 5 # Copyright (c) 2026 pacman64 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the "Software"), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be included in 15 # all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 # SOFTWARE. 24 25 26 from curses import ( 27 cbreak, curs_set, endwin, initscr, noecho, resetty, savetty, set_escdelay, 28 A_NORMAL, A_REVERSE, 29 ) 30 from itertools import islice 31 from os import dup2 32 from sys import argv, stderr, stdin 33 34 35 info = ''' 36 pl [options...] [title words...] 37 38 39 Pick Line is a text user-interface (TUI) to do just that. 40 41 42 Enter Quit this app, emitting the currently-selected entry 43 Escape Quit this app without emitting an entry 44 F1 Toggle help-message screen; the Escape key also quits it 45 F10 Quit this app without emitting an entry; quit the help viewer 46 F12 Quit this app without emitting an entry; quit the help viewer 47 48 Left Quit this app without emitting an entry 49 Right Quit this app, emitting the currently-selected entry 50 Backspace Quit this app without emitting an entry; quit the help viewer 51 Tab Quit this app, emitting the currently-selected entry 52 53 Home Select the first entry available 54 End Select the last entry available 55 Up Select the entry before the currently selected one 56 Down Select the entry after the currently selected one 57 Page Up Select entry by jumping one screen backward 58 Page Down Select entry by jumping one screen forward 59 60 [Other] Jump to the first/next entry which starts with that letter 61 or digit; letters are matched case-insensitively 62 63 64 Escape quits the app without emitting the currently-selected item and with 65 an error-code, while Enter emits the selected item, quitting successfully. 66 67 The right side of the screen also shows little up/down arrow symbols when 68 there are more entries before/after the ones currently showing. 69 70 All (optional) leading options start with either single or double-dash: 71 72 -h, -help show this help message 73 ''' 74 75 76 class SimpleTUI: 77 ''' 78 Manager to start/stop a no-color text user-interface (TUI), allowing for 79 standard input/output to be used normally before method `start` is called 80 and after method `stop` is called. After calling is method `start`, its 81 field `screen` has the ncurses value for all the interactive input-output. 82 ''' 83 84 def __init__(self): 85 self.screen = None 86 87 def start(self, out_fd = -1, esc_delay = -1): 88 ''' 89 Start interactive-mode: the first optional argument should be more 90 than 2, if given, since it would mess with stdio, which is precisely 91 what it's meant to avoid doing. 92 ''' 93 94 if out_fd >= 0: 95 from os import dup2 96 97 # keep original stdout as /dev/fd/... 98 dup2(1, out_fd) 99 # separate live output from final (optional) result on stdout 100 with open('/dev/tty', 'rb') as inp, open('/dev/tty', 'wb') as out: 101 dup2(inp.fileno(), 0) 102 dup2(out.fileno(), 1) 103 104 self.screen = initscr() 105 savetty() 106 noecho() 107 cbreak() 108 self.screen.keypad(True) 109 curs_set(0) 110 if esc_delay >= 0: 111 set_escdelay(esc_delay) 112 113 def stop(self): 114 'Stop interactive-mode.' 115 if self.screen: 116 resetty() 117 endwin() 118 119 120 class LineBrowserTUI: 121 ''' 122 This is a scrollable viewer to browse single-line entries. After initializing 123 it with a TUI screen value, you can configure various fields before calling 124 its method `run`: 125 - max_view_size, which limits of big (in bytes) text files can be 126 viewed/loaded; negative values disables text-viewer functionality 127 - side_step, which controls the speed of lateral side-scrolling 128 - handlers, which has all ncurses key-bindings for the viewer 129 ''' 130 131 def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 132 'Optional argument controls which ncurses keys quit the viewer.' 133 134 self.max_view_size = -1 135 self.side_step = 1 136 self.handlers = { 137 'KEY_RESIZE': lambda: self._on_resize(), 138 'KEY_UP': lambda: self._on_up(), 139 'KEY_DOWN': lambda: self._on_down(), 140 'KEY_NPAGE': lambda: self._on_page_down(), 141 'KEY_PPAGE': lambda: self._on_page_up(), 142 'KEY_HOME': lambda: self._on_home(), 143 'KEY_END': lambda: self._on_end(), 144 'KEY_LEFT': lambda: self._on_left(), 145 'KEY_RIGHT': lambda: self._on_right(), 146 } 147 if quit_set: 148 for k in quit_set: 149 self.handlers[k] = None 150 151 self._screen = screen 152 self._inner_width = 0 153 self._inner_height = 0 154 self._max_line_width = 0 155 self._pick = 0 156 self._left = 0 157 self._max_top = 0 158 self._max_left = 0 159 self.entries = tuple() 160 self._title = '' 161 self.pick = None 162 163 def run(self): 164 'Interactively view/browse entries.' 165 166 if not self.entries: 167 return ('', None) 168 else: 169 self._max_line_width = max(len(l) for l in self.entries) 170 self._on_resize() 171 172 self._title = self._fit_string(self.title)[:self._inner_width] 173 174 while True: 175 self._redraw() 176 k = self._screen.getkey() 177 if k in ('\n', '\t'): 178 pick = self.entries[self._pick] 179 self.entries = tuple() 180 return (pick, k) 181 182 if k in self.handlers: 183 h = self.handlers[k] 184 if h is None: 185 self.entries = tuple() 186 return ('', None) 187 if h() is False: 188 pick = self.entries[self._pick] 189 self.entries = tuple() 190 return (pick, None) 191 elif len(k) == 1: 192 i = self._seek(k, self._pick + 1) 193 if i < 0: 194 i = self._seek(k, 0) 195 if i >= 0: 196 self._pick = i 197 198 def _fit_string(self, s): 199 maxlen = max(self._inner_width, 0) 200 return s if len(s) <= maxlen else s[:maxlen] 201 202 def _pick_name(self, name, fallback = 0): 203 self._pick = fallback 204 for i, e in enumerate(self.entries): 205 if e == name: 206 self._pick = i 207 return 208 209 def _redraw(self): 210 entries = self.entries 211 screen = self._screen 212 iw = self._inner_width 213 ih = self._inner_height 214 215 if iw < 10 or ih < 10: 216 return 217 218 screen.erase() 219 220 if self._title: 221 screen.addstr(0, 0, self._title, iw) 222 223 start = self._pick - (self._pick % ih) 224 stop = start + ih 225 226 from math import ceil, log10 227 228 at_bottom = start >= len(entries) - ih 229 w = int(ceil(log10(len(entries)))) 230 msg = f'({self._pick + 1:>{w},} / {len(entries):,})' 231 screen.addstr(0, iw - len(msg), self._fit_string(msg)) 232 233 spaces = max(self._max_line_width, 80) * ' ' 234 235 from itertools import islice 236 237 for i, l in enumerate(islice(entries, start, stop)): 238 if not l: 239 l = spaces 240 if self._left > 0: 241 l = l[self._left:] 242 try: 243 style = A_REVERSE if i == self._pick % ih else A_NORMAL 244 screen.addnstr(i + 1, 0, l, iw, style) 245 except Exception as e: 246 # some utf-8 files have lines which upset func addstr 247 screen.addnstr(i + 1, 0, '?' * len(l), iw, style) 248 249 # show up/down arrows 250 if start > 0: 251 self._screen.addstr(1, iw - 1, '▲') 252 if at_bottom and len(entries) > 0: 253 self._screen.addstr(ih, iw - 1, '▼') 254 255 screen.refresh() 256 257 def _seek(self, k, start): 258 from itertools import islice 259 260 if len(k) != 1: 261 return -1 262 263 k = k.lower() 264 for i, s in enumerate(islice(self.entries, start, None)): 265 if s.startswith(k) or s.lower().startswith(k): 266 return start + i 267 return -1 268 269 def _on_resize(self): 270 height, width = self._screen.getmaxyx() 271 self._inner_width = width - 1 272 self._inner_height = height - 1 273 self._max_top = max(len(self.entries) - self._inner_height, 0) 274 ss = self.side_step 275 self._max_left = self._max_line_width - self._inner_width - 1 + ss 276 self._max_left = max(self._max_left, 0) 277 if self._max_left >= self._inner_width - 1 + ss: 278 self._max_left = 0 279 280 def _on_up(self): 281 self._pick = max(self._pick - 1, 0) 282 283 def _on_down(self): 284 limit = max(len(self.entries) - 1, 0) 285 self._pick = min(self._pick + 1, limit) 286 287 def _on_page_up(self): 288 self._pick = max(self._pick - self._inner_height, 0) 289 290 def _on_page_down(self): 291 limit = max(len(self.entries) - 1, 0) 292 self._pick = min(self._pick + self._inner_height, limit) 293 294 def _on_home(self): 295 self._pick = 0 296 297 def _on_end(self): 298 self._pick = max(len(self.entries) - 1, 0) 299 300 def _on_left(self): 301 self._left = max(self._left - self.side_step, 0) 302 303 def _on_right(self): 304 self._left = min(self._left + self.side_step, self._max_left) 305 306 307 class TextViewerTUI: 308 ''' 309 This is a scrollable viewer for plain-text content. After initializing it 310 with a TUI screen value, you can configure various fields, before running 311 it by calling method `run`: 312 - title, which is shown at the top in reverse-style 313 - tab_stop, which controls how tabs are turned into spaces 314 - side_step, which controls the speed of lateral side-scrolling 315 - handlers, which has all ncurses key-bindings for the viewer 316 ''' 317 318 def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 319 'Optional argument controls which ncurses keys quit the viewer.' 320 321 self.title = '' 322 self.tab_stop = 4 323 self.side_step = 1 324 self.handlers = { 325 'KEY_RESIZE': lambda: self._on_resize(), 326 'KEY_UP': lambda: self._on_up(), 327 'KEY_DOWN': lambda: self._on_down(), 328 'KEY_NPAGE': lambda: self._on_page_down(), 329 'KEY_PPAGE': lambda: self._on_page_up(), 330 'KEY_HOME': lambda: self._on_home(), 331 'KEY_END': lambda: self._on_end(), 332 'KEY_LEFT': lambda: self._on_left(), 333 'KEY_RIGHT': lambda: self._on_right(), 334 } 335 if quit_set: 336 for k in quit_set: 337 self.handlers[k] = None 338 339 self._screen = screen 340 self._inner_width = 0 341 self._inner_height = 0 342 self._max_line_width = 0 343 self._top = 0 344 self._left = 0 345 self._max_top = 0 346 self._max_left = 0 347 self._lines = tuple() 348 349 def run(self, content): 350 'Interactively view/browse the string/strings given.' 351 352 if isinstance(content, BaseException): 353 self._on_resize() 354 self._show_error(content) 355 self._screen.getkey() 356 return 357 358 ts = self.tab_stop 359 if isinstance(content, str): 360 self._lines = tuple(l.expandtabs(ts) for l in content.splitlines()) 361 else: 362 self._lines = tuple(l.expandtabs(ts) for l in content) 363 content = '' # try to deallocate a few MBs when viewing big files 364 365 if len(self._lines) == 0: 366 self._max_line_width = 0 367 else: 368 self._max_line_width = max(len(l) for l in self._lines) 369 self._on_resize() 370 371 iw = self._inner_width 372 ih = self._inner_height 373 374 if iw < 10 or ih < 10: 375 return 376 377 while True: 378 self._redraw() 379 k = self._screen.getkey() 380 if self.handlers and (k in self.handlers): 381 h = self.handlers[k] 382 if (h is None) or (h() is False): 383 self._lines = tuple() 384 return k 385 386 def _fit_string(self, s): 387 maxlen = max(self._inner_width, 0) 388 return s if len(s) <= maxlen else s[:maxlen] 389 390 def _redraw(self): 391 title = self._fit_string(self.title) 392 lines = self._lines 393 screen = self._screen 394 iw = self._inner_width 395 ih = self._inner_height 396 397 if iw < 10 or ih < 10: 398 return 399 400 screen.erase() 401 402 if title: 403 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 404 405 at_bottom = len(self._lines) - self._top <= ih 406 if at_bottom: 407 msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)' 408 else: 409 from math import ceil, log10 410 w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1 411 msg = f'({self._top + 1:>{w},} / {len(lines):,})' 412 screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE) 413 414 from itertools import islice 415 416 for i, l in enumerate(islice(lines, self._top, self._top + ih)): 417 if self._left > 0: 418 l = l[self._left:] 419 try: 420 screen.addnstr(i + 1, 0, l, iw) 421 except Exception: 422 # some utf-8 files have lines which upset func addstr 423 screen.addnstr(i + 1, 0, '?' * len(l), iw) 424 425 # show up/down arrows 426 if self._top > 0: 427 self._screen.addstr(1, iw - 1, '▲') 428 if self._top < self._max_top: 429 self._screen.addstr(ih, iw - 1, '▼') 430 431 screen.refresh() 432 433 def _show_error(self, err): 434 title = self._fit_string(self.title) 435 screen = self._screen 436 iw = self._inner_width 437 ih = self._inner_height 438 439 if iw < 10 or ih < 10: 440 return 441 442 screen.erase() 443 if title: 444 screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) 445 screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE) 446 screen.refresh() 447 448 def _on_resize(self): 449 height, width = self._screen.getmaxyx() 450 self._inner_width = width - 1 451 self._inner_height = height - 1 452 self._max_top = max(len(self._lines) - self._inner_height, 0) 453 ss = self.side_step 454 self._max_left = self._max_line_width - self._inner_width - 1 + ss 455 self._max_left = max(self._max_left, 0) 456 if self._max_left >= self._inner_width - 1 + ss: 457 self._max_left = 0 458 459 def _on_up(self): 460 self._top = max(self._top - 1, 0) 461 462 def _on_down(self): 463 self._top = min(self._top + 1, self._max_top) 464 465 def _on_page_up(self): 466 self._top = max(self._top - self._inner_height, 0) 467 468 def _on_page_down(self): 469 self._top = min(self._top + self._inner_height, self._max_top) 470 471 def _on_home(self): 472 self._top = 0 473 474 def _on_end(self): 475 self._top = self._max_top 476 477 def _on_left(self): 478 self._left = max(self._left - self.side_step, 0) 479 480 def _on_right(self): 481 self._left = min(self._left + self.side_step, self._max_left) 482 483 484 def show_help(screen): 485 quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE') 486 tv = TextViewerTUI(screen, quit_set) 487 tv.title = 'Help for Browse Folders (bf)' 488 return tv.run(info) 489 490 491 def run(title): 492 tui = None 493 quit_set = ('KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE', '\x1b') 494 495 try: 496 # can read piped input only before entering the `ui-mode` 497 text = stdin.read() 498 499 # save memory by clearing the variable holding the slurped string 500 def free_mem(res): 501 nonlocal text 502 text = '' 503 return res 504 505 tui = SimpleTUI() 506 tui.start(3, 10) 507 lb = LineBrowserTUI(tui.screen, quit_set) 508 msg = 'Pick one of these lines/entries; Enter confirms, Escape cancels' 509 lb.title = title if title else msg 510 lb.side_step = 4 511 lb.handlers['KEY_F(1)'] = lambda: show_help(tui.screen) 512 lb.handlers['KEY_BACKSPACE'] = None 513 lb.entries = free_mem(text).splitlines() 514 pick, last = lb.run() 515 except KeyboardInterrupt: 516 if tui: 517 tui.stop() 518 return 1 519 except Exception as e: 520 tui.stop() 521 # raise e 522 print(str(e), file=stderr) 523 return 1 524 525 tui.stop() 526 dup2(3, 1) 527 528 if last is None or last in quit_set: 529 return 1 530 531 if isinstance(pick, str): 532 print(pick) 533 return 0 534 535 for e in pick: 536 print(e) 537 return 0 538 539 540 def show_help(screen): 541 quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)') 542 h = TextViewerTUI(screen, quit_set) 543 h.title = 'Help for Browse Folders (bf)' 544 return h.run(info) != '\x1b' 545 546 547 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): 548 print(info.strip()) 549 exit(0) 550 551 # avoid func curses.wrapper, since it calls func curses.start_color, which in 552 # turn forces a black background no matter the terminal configuration 553 554 skip = 2 if len(argv) > 1 and argv[1] == '--' else 1 555 exit(run(' '.join(islice(argv, skip, None))))