backpack-backbone 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {backpack_backbone-0.3.0/backpack_backbone.egg-info → backpack_backbone-0.3.1}/PKG-INFO +1 -1
  2. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/audio.py +6 -6
  3. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/chrome.py +11 -9
  4. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/core.py +255 -38
  5. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/lists.py +27 -15
  6. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/text.py +56 -25
  7. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/ui.py +11 -3
  8. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1/backpack_backbone.egg-info}/PKG-INFO +1 -1
  9. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/pyproject.toml +1 -1
  10. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_boxes.py +158 -9
  11. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_hint_clicks.py +1 -1
  12. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_keys.py +3 -3
  13. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_overlay.py +49 -0
  14. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_tabs.py +14 -4
  15. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/LICENSE +0 -0
  16. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/README.md +0 -0
  17. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/__init__.py +0 -0
  18. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/app.py +0 -0
  19. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/datetime_parse.py +0 -0
  20. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/deps.py +0 -0
  21. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/files.py +0 -0
  22. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/keyboard.py +0 -0
  23. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/keys.py +0 -0
  24. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/log.py +0 -0
  25. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/nav.py +0 -0
  26. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/notify.py +0 -0
  27. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/numbering.py +0 -0
  28. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/output.py +0 -0
  29. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/procs.py +0 -0
  30. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/__init__.py +0 -0
  31. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/dates.py +0 -0
  32. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/keymap.py +0 -0
  33. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/list_edit.py +0 -0
  34. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/timezone.py +0 -0
  35. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/prompt/values.py +0 -0
  36. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/terminal_input.py +0 -0
  37. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backbone/timefmt.py +0 -0
  38. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backpack_backbone.egg-info/SOURCES.txt +0 -0
  39. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backpack_backbone.egg-info/dependency_links.txt +0 -0
  40. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/backpack_backbone.egg-info/top_level.txt +0 -0
  41. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/setup.cfg +0 -0
  42. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_datetime_parse.py +0 -0
  43. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_deps.py +0 -0
  44. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_edit_line.py +0 -0
  45. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_list_edit_render.py +0 -0
  46. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_log.py +0 -0
  47. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_multiline.py +0 -0
  48. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_procs.py +0 -0
  49. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_quietly.py +0 -0
  50. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_select_move.py +0 -0
  51. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_terminal_input.py +0 -0
  52. {backpack_backbone-0.3.0 → backpack_backbone-0.3.1}/tests/test_ui_progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backpack-backbone
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Shared code for the back* tools: terminal UI, prompt widgets, live views, logging, dates, settings plumbing and small file helpers, implemented once.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/RedEraRrow/backbone
@@ -67,12 +67,12 @@ keys.define("levels", "Volume and equaliser", [
67
67
  ("up_big", ("PGUP",), "raise by 3"),
68
68
  ("down_big", ("PGDN",), "lower by 3"),
69
69
  ("zero", ("0",), "back to zero"),
70
- ("prev_band", ("LEFT",), "previous band (equaliser)"),
71
- ("next_band", ("RIGHT",), "next band (equaliser)"),
72
- ("add", ("a", "A"), "add a band (equaliser)"),
73
- ("delete", ("d", "D", "BACKSPACE", "DELETE"), "delete the band (equaliser)"),
74
- ("flat", ("f", "F"), "flatten every band (equaliser)"),
75
- ("preset", ("p", "P"), "next preset (equaliser)"),
70
+ ("prev_band", ("LEFT",), "previous band"),
71
+ ("next_band", ("RIGHT",), "next band"),
72
+ ("add", ("a", "A"), "add a band"),
73
+ ("delete", ("d", "D", "BACKSPACE", "DELETE"), "delete the band"),
74
+ ("flat", ("f", "F"), "flatten every band"),
75
+ ("preset", ("p", "P"), "next preset"),
76
76
  ("save", ("ENTER",), "save"),
77
77
  ("back", ("ESC",), "back"),
78
78
  ], within=("list", "global"))
@@ -79,20 +79,22 @@ _command_line = None
79
79
 
80
80
 
81
81
  def set_command_line(fn) -> None:
82
- """Register a ``callable()`` that `:` opens, where a screen leaves it free.
83
- It returns whether it opened a screen of its own (the screen under it then
84
- needs drawing again)."""
82
+ """Register a ``callable(around=None)`` that `:` opens, where a screen
83
+ leaves it free. It returns whether it opened a screen of its own (the
84
+ screen under it then needs drawing again), opening it inside `around()`
85
+ when given."""
85
86
  global _command_line
86
87
  _command_line = fn
87
88
 
88
89
 
89
- def open_command_line() -> bool | None:
90
- """Run the registered command line: whether it opened a screen of its own;
91
- None when there is none to run here (none registered, or a screen that
92
- keeps you in it, nav.modal())."""
90
+ def open_command_line(around=None) -> bool | None:
91
+ """Run the registered command line: whether it opened a screen of its own
92
+ (inside the context `around()`, when given: the player steps out of view
93
+ only for that); None when there is none to run here (none registered, or
94
+ a screen that keeps you in it, nav.modal())."""
93
95
  if _command_line is None or nav.is_modal():
