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