#!/usr/bin/python # The MIT License (MIT) # # Copyright (c) 2026 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from curses import ( cbreak, curs_set, endwin, initscr, noecho, resetty, savetty, set_escdelay, A_NORMAL, A_REVERSE, ) from itertools import islice from os import dup2 from sys import argv, stderr, stdin info = ''' pl [options...] [title words...] Pick Line is a text user-interface (TUI) to do just that. Enter Quit this app, emitting the currently-selected entry Escape Quit this app without emitting an entry F1 Toggle help-message screen; the Escape key also quits it F10 Quit this app without emitting an entry; quit the help viewer F12 Quit this app without emitting an entry; quit the help viewer Left Quit this app without emitting an entry Right Quit this app, emitting the currently-selected entry Backspace Quit this app without emitting an entry; quit the help viewer Tab Quit this app, emitting the currently-selected entry Home Select the first entry available End Select the last entry available Up Select the entry before the currently selected one Down Select the entry after the currently selected one Page Up Select entry by jumping one screen backward Page Down Select entry by jumping one screen forward [Other] Jump to the first/next entry which starts with that letter or digit; letters are matched case-insensitively Escape quits the app without emitting the currently-selected item and with an error-code, while Enter emits the selected item, quitting successfully. The right side of the screen also shows little up/down arrow symbols when there are more entries before/after the ones currently showing. All (optional) leading options start with either single or double-dash: -h, -help show this help message ''' class SimpleTUI: ''' Manager to start/stop a no-color text user-interface (TUI), allowing for standard input/output to be used normally before method `start` is called and after method `stop` is called. After calling is method `start`, its field `screen` has the ncurses value for all the interactive input-output. ''' def __init__(self): self.screen = None def start(self, out_fd = -1, esc_delay = -1): ''' Start interactive-mode: the first optional argument should be more than 2, if given, since it would mess with stdio, which is precisely what it's meant to avoid doing. ''' if out_fd >= 0: from os import dup2 # keep original stdout as /dev/fd/... dup2(1, out_fd) # separate live output from final (optional) result on stdout with open('/dev/tty', 'rb') as inp, open('/dev/tty', 'wb') as out: dup2(inp.fileno(), 0) dup2(out.fileno(), 1) self.screen = initscr() savetty() noecho() cbreak() self.screen.keypad(True) curs_set(0) if esc_delay >= 0: set_escdelay(esc_delay) def stop(self): 'Stop interactive-mode.' if self.screen: resetty() endwin() class LineBrowserTUI: ''' This is a scrollable viewer to browse single-line entries. After initializing it with a TUI screen value, you can configure various fields before calling its method `run`: - max_view_size, which limits of big (in bytes) text files can be viewed/loaded; negative values disables text-viewer functionality - side_step, which controls the speed of lateral side-scrolling - handlers, which has all ncurses key-bindings for the viewer ''' def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 'Optional argument controls which ncurses keys quit the viewer.' self.max_view_size = -1 self.side_step = 1 self.handlers = { 'KEY_RESIZE': lambda: self._on_resize(), 'KEY_UP': lambda: self._on_up(), 'KEY_DOWN': lambda: self._on_down(), 'KEY_NPAGE': lambda: self._on_page_down(), 'KEY_PPAGE': lambda: self._on_page_up(), 'KEY_HOME': lambda: self._on_home(), 'KEY_END': lambda: self._on_end(), 'KEY_LEFT': lambda: self._on_left(), 'KEY_RIGHT': lambda: self._on_right(), } if quit_set: for k in quit_set: self.handlers[k] = None self._screen = screen self._inner_width = 0 self._inner_height = 0 self._max_line_width = 0 self._pick = 0 self._left = 0 self._max_top = 0 self._max_left = 0 self.entries = tuple() self._title = '' self.pick = None def run(self): 'Interactively view/browse entries.' if not self.entries: return ('', None) else: self._max_line_width = max(len(l) for l in self.entries) self._on_resize() self._title = self._fit_string(self.title)[:self._inner_width] while True: self._redraw() k = self._screen.getkey() if k in ('\n', '\t'): pick = self.entries[self._pick] self.entries = tuple() return (pick, k) if k in self.handlers: h = self.handlers[k] if h is None: self.entries = tuple() return ('', None) if h() is False: pick = self.entries[self._pick] self.entries = tuple() return (pick, None) elif len(k) == 1: i = self._seek(k, self._pick + 1) if i < 0: i = self._seek(k, 0) if i >= 0: self._pick = i def _fit_string(self, s): maxlen = max(self._inner_width, 0) return s if len(s) <= maxlen else s[:maxlen] def _pick_name(self, name, fallback = 0): self._pick = fallback for i, e in enumerate(self.entries): if e == name: self._pick = i return def _redraw(self): entries = self.entries screen = self._screen iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return screen.erase() if self._title: screen.addstr(0, 0, self._title, iw) start = self._pick - (self._pick % ih) stop = start + ih from math import ceil, log10 at_bottom = start >= len(entries) - ih w = int(ceil(log10(len(entries)))) msg = f'({self._pick + 1:>{w},} / {len(entries):,})' screen.addstr(0, iw - len(msg), self._fit_string(msg)) spaces = max(self._max_line_width, 80) * ' ' from itertools import islice for i, l in enumerate(islice(entries, start, stop)): if not l: l = spaces if self._left > 0: l = l[self._left:] try: style = A_REVERSE if i == self._pick % ih else A_NORMAL screen.addnstr(i + 1, 0, l, iw, style) except Exception as e: # some utf-8 files have lines which upset func addstr screen.addnstr(i + 1, 0, '?' * len(l), iw, style) # show up/down arrows if start > 0: self._screen.addstr(1, iw - 1, '▲') if at_bottom and len(entries) > 0: self._screen.addstr(ih, iw - 1, '▼') screen.refresh() def _seek(self, k, start): from itertools import islice if len(k) != 1: return -1 k = k.lower() for i, s in enumerate(islice(self.entries, start, None)): if s.startswith(k) or s.lower().startswith(k): return start + i return -1 def _on_resize(self): height, width = self._screen.getmaxyx() self._inner_width = width - 1 self._inner_height = height - 1 self._max_top = max(len(self.entries) - self._inner_height, 0) ss = self.side_step self._max_left = self._max_line_width - self._inner_width - 1 + ss self._max_left = max(self._max_left, 0) if self._max_left >= self._inner_width - 1 + ss: self._max_left = 0 def _on_up(self): self._pick = max(self._pick - 1, 0) def _on_down(self): limit = max(len(self.entries) - 1, 0) self._pick = min(self._pick + 1, limit) def _on_page_up(self): self._pick = max(self._pick - self._inner_height, 0) def _on_page_down(self): limit = max(len(self.entries) - 1, 0) self._pick = min(self._pick + self._inner_height, limit) def _on_home(self): self._pick = 0 def _on_end(self): self._pick = max(len(self.entries) - 1, 0) def _on_left(self): self._left = max(self._left - self.side_step, 0) def _on_right(self): self._left = min(self._left + self.side_step, self._max_left) class TextViewerTUI: ''' This is a scrollable viewer for plain-text content. After initializing it with a TUI screen value, you can configure various fields, before running it by calling method `run`: - title, which is shown at the top in reverse-style - tab_stop, which controls how tabs are turned into spaces - side_step, which controls the speed of lateral side-scrolling - handlers, which has all ncurses key-bindings for the viewer ''' def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 'Optional argument controls which ncurses keys quit the viewer.' self.title = '' self.tab_stop = 4 self.side_step = 1 self.handlers = { 'KEY_RESIZE': lambda: self._on_resize(), 'KEY_UP': lambda: self._on_up(), 'KEY_DOWN': lambda: self._on_down(), 'KEY_NPAGE': lambda: self._on_page_down(), 'KEY_PPAGE': lambda: self._on_page_up(), 'KEY_HOME': lambda: self._on_home(), 'KEY_END': lambda: self._on_end(), 'KEY_LEFT': lambda: self._on_left(), 'KEY_RIGHT': lambda: self._on_right(), } if quit_set: for k in quit_set: self.handlers[k] = None self._screen = screen self._inner_width = 0 self._inner_height = 0 self._max_line_width = 0 self._top = 0 self._left = 0 self._max_top = 0 self._max_left = 0 self._lines = tuple() def run(self, content): 'Interactively view/browse the string/strings given.' if isinstance(content, BaseException): self._on_resize() self._show_error(content) self._screen.getkey() return ts = self.tab_stop if isinstance(content, str): self._lines = tuple(l.expandtabs(ts) for l in content.splitlines()) else: self._lines = tuple(l.expandtabs(ts) for l in content) content = '' # try to deallocate a few MBs when viewing big files if len(self._lines) == 0: self._max_line_width = 0 else: self._max_line_width = max(len(l) for l in self._lines) self._on_resize() iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return while True: self._redraw() k = self._screen.getkey() if self.handlers and (k in self.handlers): h = self.handlers[k] if (h is None) or (h() is False): self._lines = tuple() return k def _fit_string(self, s): maxlen = max(self._inner_width, 0) return s if len(s) <= maxlen else s[:maxlen] def _redraw(self): title = self._fit_string(self.title) lines = self._lines screen = self._screen iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return screen.erase() if title: screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) at_bottom = len(self._lines) - self._top <= ih if at_bottom: msg = f'END ({len(lines):,})' if len(lines) > 0 else '(empty)' else: from math import ceil, log10 w = int(ceil(log10(len(lines)))) if len(lines) > 0 else 1 msg = f'({self._top + 1:>{w},} / {len(lines):,})' screen.addstr(0, iw - len(msg), self._fit_string(msg), A_REVERSE) from itertools import islice for i, l in enumerate(islice(lines, self._top, self._top + ih)): if self._left > 0: l = l[self._left:] try: screen.addnstr(i + 1, 0, l, iw) except Exception: # some utf-8 files have lines which upset func addstr screen.addnstr(i + 1, 0, '?' * len(l), iw) # show up/down arrows if self._top > 0: self._screen.addstr(1, iw - 1, '▲') if self._top < self._max_top: self._screen.addstr(ih, iw - 1, '▼') screen.refresh() def _show_error(self, err): title = self._fit_string(self.title) screen = self._screen iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return screen.erase() if title: screen.addstr(0, 0, f'{title:<{iw}}', A_REVERSE) screen.addstr(2, 0, self._fit_string(str(err)), A_REVERSE) screen.refresh() def _on_resize(self): height, width = self._screen.getmaxyx() self._inner_width = width - 1 self._inner_height = height - 1 self._max_top = max(len(self._lines) - self._inner_height, 0) ss = self.side_step self._max_left = self._max_line_width - self._inner_width - 1 + ss self._max_left = max(self._max_left, 0) if self._max_left >= self._inner_width - 1 + ss: self._max_left = 0 def _on_up(self): self._top = max(self._top - 1, 0) def _on_down(self): self._top = min(self._top + 1, self._max_top) def _on_page_up(self): self._top = max(self._top - self._inner_height, 0) def _on_page_down(self): self._top = min(self._top + self._inner_height, self._max_top) def _on_home(self): self._top = 0 def _on_end(self): self._top = self._max_top def _on_left(self): self._left = max(self._left - self.side_step, 0) def _on_right(self): self._left = min(self._left + self.side_step, self._max_left) def show_help(screen): quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE') tv = TextViewerTUI(screen, quit_set) tv.title = 'Help for Browse Folders (bf)' return tv.run(info) def run(title): tui = None quit_set = ('KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE', '\x1b') try: # can read piped input only before entering the `ui-mode` text = stdin.read() # save memory by clearing the variable holding the slurped string def free_mem(res): nonlocal text text = '' return res tui = SimpleTUI() tui.start(3, 10) lb = LineBrowserTUI(tui.screen, quit_set) msg = 'Pick one of these lines/entries; Enter confirms, Escape cancels' lb.title = title if title else msg lb.side_step = 4 lb.handlers['KEY_F(1)'] = lambda: show_help(tui.screen) lb.handlers['KEY_BACKSPACE'] = None lb.entries = free_mem(text).splitlines() pick, last = lb.run() except KeyboardInterrupt: if tui: tui.stop() return 1 except Exception as e: tui.stop() # raise e print(str(e), file=stderr) return 1 tui.stop() dup2(3, 1) if last is None or last in quit_set: return 1 if isinstance(pick, str): print(pick) return 0 for e in pick: print(e) return 0 def show_help(screen): quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)') h = TextViewerTUI(screen, quit_set) h.title = 'Help for Browse Folders (bf)' return h.run(info) != '\x1b' if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): print(info.strip()) exit(0) # avoid func curses.wrapper, since it calls func curses.start_color, which in # turn forces a black background no matter the terminal configuration skip = 2 if len(argv) > 1 and argv[1] == '--' else 1 exit(run(' '.join(islice(argv, skip, None))))