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))))