94
96
  return None
95
- return bool(_command_line())
97
+ return bool(_command_line(around=around) if around else _command_line())
96
98
 
97
99
 
98
100
  # --- shared widget chrome -------------------------------------------------
@@ -142,7 +144,7 @@ def append_chrome(out: list, pairs, cells: dict, *, extra: str = "",
142
144
 
143
145
  The bar is empty unless hints are switched on; either way the top line
144
146
  (`out[0]`) carries the corner toggle. `help_key`: this screen leaves `?` free,
145
- so `?` toggles and the corner says so; otherwise it names Ctrl-/.
147
+ so `?` toggles and the corner says so; otherwise it names Ctrl-/ (or Ctrl-G).
146
148
  """
147
149
  items = chrome_hint_pairs(pairs)
148
150
  hint_lines = _hint(*items, extra=extra).splitlines()
@@ -8,7 +8,7 @@ import textwrap
8
8
  import time
9
9
  import select as _sel
10
10
  from dataclasses import dataclass, field
11
- from typing import Any
11
+ from typing import Any, Callable
12
12
 
13
13
  from backbone import keys, nav, ui
14
14
  from backbone.log import log, enabled as _logging, quietly
@@ -110,6 +110,7 @@ def screen_invalidate() -> None:
110
110
  _screen_size[0] = size
111
111
  _reserved.clear() # a frame after this reserves afresh
112
112
  _shown_pictures.clear() # a clear took them with it
113
+ _picture_areas.clear()
113
114
  _float.clear()
114
115
  _screen.clear()
115
116
  _cells.clear()
@@ -140,6 +141,7 @@ def _resize_wipe() -> str:
140
141
  _screen.clear()
141
142
  _cells.clear()
142
143
  _shown_pictures.clear()
144
+ _float.clear() # the wipe takes a floating box with it; it isn't drawn back at the old place
143
145
  return "\033[H\033[2J" if was is not None else ""
144
146
 
145
147
 
@@ -301,7 +303,8 @@ def _notched(cells: list, notch: tuple) -> list:
301
303
  def _title_right(cells: list, left: int, right: int, after: int) -> None:
302
304
  """Move a box's top-border title (" Title ", just after its corner) to
303
305
  the right end of the border, before what's already there (a subtitle,
304
- the help toggle), past column `after`; drop it if it won't fit."""
306
+ the help toggle), past column `after`: shortened by whole " · " parts
307
+ where it won't fit, and dropped where even its first won't."""
305
308
  if left + 2 >= right or cells[left + 1][1] not in _EDGE or cells[left + 2][1] != " ":
306
309
  return
307
310
  end = next((i for i in range(left + 3, right) if cells[i][1] in _EDGE and cells[i - 1][1] == " "), None)
@@ -312,11 +315,10 @@ def _title_right(cells: list, left: int, right: int, after: int) -> None:
312
315
  cells[i] = dash
313
316
  stop = next((i for i in range(end, right) if cells[i][1] not in _EDGE), right) - 1
314
317
  room = stop - (after + 2) # columns between the tab and what's at the right
315
- if len(title) > room >= 8: # shortened, rather than lost: " Tag ID (e.g. …"
316
- title = title[:room - 2] + [(title[room - 3][0], "…"), title[-1]]
317
- while title[-3][1] == "": # never half a wide character before the …
318
- title[-3:-2] = []
319
- title.insert(-2, (title[-2][0], " "))
318
+ if len(title) > room: # never cut short: whole " · " parts off the end, else none
319
+ cuts = [i for i in range(len(title) - 2, 0, -1)
320
+ if (title[i - 1][1], title[i][1], title[i + 1][1]) == (" ", "·", " ") and i <= room]
321
+ title = title[:cuts[0]] if cuts else [] # " Queue · 1 of 28 " → " Queue "
320
322
  start = stop - len(title)
321
323
  if start > after + 1:
322
324
  cells[start:stop] = title
@@ -356,6 +358,9 @@ def screen_span_paint(row: int, col: int, text: str) -> str:
356
358
  before = list(new)
357
359
  if old is None: # unknown row: the span's cells all written
358
360
  before = [_UNKNOWN] * len(new)
361
+ for first, last in _picture_cols(row): # an image is there, whatever the cells say:
362
+ for k in range(max(first, col) - 1, min(last, col - 1 + len(span))):
363
+ before[k] = _UNKNOWN # write them all, or it shows through the blanks
359
364
  new[col - 1:col - 1 + len(span)] = span
360
365
  _cells[row] = new
361
366
  _screen[row] = _screen.get(row, "") + "\x00span" # the row's text no longer says it all
@@ -507,14 +512,67 @@ def screen_float(lines: list, seconds: float) -> None:
507
512
  cols, rows = ui.get_terminal_size()
508
513
  lines = [ui.clip_ansi(ln, cols) for ln in lines[:rows]]
509
514
  w = max((ui.visual_len(ln) for ln in lines), default=0)
510
- place = dict(w=w, top=max(1, (rows - len(lines)) // 2 + 1), left=max(1, (cols - w) // 2 + 1))
511
- if _float and (len(_float['lines']), _float['w'], _float['top'], _float['left']) != (
515
+ place = dict(w=w, top=max(1, (rows - len(lines)) // 2 + 1), left=max(1, (cols - w) // 2 + 1), size=(cols, rows))
516
+ if _float and _float['size'] == (cols, rows) and (len(_float['lines']), _float['w'], _float['top'], _float['left']) != (
512
517
  len(lines), place['w'], place['top'], place['left']):
513
- _float_gone() # a new shape: put back what the old one covered first
518
+ _float_gone() # a new shape: put back what the old one covered (not at a new size: those cells are gone)
514
519
  _float.update(lines=lines, until=time.time() + seconds, **place)
515
520
  _float_draw()
516
521
 
517
522
 
523
+ _backdrops: list = [] # the screens showing, innermost last: their redraw, and how they meet a small window
524
+
525
+
526
+ def screen_backdrop(redraw, small: bool = False) -> Callable[[], None]:
527
+ """Say the screen now showing is this one, till the returned call (for
528
+ its `finally`) takes it back. `redraw()` lays it out afresh at the current
529
+ size, what an overlay over it calls on a resize; None: it notices a new
530
+ size itself (asked to with ui.request_relayout). `small`: it lays out a
531
+ window too small for boxes itself (the player, the miniplayer), so no
532
+ miniplayer takes over from it."""
533
+ entry = {'redraw': redraw, 'small': small, 'overlay': False}
534
+ _backdrops.append(entry)
535
+ return lambda: _backdrops.remove(entry) if entry in _backdrops else None
536
+
537
+
538
+ def overlay_layer() -> Callable[[], None]:
539
+ """An overlay opening over the screen showing: it meets any size itself,
540
+ and what it redraws under it is that screen. Returns the call that closes
541
+ the layer, made before the screen under it is put back."""
542
+ entry = {'redraw': None, 'small': True, 'overlay': True}
543
+ _backdrops.append(entry)
544
+ return lambda: _backdrops.remove(entry) if entry in _backdrops else None
545
+
546
+
547
+ def _screen_under_overlay() -> dict | None:
548
+ """The screen an overlay is over (or the top one, with none open)."""
549
+ return next((e for e in reversed(_backdrops) if not e['overlay']), None)
550
+
551
+
552
+ def relayout_under_overlay() -> None:
553
+ """The window was resized while an overlay is open: the screen under it
554
+ lays itself out at the new size, where it can keep its boxes (smaller,
555
+ the overlay has the window to itself). The overlay then places itself
556
+ afresh over what's there now."""
557
+ _float.clear() # a floating box goes; its owner shows it again if it's still wanted
558
+ if box_fits():
559
+ redraw_under_overlay()
560
+
561
+
562
+ def redraw_under_overlay() -> None:
563
+ """The screen under an overlay drawn again, whole, at the current size:
564
+ by its redraw, or, with none to call, cleared and asked to lay itself out
565
+ (it notices at its next resize check)."""
566
+ under = _screen_under_overlay()
567
+ if under and under['redraw'] is not None:
568
+ under['redraw']()
569
+ return
570
+ sys.stdout.write("\033[H\033[2J")
571
+ sys.stdout.flush()
572
+ screen_invalidate()
573
+ ui.request_relayout()
574
+
575
+
518
576
  def screen_float_close() -> None:
