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