#!/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 json import dumps, load, loads from os import dup2 from sys import argv, stderr, stdin info = ''' bj [options...] [file...] Browse Json is a text user-interface (TUI) which lets interactively pick/zoom JSON input data. Picking anything is optional, so the final result on stdout is either nothing or valid JSON, assuming the input is valid JSON. All (optional) leading options start with either single or double-dash: -c, -compact, -json0 emit compact JSON output -d, -default [json] default JSON string, when not picking anything -f, -fallback [json] fallback JSON string, same as `-d`, or `-default` -h, -help show this help message -v, -verbose also show gron-style path on the standard error You can also view this help message by pressing the F1 key while browsing folders: you can exit the help viewer via any of F1, F10, F12, or Escape. ''' 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 ValueBrowserTUI: ''' This is a scrollable viewer to browse JSON fields. After initializing it with a TUI screen value, you can call its method `run`. ''' def __init__(self, screen, quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b')): 'Optional argument controls which ncurses keys quit the viewer.' self.data = None 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(), 'KEY_BACKSPACE': lambda: self._on_left(), '\t': 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._max_top = 0 self._max_left = 0 self._path = [] self._stack = [] self._values = [] self.pick = None def run(self): 'Interactively view/browse the fields/items in the value given.' self._path = [] self._stack = [] self._values = [] res = self._run() self.data = None self._path = [] self._stack = [] self._values = [] return res def _run(self): self._on_resize() self._cache_entries() while True: self._redraw() k = self._screen.getkey() if k == '\n': if len(self._stack) == 0: return ('data', self.data, k) _, pick = self._get() from io import StringIO sb = StringIO() self._gron(sb) return (sb.getvalue(), pick, k) if k in self.handlers: h = self.handlers[k] if h is None or h() is False: return (None, None, 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 _is_ident(self, s: str): if len(s) == 0 or '0' <= s[0] <= '9': return False return all(e.isalnum() or e == '_' for e in s) def _gron(self, sb): from itertools import islice from json import dumps sb.write('data') for k in islice(self._path, 1, None): if k is None: continue if isinstance(k, int): sb.write('[') sb.write(str(k)) sb.write(']') else: if self._is_ident(k): sb.write('.') sb.write(k) else: sb.write('[') sb.write(dumps(k)) sb.write(']') def _redraw(self): screen = self._screen iw = self._inner_width ih = self._inner_height if iw < 10 or ih < 10: return from io import StringIO sb = StringIO() if self.title: sb.write(self.title) sb.write(': ') self._gron(sb) if len(self._stack) > 1: title = sb.getvalue() elif self.title: title = f'{self.title}: {self._describe_type(self.data)}' else: title = self._describe_type(self.data) screen.erase() if not isinstance(self.data, (dict, list, tuple)): if title: screen.addstr(0, 0, f'{title:<{iw}}') screen.addstr(1, 2, self._values[0], A_REVERSE) screen.refresh() return if title: style = A_NORMAL if len(self._stack) > 0 else A_REVERSE screen.addstr(0, 0, f'{title:<{iw}}', style) if len(self._stack) == 0: screen.refresh() return end = self._current_value() n = max(self._count_entries(), 1) start = self._pick - (self._pick % ih) stop = min(start + ih, n) if isinstance(end, (list, tuple)): n = len(end) if n > 0: from math import ceil, log10 w = int(ceil(log10(n - 1))) if n > 1 else 1 msg = f'({self._pick:>{w},} < {n:,})' style = A_NORMAL else: style = A_REVERSE msg = '[]' screen.addstr(0, iw - len(msg), self._fit_string(msg), style) self._redraw_array_entries(end) elif isinstance(end, dict): n = len(end) msg = f'({n:,} keys)' if n > 0 else '{}' style = A_NORMAL if n > 0 else A_REVERSE screen.addstr(0, iw - len(msg), self._fit_string(msg), style) self._redraw_object_entries(end) else: self._redraw_value(end) # show up/down arrows if start > 0 and n > 0: self._screen.addstr(1, iw - 1, '▲') if stop < n and n > 0: self._screen.addstr(ih, iw - 1, '▼') screen.refresh() def _redraw_array_entries(self, data, indent=2): screen = self._screen iw = self._inner_width ih = self._inner_height start = self._pick - (self._pick % ih) stop = min(start + ih, len(data)) for i in range(start, stop): j = i - start try: style = A_REVERSE if j == self._pick % ih else A_NORMAL s = self._values[j] screen.addnstr(j + 1, indent, s, iw - indent, style) except Exception as _: # some utf-8 files have lines which upset func addstr screen.addnstr(j + 1, 0, '?' * 20, iw, style) def _redraw_object_entries(self, data, indent=2): from itertools import islice screen = self._screen iw = self._inner_width ih = self._inner_height start = self._pick - (self._pick % ih) stop = min(start + ih, len(data)) maxw = 0 for k in islice(data.keys(), start, stop): maxw = max(maxw, len(k)) for i, k in enumerate(islice(data.keys(), start, stop), start): j = i - start try: style = A_REVERSE if j == self._pick % ih else A_NORMAL screen.addnstr(j + 1, indent, k, iw - indent, A_NORMAL) v = self._values[j] x = indent + maxw + 2 screen.addnstr(j + 1, x, v, iw - x, style) except Exception as _: # some utf-8 files have lines which upset func addstr screen.addnstr(j + 1, 0, '?' * 20, iw, style) def _redraw_value(self, data, indent=2): screen = self._screen iw = self._inner_width style = A_REVERSE try: screen.addnstr(1, indent, self._values[0], iw - indent, style) except Exception as _: # some utf-8 files have lines which upset func addstr screen.addnstr(1, 0, '?' * 20, iw) def _cache_entries(self): from itertools import islice iw = self._inner_width ih = self._inner_height end = self._current_value() if len(self._stack) == 0 and len(self._values) == 0: self._values = tuple([self._json0(self.data)[:self._inner_width]]) return if isinstance(end, (list, tuple)): start = self._pick - (self._pick % ih) stop = min(start + ih, len(end)) self._values = tuple( self._json0(e)[:iw] for e in islice(end, start, stop) ) elif isinstance(end, dict): start = self._pick - (self._pick % ih) stop = min(start + ih, len(end)) self._values = tuple( self._json0(v)[:iw] for v in islice(end.values(), start, stop) ) else: self._values = tuple([self._json0(end)[:iw]]) def _change_selection(self, new): new = max(new, 0) limit = max(self._count_entries() - 1, 0) new = min(new, limit) if not self._in_page(new): self._pick = new self._cache_entries() else: self._pick = new def _in_page(self, new): end = self._current_value() if isinstance(end, (dict, list, tuple)): ih = self._inner_height start = self._pick - (self._pick % ih) stop = min(start + ih, len(end)) return start <= new < stop else: return new == 0 def _count_entries(self): end = self._current_value() n = len(end) if isinstance(end, (dict, list, tuple)) else -1 return n def _current_value(self): return self._stack[-1] if len(self._stack) > 0 else self.data def _describe_type(self, data): kind = { bool: 'boolean', int: 'number', float: 'number', str: 'string', list: 'array', tuple: 'array', dict: 'object', }.get(type(data), '?') return f'{kind} ({len(data)})' if kind in ('array', 'object') else kind def _json0(self, data): from json import dumps return dumps(data, indent=None, separators=(', ', ': '), allow_nan=False, check_circular=False) def _seek(self, k, start): from itertools import islice end = self._current_value() if not isinstance(end, dict): return -1 if len(k) != 1: return -1 k = k.lower() for i, e in enumerate(islice(end.keys(), start, None)): name = e[0] if name.startswith(k) or name.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 n = max(self._count_entries(), 1) self._max_top = max(n - 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 self._cache_entries() def _on_up(self): if len(self._stack) == 0: self._on_right() else: self._change_selection(self._pick - 1) def _on_down(self): if len(self._stack) == 0: self._on_right() else: self._change_selection(self._pick + 1) def _on_page_up(self): if len(self._stack) == 0: self._on_right() else: self._change_selection(self._pick - self._inner_height) def _on_page_down(self): if len(self._stack) == 0: self._on_right() else: self._change_selection(self._pick + self._inner_height) def _on_home(self): if len(self._stack) == 0: self._on_right() else: self._change_selection(0) def _on_end(self): if len(self._stack) == 0: self._on_right() else: self._change_selection(self._count_entries() - 1) def _on_left(self): if len(self._stack) > 0: pick = self._path.pop() self._stack.pop() self._pick = 0 if isinstance(pick, int): self._pick = pick elif len(self._stack) > 0: end = self._stack[-1] for i, k in enumerate(end.keys()): if k == pick: self._pick = i break self._cache_entries() def _on_right(self): if len(self._stack) == 0: self._path.append(None) self._stack.append(self.data) self._pick = 0 self._cache_entries() return end = self._current_value() if isinstance(end, (dict, list, tuple)): k, v = self._get() self._path.append(k) self._stack.append(v) self._pick = 0 self._cache_entries() def _get(self): end = self._current_value() if len(self._stack) == 0: return (None, end) if isinstance(end, (list, tuple)): i = self._pick return (i, end[i]) if 0 <= i < len(end) else (None, None) if isinstance(end, dict): for i, k in enumerate(end.keys()): if i == self._pick: return (k, end[k]) return (None, None) return (0, end) 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 n = max(len(lines) - 1, 0) w = int(ceil(log10(n))) if n > 0 else 1 msg = f'({self._top + 1:>{w},} / {n:,})' 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) 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 browse_file(name, screen): tv = TextViewerTUI(screen) tv.title = name tv.side_step = 4 tv.handlers['KEY_F(1)'] = lambda: show_help(screen) tv.handlers['kLFT5'] = None tv.handlers['KEY_BACKSPACE'] = None return tv.run(slurp(name)) def run(name, fallback, verbose, compact): try: tui = None # can read piped input only before entering the `ui-mode` if name == '-': data = loads(stdin.read()) else: with open(name, 'r') as inp: data = load(inp) tui = SimpleTUI() tui.start(3, 10) vb = ValueBrowserTUI(tui.screen) vb.title = '' if name == '-' else name vb.side_step = 4 vb.handlers['KEY_F(1)'] = lambda: show_help(tui.screen) vb.data = data path, pick, last = vb.run() tui.stop() dup2(3, 1) indent = None if compact else 2 seps = (',', ':' if compact else ': ') if last is None or last in ('\x1b', 'KEY_F(10)', 'KEY_F(12)'): if not fallback: return 1 pick = loads(fallback) verbose = False if verbose: print(path, file=stderr) print(dumps(pick, indent=indent, separators=seps, allow_nan=False, check_circular=False)) return 0 except KeyboardInterrupt: if tui: tui.stop() dup2(3, 1) return 1 except Exception as e: if tui: tui.stop() dup2(3, 1) # raise e print(str(e), file=stderr) return 1 def show_help(screen): # quit_set = ('KEY_F(10)', 'KEY_F(12)', '\x1b', 'KEY_F(1)') quit_set = ('\x1b', 'KEY_F(1)', 'KEY_F(10)', 'KEY_F(12)', 'KEY_BACKSPACE') h = TextViewerTUI(screen, quit_set) h.title = 'Help for Browse JSON (bj)' return h.run(info) != '\x1b' def slurp(name): try: with open(name, 'r') as inp: return inp.read() except Exception as e: return e if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'): print(info.strip()) exit(0) compact_output_opts = ( '-c', '--c', '-compact', '--compact', '-j0', '--j0', '-json0', '--json0', ) fallback_opts = ( '-d', '--d', '-f', '--f', '-fallback', '--fallback', '-default', '--default', ) verbose_output_opts = ( '-v', '--v', '-verbose', '--verbose', '-gron-path', '--gron-path', ) args = argv[1:] compact = False verbose = False fallback = '' while len(args) > 0: if args[0] in compact_output_opts: compact = True args = args[1:] continue if args[0] in fallback_opts: if len(args) < 2: print('forgot the JSON fallback value', file=stderr) exit(1) try: fallback = args[1] loads(fallback) except Exception as e: print(str(e), file=stderr) exit(1) args = args[2:] continue if args[0] in verbose_output_opts: verbose = True args = args[1:] continue break if len(args) > 0 and args[0] == '--': args = args[1:] name = args[0] if len(args) > 0 else '-' if len(args) > 1: msg = 'there can only be one (optional) filename argument' print(msg, file=stderr) exit(4) # avoid func curses.wrapper, since it calls func curses.start_color, which in # turn forces a black background no matter the terminal configuration exit(run(name, fallback, verbose, compact))