519
577
  """Take a floating box away now, putting back what it covered."""
520
578
  if _float:
@@ -578,6 +636,26 @@ def float_tick() -> bool:
578
636
  return True
579
637
 
580
638
 
639
+ _picture_areas: dict = {} # pictures other screens draw themselves: name → (top, left, rows, cols)
640
+
641
+
642
+ def screen_picture_area(name: str, area: tuple | None) -> None:
643
+ """Say a screen drawing its own picture (the player's cover) has one at
644
+ `area` (top, left, rows, cols; 1-based), or None now it's gone: anything
645
+ drawn over it then writes every cell, so none of the image shows through."""
646
+ if area:
647
+ _picture_areas[name] = area
648
+ else:
649
+ _picture_areas.pop(name, None)
650
+
651
+
652
+ def _picture_cols(row: int) -> list[tuple[int, int]]:
653
+ """The columns (first, last; 1-based) of `row` a picture covers."""
654
+ return ([(c, c + size[0] - 1 if size else 10 ** 6)
655
+ for r, c, n, _k, _esc, *size in _shown_pictures if r <= row < r + n]
656
+ + [(c, c + w - 1) for r, c, n, w in _picture_areas.values() if r <= row < r + n])
657
+
658
+
581
659
  def screen_forget_pictures() -> None:
582
660
  """For a screen that draws without _Widget (a player view): forget the
583
661
  cells under the pictures the last widget drew, so this screen's paint
@@ -590,6 +668,21 @@ def screen_forget_pictures() -> None:
590
668
  _shown_pictures.clear()
591
669
 
592
670
 
671
+ _BORDER_CHARS = set("╭╮╰╯│─├┤┬┴┼┊┈")
672
+
673
+
674
+ def screen_crosses_box(first_row: int, last_row: int, first_col: int, last_col: int) -> bool:
675
+ """Whether any box's border on screen lies in the rectangle (1-based,
676
+ inclusive): something drawn there would cut that box, leaving it half a
677
+ box. A rectangle wholly inside one box's inside crosses nothing."""
678
+ for r in range(first_row, last_row + 1):
679
+ cells = _cells.get(r) or []
680
+ for c in range(first_col - 1, min(last_col, len(cells))):
681
+ if cells[c] is not _UNKNOWN and cells[c][1] in _BORDER_CHARS:
682
+ return True
683
+ return False
684
+
685
+
593
686
  def screen_save(first_row: int, last_row: int, first_col: int, last_col: int) -> dict:
594
687
  """What the painter holds in a rectangle, to put back with screen_restore
595
688
  once something drawn over it (an overlay) is gone."""
@@ -640,6 +733,8 @@ def screen_paint(rows: dict, *, cursor: tuple | None = None,
640
733
  inputs); `save_cursor` wraps the frame in DEC save/restore so a caret
641
734
  elsewhere is left alone (background repaints).
642
735
  """
736
+ if _cramped(): # a frame without its boxes: the miniplayer shows instead
737
+ return
643
738
  rows = _takeover_rows(rows)
644
739
  parts: list[str] = []
645
740
  for row in sorted(rows):
@@ -722,6 +817,8 @@ def _render_footer_bar() -> None:
722
817
  change lands) when nothing else redraws. Repaints when either the track
723
818
  identity or the styled rows changed, so an idle screen never flickers yet a
724
819
  new song is never missed."""
820
+ if _chrome_held(): # the miniplayer has the window
821
+ return
725
822
  rows = ui.get_terminal_height()
726
823
  cols = ui.get_terminal_width()
727
824
  lines = ui.footer_lines(cols)
@@ -736,11 +833,97 @@ def _render_footer_bar() -> None:
736
833
  sys.stdout.flush()
737
834
 
738
835
 
836
+ # A window too small for a screen's boxes: the screen gives way to the
837
+ # miniplayer (a tool registers it), which keeps the window till the boxes fit
838
+ # again. A screen that lays out small windows itself (the player) says so
839
+ # when it registers (screen_backdrop small=True).
840
+ _cramped_view = [None]
841
+ _cramped_showing = [0] # the miniplayer is up (nested: a screen opened from it, then it again)
842
+
843
+
844
+ def set_cramped_view(fn) -> None:
845
+ """Register ``fn()``: what shows while a window is too small for any
846
+ screen's boxes, returning once they fit again (the miniplayer). With
847
+ none, a box saying the window's too small (_too_small_notice)."""
848
+ _cramped_view[0] = fn
849
+
850
+
851
+ def _too_small() -> bool:
852
+ """The screen on top can't keep its boxes (it doesn't lay out small
853
+ windows itself), so the miniplayer, or the notice, shows instead."""
854
+ top = _backdrops[-1] if _backdrops else None
855
+ return not box_fits() and not (top and top['small'])
856
+
857
+
858
+ _TOO_SMALL_NOTE = "Make the window bigger"
859
+
860
+
861
+ def _too_small_notice() -> None:
862
+ """A tool with no miniplayer, in a window too small for its screen's
863
+ boxes: a box saying so where one fits, else nothing (no box, no
864
+ content), till the window grows. Keys wait; Ctrl-C still stops."""
865
+ fd = sys.stdin.fileno()
866
+ drawn = None
867
+ while not box_fits():
868
+ size = ui.get_terminal_size()
869
+ if size != drawn:
870
+ cols, rows = size
871
+ w = ui.visual_len(_TOO_SMALL_NOTE) + 4
872
+ sys.stdout.write("\033[H\033[2J")
873
+ if rows >= 3 and cols >= w:
874
+ top, left = (rows - 3) // 2 + 1, (cols - w) // 2 + 1
875
+ box = box_lines([f"{C.DIM}{_TOO_SMALL_NOTE}{C.RESET}"], w, 3)
876
+ sys.stdout.write("".join(f"\033[{top + k};{left}H{ln[ui.MARGIN_H:]}" for k, ln in enumerate(box)))
877
+ sys.stdout.flush()
878
+ drawn = size
879
+ if _byte_ready(fd, 0.1) and _read_key(fd) == 'CTRL_C':
880
+ raise KeyboardInterrupt
881
+
882
+
883
+ _cramped = _too_small # a screen's paint is held back: the miniplayer shows instead
884
+
885
+
886
+ def _chrome_held() -> bool:
887
+ """The now-playing box and status line stay off: the miniplayer has the window."""
888
+ return _too_small() or _cramped_showing[0] > 0
889
+
890
+
891
+ def _run_cramped() -> None:
892
+ """The miniplayer until the boxes fit, then the screen under it laid out
893
+ afresh (its next resize check says yes)."""
894
+ tabs_were = ui.set_tabs_hidden(True) # the miniplayer alone
895
+ _cramped_showing[0] += 1
896
+ log.debug("window too small for boxes: the miniplayer")
897
+ try:
898
+ (_cramped_view[0] or _too_small_notice)()
899
+ finally:
900
+ _cramped_showing[0] -= 1
901
+ ui.set_tabs_hidden(tabs_were)
902
+ sys.stdout.write("\033[H\033[2J")
903
+ sys.stdout.flush()
904
+ screen_invalidate()
905
+ ui.request_relayout()
906
+
907
+
908
+ _wheel_at: list = [None] # (row, col) of the last wheel or trackpad scroll
909
+
910
+
911
+ def wheel_at() -> tuple | None:
912
+ """Where the last SCROLL_UP / SCROLL_DOWN happened, (row, col) 1-based:
913
+ for a screen with more than one thing to scroll."""
914
+ return _wheel_at[0]
915
+
916
+
739
917
  def _wait_for_keypress(timeout: float = 0.05) -> bool:
740
918
  """Block up to `timeout` seconds for a keypress; return whether one arrived.
741
919
 
742
920
  Also refreshes the now-playing box at ~4 Hz so background-audio status
743
- stays live on every widget/menu without each one needing its own tick."""
921
+ stays live on every widget/menu without each one needing its own tick.
922
+ In a window too small for the screen's boxes, the miniplayer instead
923
+ (returning no key once the screen's back)."""
924
+ if _cramped():
925
+ _run_cramped()
926
+ return False
744
927
  now = time.time()
745
928
  if now - _footer_last_draw[0] >= 0.12:
746
929
  _footer_last_draw[0] = now
@@ -783,17 +966,20 @@ def _cols() -> int:
783
966
 
784
967
 
785
968
  # --- Hint bar visibility ------------------------------------------------------
786
- # One switch for every screen's hint bar, off until turned on (`?`, Ctrl-/ where
787
- # `?` is typed, or a click on the corner toggle each screen shows on its top
969
+ # One switch for every screen's hint bar, off until turned on (`?`, Ctrl-/ or
970
+ # Ctrl-G where `?` is typed, or a click on the corner toggle each screen shows on its top
788
971
  # line), remembered between runs.
789
972
  # Kept in its own small file rather than config.json: screens hold a loaded
790
973
  # config and save it back later, which would quietly undo a toggle made meanwhile.
791
974
  HINTS_CLICK = '\x00hints' # the key a click on the corner toggle replays
975
+ _TYPED_HELP_KEYS = (("\x07", "\x1f") if os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
976
+ else ("\x1f", "\x07"))
792
977
  keys.define("global", "Everywhere", [
793
- # Wherever it isn't typed or bound; Ctrl-/ (the same key with Ctrl) is the
794
- # way where it is, and works everywhere.
978
+ # Wherever it isn't typed or bound; where it is, Ctrl-/ (the same key with
979
+ # Ctrl), and Ctrl-G (nano's help key) for macOS's Terminal, which sends
980
+ # nothing for Ctrl-/. Both work everywhere; what's shown is the first.
795
981
  ("help", ("?",), "show or hide the key hints"),
796
- ("help_typed", ("\x1f",), "show or hide the key hints, in a text field too"),
982
+ ("help_typed", _TYPED_HELP_KEYS, "show or hide the key hints, in a text field too"),
797
983
  ], within=())
798
984
  _hints_on: list = [None] # None until first read
799
985
 
@@ -862,7 +1048,7 @@ def set_help_toggle_shown(shown: bool) -> None:
862
1048
 
863
1049
 
864
1050
  def is_hints_key(key: str, key_free: bool) -> bool:
865
- """Whether `key` toggles the hints: a click on the corner, Ctrl-/, or `?`
1051
+ """Whether `key` toggles the hints: a click on the corner, Ctrl-/ or Ctrl-G, or `?`
866
1052
  where the screen leaves `?` free (not typed or bound)."""
867
1053
  return (key == HINTS_CLICK or keys.pressed(key, "global.help_typed")
868
1054
  or (key_free and keys.pressed(key, "global.help")))
@@ -870,7 +1056,8 @@ def is_hints_key(key: str, key_free: bool) -> bool:
870
1056
 
871
1057
  def help_corner_text(help_key: bool = True, shown: bool | None = None) -> tuple[str, int]:
872
1058
  """The header toggle, styled, and its width: `[?] help` / `[?] hide help`,
873
- or `[^/] …` where `?` is typed (a text field) and Ctrl-/ is the key.
1059
+ or `[^/] …` where `?` is typed (a text field) and Ctrl-/ is the key
1060
+ (`[^g]`, Ctrl-G, in macOS's Terminal).
874
1061
  `shown`: the hints' state to describe (default: as they are now).
875
1062
  Nothing, when the toggle is switched off (help_toggle_shown)."""
876
1063
  if not help_toggle_shown():
@@ -896,7 +1083,7 @@ def add_help_corner(line: str, row: int, cells: dict, help_key: bool = False) ->
896
1083
  """`line` (a screen's top line, drawn on screen row `row`) with the toggle
897
1084
  right-aligned on it, clipping the line if the two would meet. Only the key
898
1085
  is clickable (a click replays HINTS_CLICK). `help_key`: pressing `?` toggles
899
- here; elsewhere `?` is typed or bound, and the toggle names Ctrl-/."""
1086
+ here; elsewhere `?` is typed or bound, and the toggle names Ctrl-/ (or Ctrl-G)."""
900
1087
  text, width = help_corner_text(help_key)
901
1088
  if not width: # the toggle is switched off
902
1089
  if help_key:
@@ -947,25 +1134,34 @@ def box_lines(lines: list[str], width: int, height: int, title: str = "", right:
947
1134
  h, v = ("┈", "┊") if dotted else ("─", "│")
948
1135
  inner = max(1, width - 4) # "│ " + content + " │"
949
1136
  pad = " " * ui.MARGIN_H
1137
+ # Nothing in a border is ever cut short. Short of room, whole parts give
1138
+ # way, least first: the title's " · " parts after its name, then the
1139
+ # right end's (a subtitle, a message), then the help toggle, then the name.
1140
+ toggle = next((t for t, _w in _toggle_variants() if t and right.endswith(t)), "") if right else ""
1141
+ sub = ui.strip_ansi(right[:len(right) - len(toggle)]).rstrip(" ─") if right else ""
1142
+ tparts, sparts = (title.split(" · ") if title else []), (sub.split(" · ") if sub else [])
1143
+
1144
+ def fits(tp: list, sp: list, tg: str) -> bool:
1145
+ name_w = ui.visual_len(" · ".join(tp)) + 2 if tp else 0
1146
+ r = " ─ ".join(x for x in (" · ".join(sp), tg) if x)
1147
+ return name_w + (ui.visual_len(r) + 3 if r else 0) <= width - 4
1148
+
1149
+ while not fits(tparts, sparts, toggle):
1150
+ if len(tparts) > 1:
1151
+ tparts = tparts[:-1]
1152
+ elif sparts:
1153
+ sparts = sparts[:-1]
1154
+ elif toggle:
1155
+ toggle = ""
1156
+ elif tparts:
1157
+ tparts = []
1158
+ else:
1159
+ break
1160
+ title = " · ".join(tparts)
950
1161
  name = f" {title} " if title else ""
951
- # Short of room, the title and the right end (a subtitle, the help
952
- # toggle) each keep up to half the border, the longer giving way first.
953
- avail = width - 4 - 3
954
- ln, lr = ui.visual_len(name), ui.visual_len(right)
955
- if right and ln + lr > avail:
956
- fit = avail - min(ln, max(avail // 2, avail - lr))
957
- if lr > fit:
958
- # The help toggle at its end stays whole (it's clicked, and found
959
- # there by place_help_toggle); what's before it shortens.
960
- toggle = next((t for t, _w in _toggle_variants() if t and right.endswith(t)), "")
961
- head, tw = right[:len(right) - len(toggle)], ui.visual_len(toggle)
962
- room_h = fit - tw
963
- head = (_clip_ansi(head, room_h - 2) + f"{C.DIM}… {C.RESET}") if room_h > 4 else ""
964
- right = head + toggle if tw <= fit else ""
1162
+ sub = " · ".join(sparts)
1163
+ right = (f"{C.DIM}{sub}{C.RESET}" if sub else "") + (f"{C.DIM} ─ {C.RESET}" if sub and toggle else "") + toggle
965
1164
  tail = f" {right} ─" if right else ""
966
- room = width - 4 - ui.visual_len(tail)
967
- if ui.visual_len(name) > room:
968
- name = (_clip_ansi(name, max(0, room - 2)) + "… ") if room > 2 else ""
969
1165
  rule = max(1, width - 3 - ui.visual_len(name) - ui.visual_len(tail))
970
1166
  shade = C.PRIMARY if focused else C.DIM
971
1167
  tail = f"{C.DIM} {C.RESET}{right}{C.DIM} ─{C.RESET}" if right else ""
@@ -979,6 +1175,18 @@ def box_lines(lines: list[str], width: int, height: int, title: str = "", right:
979
1175
  return [top, *mid, f"{pad}{C.DIM}╰{h * (width - 2)}╯{C.RESET}"]
980
1176
 
981
1177
 
1178
+ def whole_parts(text: str, room: int) -> str:
1179
+ """`text` as it is if it fits in `room` columns, else with whole " · "
1180
+ parts dropped from the end till it does, else nothing: never cut short."""
1181
+ parts = text.split(" · ")
1182
+ while parts:
1183
+ shown = " · ".join(parts)
1184
+ if ui.visual_len(shown) <= room:
1185
+ return shown
1186
+ parts.pop()
1187
+ return ""
1188
+
1189
+
982
1190
  def border_right(subtitle: str | None, help_key: bool | None) -> str:
983
1191
  """The right end of a box's top border: a dim `subtitle`, then the hints
984
1192
  toggle (`help_key` None: none; else as help_corner_text has it)."""
@@ -1254,6 +1462,10 @@ def place_help_toggle(out: list, first_row: int, cells: dict, help_key: bool = F
1254
1462
  if help_key:
1255
1463
  cells['__help_key__'] = True
1256
1464
  return
1465
+ if out and ui.strip_ansi(out[0]).lstrip().startswith("╭"):
1466
+ if help_key: # a box's border without it: it had no room (`?` still works)
1467
+ cells['__help_key__'] = True
1468
+ return
1257
1469
  if out:
1258
1470
  out[0] = add_help_corner(out[0], first_row, cells, help_key)
1259
1471
 
@@ -1532,6 +1744,8 @@ def footer_click_action(row: int, col: int) -> str | None:
1532
1744
  def render_status_bar():
1533
1745
  """Redraw the bottom status bar in place, saving/restoring the cursor so
1534
1746
  the text input caret doesn't move."""
1747
+ if _chrome_held(): # the miniplayer has the window
1748
+ return
1535
1749
  rows = ui.get_terminal_height()
1536
1750
  float_tick()
1537
1751
  if rows <= 0 or not ui.chrome_fits(): # too short a window: its rows are the screen's
@@ -2050,8 +2264,9 @@ def _read_key_raw(fd: int) -> str:
2050
2264
  btn, col, row = int(parts[0]), int(parts[1]), int(parts[2])
2051
2265
  if c == 'm':
2052
2266
  return f'MOUSE_RELEASE:{btn}:{row}:{col}'
2053
- if btn == 64: return 'SCROLL_UP'
2054
- if btn == 65: return 'SCROLL_DOWN'
2267
+ if btn in (64, 65):
2268
+ _wheel_at[0] = (row, col)
2269
+ return 'SCROLL_UP' if btn == 64 else 'SCROLL_DOWN'
2055
2270
  if btn in (0, 1, 2): return f'MOUSE_CLICK:{btn}:{row}:{col}'
2056
2271
  except ValueError:
2057
2272
  pass
@@ -2184,6 +2399,8 @@ class _Widget:
2184
2399
  status bar) are left exactly as they are instead of being wiped and
2185
2400
  restamped on every keystroke.
2186
2401
  """
2402
+ if _cramped(): # a frame without its boxes: the miniplayer shows instead
2403
+ return
2187
2404
  mv = ui.MARGIN_V
2188
2405
  top = ui.top_margin()
2189
2406
  rows = ui.get_terminal_height()
@@ -9,7 +9,7 @@ from typing import Any, Callable, Literal, overload
9
9
  from backbone.prompt.core import (
10
10
  _COLUMNS_MAX_WIDTH, _EDGE_MARGIN, _get_term_attrs, _set_raw, _restore_term_attrs,
11
11
  _wait_for_keypress, _table_widths, _render_table_row, _clip_ansi, _norm, block_cursor,
12
- _read_key, _visible_rows, _cols, _Widget, _hint_pin_target, screen_takeover_next,
12
+ _read_key, _visible_rows, _cols, _Widget, _hint_pin_target, screen_takeover_next, screen_backdrop,
13
13
  Choice, Column, JumpTo, PanelTitle, Trail, border_right, box_fits, box_lines, boxed_frame, column_widths,
14
14
  COL_GAP, _COL_MAIN_MIN, _COL_PREVIEW, _LIST_ROWS_MIN, _SHAPE_SAMPLE, _STRIP_SPLIT, strip_rows,
15
15
  columns_shown, help_corner_text, panel_header, rounded_header, set_columns_shown, trail_lines,
@@ -83,14 +83,14 @@ keys.define("list", "Lists", [
83
83
  ("top", ("HOME",), "first row"),
84
84
  ("bottom", ("END",), "last row"),
85
85
  ("choose", ("ENTER", "RIGHT"), "choose the row"),
86
- ("toggle", ("SPACE",), "tick the row (lists with ticks)"),
87
- ("toggle_all", ("a", "A"), "tick or clear every row (lists with ticks)"),
86
+ ("toggle", ("SPACE",), "tick the row, in lists with ticks"),
87
+ ("toggle_all", ("a", "A"), "tick or clear every row, in lists with ticks"),
88
88
  ("back", ("ESC", "b", "LEFT"), "back"),
89
89
  ("quit", ("q", "Q"), "quit the app"),
90
90
  ("sections", ("/",), "a long list's sections, or the whole list"),
91
91
  ("row_options", ("o",), "everything you can do with the row"),
92
92
  ("list_options", ("O",), "everything you can do with the whole list"),
93
- ("columns", ("v",), "side columns on or off (lists that have them)"),
93
+ ("columns", ("v",), "side columns on or off, in lists that have them"),
94
94
  ])
95
95
  # The live search lists: typing goes into the query, so their keys are the
96
96
  # ones that can't be typed.
@@ -108,7 +108,7 @@ keys.define("confirm", "Yes/no questions", [
108
108
  ("yes", ("y", "Y"), "yes"),
109
109
  ("no", ("n", "N"), "no"),
110
110
  ("default", ("ENTER",), "the default answer"),
111
- ("back", ("ESC",), "back (answers no)"),
111
+ ("back", ("ESC",), "back, answering no"),
112
112
  ])
113
113
  L = keys.label
114
114
 
@@ -558,7 +558,8 @@ def _select_flat(message: str, choices: list, *,
558
558
  """The preview's width moved a few columns, if need be, so the edge
559
559
  between the browser's box and the preview's isn't under the showing
560
560
  tab (whose outline opens into the box under it: ui.tab_notch); the
561
- shorter way, as far as both keep their least. Else as it was."""
561
+ shorter way, as far as both keep their least. Where neither way
562
+ fits, no preview (0): a box cut by the tab is no box."""
562
563
  notch = ui.tab_notch() if pw else None
563
564
  if not notch:
564
565
  return pw
@@ -573,7 +574,7 @@ def _select_flat(message: str, choices: list, *,
573
574
  move = right - (a - mh - 1) # the preview's corner at the tab's left side
574
575
  if _cols() - pw - move - mh - 4 - trails_w >= _COL_MAIN_MIN[0]:
575
576
  fits.append(pw + move)
576
- return min(fits, key=lambda w: abs(w - pw)) if fits else pw
577
+ return min(fits, key=lambda w: abs(w - pw)) if fits else 0
577
578
 
578
579
  def _contents_box(pv, width: int, height: int, title: bool = True) -> list:
579
580
  """What the highlighted row holds, in a box `width` × `height`."""
@@ -620,7 +621,8 @@ def _select_flat(message: str, choices: list, *,
620
621
  def _browser_rows(body, shown, main_in, height, first, lead) -> list:
621
622
  """The column browser's rows inside its box: the levels above that fit
622
623
  (`shown`: (trail, width)), then the list (`body`, its rows' margin
623
- dropped), the levels' rows level with the list's (`lead` lines down).
624
+ dropped), the levels' rows from the top of the box (`lead` lines down:
625
+ the message), not moved by the list's "N above".
624
626
  Records where each column landed (_col_geo), for clicks."""
625
627
  sep = f" {C.DIM}│{C.RESET} "
626
628
  x = 1 + ui.MARGIN_H + 2 # the screen column inside "│ "
@@ -814,7 +816,7 @@ def _select_flat(message: str, choices: list, *,
814
816
  for line, col, rows, key, esc, *size in getattr(h_lines, 'pictures', ()):
815
817
  w.pictures.append((1 + ui.top_margin() + line, col + 1, rows, key, esc, *size))
816
818
  if boxed and (shown or pw or strip_h):
817
- browser = box_lines(_browser_rows(out, shown, main_in, box_h - 2, first, len(pre) + (viewport > 0)),
819
+ browser = box_lines(_browser_rows(out, shown, main_in, box_h - 2, first, len(pre)),
818
820
  browser_w, box_h, box_title, box_right)
819
821
  if pw:
820
822
  browser = [b + p for b, p in zip(browser, _preview_column(pview, pw, box_h, browser_w, first))]
@@ -842,17 +844,22 @@ def _select_flat(message: str, choices: list, *,
842
844
 
843
845
  result = None
844
846
  _sel_last_click: int | None = None
847
+ _drop_backdrop = lambda: None # noqa: E731 (none until the loop's first frame)
845
848
  try:
846
849
  _set_raw(fd)
847
850
  enable_mouse()
848
851
  screen_takeover_next() # paint over the previous screen, no flash
849
852
  w.render(_lines())
850
853
 
854
+ def _relaid() -> None:
855
+ ui.clear_screen()
856
+ w.anchor_reset()
857
+ w.render(_lines())
858
+ _drop_backdrop = screen_backdrop(_relaid)
859
+
851
860
  while True:
852
861
  if ui.consume_resize():
853
- ui.clear_screen()
854
- w.anchor_reset()
855
- w.render(_lines())
862
+ _relaid()
856
863
  continue
857
864
 
858
865
  if not _wait_for_keypress(0.05):
@@ -1101,6 +1108,7 @@ def _select_flat(message: str, choices: list, *,
1101
1108
  w.render(_lines())
1102
1109
 
1103
1110
  finally:
1111
+ _drop_backdrop()
1104
1112
  disable_mouse()
1105
1113
  _restore_term_attrs(fd, old)
1106
1114
  w.clear()
@@ -1351,11 +1359,15 @@ def live_select(message: str, provider: Callable[[str], list], *,
1351
1359
  screen_takeover_next() # paint over the previous screen, no flash
1352
1360
  w.render(_lines())
1353
1361
 
1362
+ def _relaid() -> None:
1363
+ ui.clear_screen()
1364
+ w.anchor_reset()
1365
+ w.render(_lines())
1366
+ _drop_backdrop = screen_backdrop(_relaid)
1367
+
1354
1368
  while True:
1355
1369
  if ui.consume_resize():
1356
- ui.clear_screen()
1357
- w.anchor_reset()
1358
- w.render(_lines())
1370
+ _relaid()
1359
1371
  continue
1360
1372
  if not _wait_for_keypress(0.05):
1361
1373
  continue