robotcode-repl 2.6.2__py3-none-any.whl → 2.8.0.dev548__py3-none-any.whl

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.
@@ -19,6 +19,10 @@ Interactive features:
19
19
  matching section, ``http(s)://`` opens in the browser.
20
20
  - **Back/forward** (``[`` / ``]``): browser-style stack of anchor
21
21
  jumps within the doc.
22
+ - **Sidebar** (``s``, library pages only): the level-2 and level-3
23
+ headings of the page and the subsections of the introduction, with
24
+ a filter field; side by side with the page in a wide terminal, over
25
+ it in a narrow one.
22
26
  - **Resize**: terminal width changes trigger a debounced reflow
23
27
  that re-renders the markdown at the new width; per-width
24
28
  snapshots are cached so re-resizes are instant.
@@ -36,13 +40,26 @@ from prompt_toolkit.filters import Condition
36
40
  from prompt_toolkit.formatted_text import ANSI, StyleAndTextTuples, to_formatted_text
37
41
  from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
38
42
  from prompt_toolkit.layout import Layout
39
- from prompt_toolkit.layout.containers import ConditionalContainer, HSplit, Window
43
+ from prompt_toolkit.layout.containers import (
44
+ ConditionalContainer,
45
+ Container,
46
+ DynamicContainer,
47
+ Float,
48
+ FloatContainer,
49
+ HSplit,
50
+ VSplit,
51
+ Window,
52
+ )
40
53
  from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
41
54
  from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
42
55
  from prompt_toolkit.styles import Style
56
+ from prompt_toolkit.utils import get_cwidth
43
57
  from prompt_toolkit.widgets import Frame
44
58
  from rich.console import Console
45
59
  from rich.markdown import Markdown
60
+ from robot.utils import MultiMatcher
61
+
62
+ from robotcode.robot.utils.markdown_docs import heading_anchors
46
63
 
47
64
 
48
65
  def render_markdown_to_ansi(text: str, *, width: int = 80) -> str:
@@ -83,7 +100,10 @@ def render_markdown_for_viewer(text: str, *, width: int = 80) -> Tuple[str, List
83
100
  """
84
101
  if not text:
85
102
  return "", []
86
- console = Console(width=width, record=False, soft_wrap=False, force_terminal=True)
103
+ # The ANSI text goes to `_strip_osc8_hyperlinks` and prompt_toolkit, never to a console.
104
+ # On Windows without a console that supports VT sequences, such as in CI, `rich` would
105
+ # assume a legacy console and leave out the OSC 8 links.
106
+ console = Console(width=width, record=False, soft_wrap=False, force_terminal=True, legacy_windows=False)
87
107
  with console.capture() as cap:
88
108
  console.print(Markdown(text))
89
109
  return _strip_osc8_hyperlinks(cap.get())
@@ -152,6 +172,12 @@ _WRAP_GAP_TOLERANCE = 4
152
172
  # (mid-file, with a little preceding context) rather than jammed at the top edge.
153
173
  _SCROLL_TO_CONTEXT = 3
154
174
 
175
+ # The sidebar's width, including its border to the page. It stands beside the
176
+ # page when the page keeps at least `_MIN_PAGE_WIDTH_BESIDE_SIDEBAR` columns,
177
+ # and lies over the page in a narrower terminal.
178
+ _SIDEBAR_WIDTH = 32
179
+ _MIN_PAGE_WIDTH_BESIDE_SIDEBAR = 60
180
+
155
181
 
156
182
  def _coalesce_link_spans(spans: List[Tuple[int, int, str]]) -> List[Tuple[int, int, str]]:
157
183
  """Merge adjacent spans that point to the same URL.
@@ -198,12 +224,21 @@ _HELP_HINTS = (
198
224
  "[/]: back/forward · Shift+drag: select · q/Esc/Enter: close "
199
225
  )
200
226
 
201
- # Matches `## Title`, `### Subtitle`, etc. Group 1 keeps the raw
202
- # title (incl. emphasis markers) — the anchor slug preserves them
203
- # to match `to_markdown`'s auto-linker, while the rendered-line
204
- # lookup runs the title through `_strip_md_emphasis` because `rich`
205
- # consumes those markers when it paints.
206
- _HEADER_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
227
+ _OUTLINE_HELP_HINTS = (
228
+ " j/k or ↑/↓ or wheel: scroll · s: sidebar · /: search · Tab or click: links · "
229
+ "[/]: back/forward · Shift+drag: select · q/Esc/Enter: close "
230
+ )
231
+
232
+ # While the sidebar stands beside the page, `Esc` on the page hides it first.
233
+ _SIDEBAR_SHOWN_HELP_HINTS = (
234
+ " Esc: hide sidebar · s: focus sidebar · j/k or ↑/↓ or wheel: scroll · /: search · "
235
+ "Tab or click: links · [/]: back/forward · q/Enter: close "
236
+ )
237
+
238
+ _SIDEBAR_HINTS = " type: filter · ↑/↓: select · wheel: scroll · Enter or click: jump · Esc: hide "
239
+
240
+ # The rendered-line lookup runs a heading's title through `_strip_md_emphasis`
241
+ # because `rich` consumes those markers when it paints.
207
242
 
208
243
  _MD_EMPHASIS_RE = re.compile(r"[*_`]")
209
244
 
@@ -213,19 +248,6 @@ def _strip_md_emphasis(text: str) -> str:
213
248
  return _MD_EMPHASIS_RE.sub("", text)
214
249
 
215
250
 
216
- def _slugify_anchor(title: str) -> str:
217
- """Convert a header title to its anchor slug.
218
-
219
- Matches the convention `robotcode.robot.utils.markdownformatter` and
220
- `LibraryDoc._link_inline_links` use: lowercase + spaces-to-dashes,
221
- nothing else stripped. The auto-linker in `to_markdown` produces
222
- refs like `[Library *BuiltIn*](\\#library-*builtin*)`, so the
223
- `*` survives in both the link target and the slug we generate
224
- here — comparison is symmetric.
225
- """
226
- return title.lower().replace(" ", "-")
227
-
228
-
229
251
  def _build_anchor_to_line_map(md_source: str, rendered_plain: str) -> Dict[str, int]:
230
252
  """Map each header's anchor slug to the 0-based rendered line where
231
253
  its title text appears.
@@ -243,18 +265,53 @@ def _build_anchor_to_line_map(md_source: str, rendered_plain: str) -> Dict[str,
243
265
  rendered_lines = rendered_plain.split("\n")
244
266
  result: Dict[str, int] = {}
245
267
  cursor = 0
246
- for m in _HEADER_RE.finditer(md_source):
247
- raw_title = m.group(1).strip()
248
- slug = _slugify_anchor(raw_title)
268
+ # `heading_anchors` skips `#` lines of fenced code blocks, which are no headings,
269
+ # and numbers repeated anchors as the links of the page do
270
+ for _, raw_title, anchor in heading_anchors(md_source):
249
271
  display = _strip_md_emphasis(raw_title)
250
272
  for j in range(cursor, len(rendered_lines)):
251
273
  if rendered_lines[j].strip() == display:
252
- result[slug] = j
274
+ result[anchor] = j
253
275
  cursor = j + 1
254
276
  break
255
277
  return result
256
278
 
257
279
 
280
+ class _OutlineEntry(NamedTuple):
281
+ """One heading in the sidebar: its level (2 or more), its title as the page shows it, and its anchor."""
282
+
283
+ level: int
284
+ title: str
285
+ anchor: str
286
+
287
+
288
+ def _build_outline(md_source: str, anchor_to_line: Dict[str, int]) -> List[_OutlineEntry]:
289
+ """The level-2 and level-3 headings of ``md_source`` and the deeper
290
+ headings of its introduction, in document order.
291
+
292
+ Deeper headings elsewhere belong to the documentation of a keyword,
293
+ a data type or an entry of `Importing`. Headings without a rendered
294
+ line are left out, because the sidebar jumps through the anchor map.
295
+ """
296
+ result: List[_OutlineEntry] = []
297
+ in_introduction = False
298
+ for level, title, anchor in heading_anchors(md_source):
299
+ if level == 2:
300
+ in_introduction = title == "Introduction"
301
+ if level >= 2 and (level <= 3 or in_introduction) and anchor in anchor_to_line:
302
+ result.append(_OutlineEntry(level, _strip_md_emphasis(title), anchor))
303
+ return result
304
+
305
+
306
+ def _fit(text: str, width: int) -> str:
307
+ """``text`` padded to ``width`` columns, or cut with `…` when it is longer."""
308
+ if get_cwidth(text) > width:
309
+ while get_cwidth(text) > width - 1:
310
+ text = text[:-1]
311
+ text += "…"
312
+ return text + " " * (width - get_cwidth(text))
313
+
314
+
258
315
  class _RenderSnapshot(NamedTuple):
259
316
  """Full set of derived data from one markdown render at a given width.
260
317
 
@@ -281,12 +338,14 @@ class _NavState(NamedTuple):
281
338
  and focused link. Carrying the source means a follow that loaded a
282
339
  *different* document — e.g. a keyword page opened from a list — can
283
340
  be reversed by reloading the previous document, not just scrolling.
341
+ `outline` tells whether that document has a sidebar.
284
342
  """
285
343
 
286
344
  title: str
287
345
  md_source: str
288
346
  scroll: int
289
347
  current_link: int
348
+ outline: bool = False
290
349
 
291
350
 
292
351
  # Viewer-only style entries. The host interpreter's `_DEFAULT_STYLE`
@@ -301,6 +360,10 @@ _VIEWER_STYLE = Style.from_dict(
301
360
  "doc.search.prompt": "fg:#5fafd7 bold",
302
361
  "doc.match": "reverse",
303
362
  "doc.match.current": "reverse bold",
363
+ "doc.sidebar.border": "fg:#7f7f7f",
364
+ "doc.sidebar.section": "bold",
365
+ "doc.sidebar.selected": "reverse",
366
+ "doc.sidebar.empty": "fg:#7f7f7f",
304
367
  }
305
368
  )
306
369
 
@@ -470,8 +533,8 @@ def _attach_link_handlers(
470
533
  class DocViewer:
471
534
  """Markdown viewer running as a separate fullscreen Application.
472
535
 
473
- The layout (body Window + search bar + footer + Frame) is built
474
- once at construction; `run(title, markdown)` swaps in the new
536
+ The layout (body Window + sidebar + search bar + footer + Frame) is
537
+ built once at construction; `run(title, markdown)` swaps in the new
475
538
  content, resets scroll + search state, and invokes
476
539
  `Application.run()`. The app is set to `full_screen=True` so
477
540
  prompt_toolkit switches to the terminal's alternate screen buffer
@@ -542,6 +605,15 @@ class DocViewer:
542
605
  self._resize_task: Optional[asyncio.TimerHandle] = None
543
606
  self._render_cache: Dict[int, _RenderSnapshot] = {}
544
607
 
608
+ # Sidebar state. Only a document loaded with `outline=True` has an
609
+ # outline. `_listed` holds the indices of the entries the filter
610
+ # lists, `_selected` the selected row of that list (-1 = none).
611
+ self._with_outline = False
612
+ self._outline: List[_OutlineEntry] = []
613
+ self._listed: List[int] = []
614
+ self._selected = -1
615
+ self._sidebar_visible = False
616
+
545
617
  # Pin the control's "cursor" to the top of the visible area so
546
618
  # `Window._scroll_without_linewrapping` doesn't clamp
547
619
  # `vertical_scroll` back to 0 — its do_scroll() resets the
@@ -591,12 +663,50 @@ class DocViewer:
591
663
  self._footer_control = FormattedTextControl(text=self._compute_footer_text)
592
664
  self._footer_window = Window(content=self._footer_control, height=1, style="class:doc.footer")
593
665
 
594
- from prompt_toolkit.layout.containers import VSplit
666
+ # Sidebar — a filter field above the list of headings, with a
667
+ # border to the page. Like the body, the list pins its "cursor" to
668
+ # the top of the visible area, so the mouse wheel scrolls it freely;
669
+ # `_scroll_selection_into_view` keeps a moved selection visible.
670
+ self._filter_buffer = Buffer(multiline=False, on_text_changed=lambda _buf: self._apply_filter())
671
+ self._filter_window = Window(
672
+ content=BufferControl(buffer=self._filter_buffer, key_bindings=self._build_sidebar_bindings()),
673
+ height=1,
674
+ )
675
+ self._outline_window = Window(
676
+ content=FormattedTextControl(
677
+ text=self._compute_outline_fragments,
678
+ show_cursor=False,
679
+ get_cursor_position=lambda: Point(x=0, y=self._outline_window.vertical_scroll),
680
+ ),
681
+ always_hide_cursor=True,
682
+ )
683
+ filter_row = VSplit(
684
+ [
685
+ Window(content=FormattedTextControl([("class:doc.search.prompt", " Filter: ")]), width=9, height=1),
686
+ self._filter_window,
687
+ ]
688
+ )
689
+ self._sidebar = VSplit(
690
+ [
691
+ HSplit(
692
+ [filter_row, Window(height=1, char="─", style="class:doc.sidebar.border"), self._outline_window]
693
+ ),
694
+ Window(width=1, char="│", style="class:doc.sidebar.border"),
695
+ ],
696
+ width=_SIDEBAR_WIDTH,
697
+ )
698
+ # The two places of a shown sidebar; `_main_area` picks one, or the
699
+ # body alone, so the layout only ever holds one of them.
700
+ self._sidebar_beside_area = VSplit([self._sidebar, self._body_window])
701
+ self._sidebar_over_area = FloatContainer(
702
+ content=self._body_window,
703
+ floats=[Float(content=self._sidebar, left=0, top=0, bottom=0, width=_SIDEBAR_WIDTH)],
704
+ )
595
705
 
596
706
  search_row = VSplit([self._search_prefix_window, self._search_window])
597
707
  frame_body = HSplit(
598
708
  [
599
- self._body_window,
709
+ DynamicContainer(self._main_area),
600
710
  ConditionalContainer(search_row, filter=Condition(lambda: self._in_search_mode)),
601
711
  ConditionalContainer(self._footer_window, filter=Condition(lambda: not self._in_search_mode)),
602
712
  ]
@@ -626,18 +736,20 @@ class DocViewer:
626
736
  self._pending_scroll: Optional[str] = None
627
737
  self._app.after_render += self._apply_pending_scroll
628
738
 
629
- def run(self, title: str, markdown: str, *, scroll_to: Optional[str] = None) -> None:
739
+ def run(self, title: str, markdown: str, *, scroll_to: Optional[str] = None, outline: bool = False) -> None:
630
740
  """Render ``markdown`` into the body and run the viewer fullscreen.
631
741
 
632
742
  Blocks until the user presses Esc / q / Enter. Uses the alt
633
743
  screen buffer, so the host prompt's terminal state survives
634
744
  the call untouched. ``scroll_to`` opens the viewer scrolled to the
635
745
  first rendered line containing that text (e.g. a marked source line).
746
+ ``outline`` gives the document a sidebar with its headings, for the
747
+ page of a library, resource file or suite file.
636
748
  """
637
749
  # A fresh top-level invocation starts with empty history.
638
750
  self._back_stack = []
639
751
  self._forward_stack = []
640
- self._load_document(title, markdown, scroll_to=scroll_to)
752
+ self._load_document(title, markdown, scroll_to=scroll_to, outline=outline)
641
753
  # The pre-render scroll above gets reset by the first render; arm the
642
754
  # `after_render` hook to re-apply it once the body is really on screen.
643
755
  self._pending_scroll = scroll_to
@@ -653,21 +765,29 @@ class DocViewer:
653
765
  self._resize_task.cancel()
654
766
  self._resize_task = None
655
767
 
656
- def _load_document(self, title: str, markdown: str, *, scroll_to: Optional[str] = None) -> None:
768
+ def _load_document(
769
+ self, title: str, markdown: str, *, scroll_to: Optional[str] = None, outline: bool = False
770
+ ) -> None:
657
771
  """Adopt ``markdown`` as the current document and reset per-doc
658
- state (scroll, focused link, search). Leaves the back / forward
659
- stacks alone so it can be reused for in-place link follows;
772
+ state (scroll, focused link, search, sidebar). Leaves the back /
773
+ forward stacks alone so it can be reused for in-place link follows;
660
774
  `run` clears them for a fresh top-level invocation. ``scroll_to``
661
775
  opens scrolled to the first rendered line containing that text.
776
+ ``outline`` gives the document a sidebar, hidden at first.
662
777
  """
663
778
  self._title = title
664
779
  self._md_source = markdown
665
780
  # New doc → all cached renders are for the OLD markdown. Wipe.
666
781
  self._render_cache = {}
667
- size = self._app.output.get_size()
668
- # Account for the frame border (1 char on each side = 2 chars).
669
- body_width = max(size.columns - 2, 40)
670
- self._render_at_width(body_width)
782
+ self._with_outline = outline
783
+ self._sidebar_visible = False
784
+ if self._sidebar_has_focus():
785
+ self._app.layout.focus(self._body_window)
786
+ self._render_at_width(self._body_width())
787
+
788
+ self._outline = _build_outline(markdown, self._anchor_to_line) if outline else []
789
+ self._filter_buffer.reset()
790
+ self._apply_filter()
671
791
 
672
792
  self._current_link = -1
673
793
  self._in_search_mode = False
@@ -706,13 +826,15 @@ class DocViewer:
706
826
 
707
827
  def _current_state(self) -> _NavState:
708
828
  """Snapshot the current document + position for the nav stacks."""
709
- return _NavState(self._title, self._md_source, self._body_window.vertical_scroll, self._current_link)
829
+ return _NavState(
830
+ self._title, self._md_source, self._body_window.vertical_scroll, self._current_link, self._with_outline
831
+ )
710
832
 
711
833
  def _restore_state(self, state: _NavState) -> None:
712
834
  """Return to a `_NavState`, reloading its document first if the
713
835
  current one differs."""
714
836
  if state.md_source != self._md_source:
715
- self._load_document(state.title, state.md_source)
837
+ self._load_document(state.title, state.md_source, outline=state.outline)
716
838
  self._body_window.vertical_scroll = state.scroll
717
839
  self._current_link = state.current_link
718
840
 
@@ -771,8 +893,17 @@ class DocViewer:
771
893
  """
772
894
  if not self._md_source:
773
895
  return
774
- size = self._app.output.get_size()
775
- body_width = max(size.columns - 2, 40)
896
+ # Over the page, the sidebar is only shown while it has the focus. After
897
+ # a resize that leaves no room beside the page while the page has the
898
+ # focus, it would cover the page, so it is hidden.
899
+ if (
900
+ self._sidebar_visible
901
+ and not self._sidebar_has_focus()
902
+ and not self._sidebar_beside(self._app.output.get_size().columns)
903
+ ):
904
+ self._sidebar_visible = False
905
+ self._app.invalidate()
906
+ body_width = self._body_width()
776
907
  if body_width == self._last_body_width:
777
908
  return
778
909
  if self._resize_task is not None:
@@ -820,6 +951,182 @@ class DocViewer:
820
951
 
821
952
  self._app.invalidate()
822
953
 
954
+ # ------------------------------------------------------------------
955
+ # Sidebar.
956
+ # ------------------------------------------------------------------
957
+
958
+ def _sidebar_beside(self, columns: int) -> bool:
959
+ """Whether a shown sidebar stands beside the page in a terminal ``columns`` wide."""
960
+ return columns - 2 - _SIDEBAR_WIDTH >= _MIN_PAGE_WIDTH_BESIDE_SIDEBAR
961
+
962
+ def _body_width(self) -> int:
963
+ """The width the page is rendered at: the terminal's, less the frame
964
+ border (1 char on each side) and a sidebar beside the page."""
965
+ columns = self._app.output.get_size().columns
966
+ if self._sidebar_visible and self._sidebar_beside(columns):
967
+ return columns - 2 - _SIDEBAR_WIDTH
968
+ return max(columns - 2, 40)
969
+
970
+ def _sidebar_has_focus(self) -> bool:
971
+ return self._app.layout.current_window is self._filter_window
972
+
973
+ def _main_area(self) -> Container:
974
+ """The body, with the sidebar beside it or over it while it is shown."""
975
+ if not self._sidebar_visible:
976
+ return self._body_window
977
+ if self._sidebar_beside(self._app.output.get_size().columns):
978
+ return self._sidebar_beside_area
979
+ return self._sidebar_over_area
980
+
981
+ def _show_sidebar(self) -> None:
982
+ """Show the sidebar, select the heading at the top of the page and focus the filter field."""
983
+ if not self._sidebar_visible:
984
+ self._sidebar_visible = True
985
+ self._reflow_for_sidebar()
986
+ self._select_entry_at_top()
987
+ self._app.layout.focus(self._filter_window)
988
+
989
+ def _hide_sidebar(self) -> None:
990
+ self._sidebar_visible = False
991
+ self._app.layout.focus(self._body_window)
992
+ self._reflow_for_sidebar()
993
+
994
+ def _reflow_for_sidebar(self) -> None:
995
+ """Render the page at the width the sidebar leaves it.
996
+
997
+ Only beside the page does the sidebar change that width. Unlike a
998
+ resize, the position is kept by heading: the last heading at or
999
+ above the top line stays at the same distance from the top. The
1000
+ search runs again on the new rendering; the history and the focused
1001
+ link are dropped as on a resize, because they hold positions of the
1002
+ old one.
1003
+ """
1004
+ body_width = self._body_width()
1005
+ if body_width == self._last_body_width:
1006
+ return
1007
+ if self._resize_task is not None:
1008
+ self._resize_task.cancel()
1009
+ self._resize_task = None
1010
+
1011
+ top = self._body_window.vertical_scroll
1012
+ heading: Optional[str] = None
1013
+ heading_line = 0
1014
+ for anchor, line in self._anchor_to_line.items():
1015
+ if line > top:
1016
+ break
1017
+ heading, heading_line = anchor, line
1018
+
1019
+ self._render_at_width(body_width)
1020
+
1021
+ new_line = self._anchor_to_line.get(heading, heading_line) if heading is not None else 0
1022
+ self._body_window.vertical_scroll = min(new_line + top - heading_line, self._plain.count("\n"))
1023
+ self._current_link = -1
1024
+ self._back_stack = []
1025
+ self._forward_stack = []
1026
+ self._matches = self._find_matches(self._search_query)
1027
+ self._current_match = min(max(self._current_match, 0), len(self._matches) - 1)
1028
+
1029
+ def _select_entry_at_top(self) -> None:
1030
+ """Select the listed entry of the last heading at or above the top line of the page."""
1031
+ top = self._body_window.vertical_scroll
1032
+ self._selected = 0 if self._listed else -1
1033
+ for row, index in enumerate(self._listed):
1034
+ line = self._anchor_to_line.get(self._outline[index].anchor)
1035
+ if line is None:
1036
+ continue
1037
+ if line > top:
1038
+ break
1039
+ self._selected = row
1040
+ self._scroll_selection_into_view()
1041
+
1042
+ def _apply_filter(self) -> None:
1043
+ """List the entries that match the filter text, as the patterns of
1044
+ `robotcode doc keywords` match: contains, `*` and `?`, case, spaces
1045
+ and underscores ignored.
1046
+
1047
+ An entry stays listed while it or one of the entries below it, the
1048
+ following entries of a greater level, matches. The selection moves
1049
+ to the first entry that matches itself, so it skips an entry that is
1050
+ only listed for the entries below it.
1051
+ """
1052
+ matcher = MultiMatcher([f"*{self._filter_buffer.text}*"], ignore="_")
1053
+ matches = [matcher.match(entry.title) for entry in self._outline]
1054
+ self._listed = []
1055
+ for index, entry in enumerate(self._outline):
1056
+ end = next(
1057
+ (i for i in range(index + 1, len(self._outline)) if self._outline[i].level <= entry.level),
1058
+ len(self._outline),
1059
+ )
1060
+ if any(matches[index:end]):
1061
+ self._listed.append(index)
1062
+ self._selected = next((row for row, index in enumerate(self._listed) if matches[index]), -1)
1063
+ self._outline_window.vertical_scroll = 0
1064
+ self._scroll_selection_into_view()
1065
+
1066
+ def _jump_to_selected(self) -> None:
1067
+ """Jump to the heading of the selected entry. Beside the page the
1068
+ sidebar stays and the page gets the focus; over it the sidebar closes."""
1069
+ if self._selected < 0:
1070
+ return
1071
+ self._jump_to_anchor(self._outline[self._listed[self._selected]].anchor)
1072
+ if self._sidebar_beside(self._app.output.get_size().columns):
1073
+ self._app.layout.focus(self._body_window)
1074
+ else:
1075
+ self._hide_sidebar()
1076
+
1077
+ def _compute_outline_fragments(self) -> StyleAndTextTuples:
1078
+ """The listed entries, one per line, indented by their level."""
1079
+ if not self._listed:
1080
+ return [("class:doc.sidebar.empty", " no matches")]
1081
+ width = _SIDEBAR_WIDTH - 1
1082
+ result: StyleAndTextTuples = []
1083
+ for row, index in enumerate(self._listed):
1084
+ entry = self._outline[index]
1085
+ if row == self._selected:
1086
+ style = "class:doc.sidebar.selected"
1087
+ elif entry.level == 2:
1088
+ style = "class:doc.sidebar.section"
1089
+ else:
1090
+ style = ""
1091
+ if row:
1092
+ result.append(("", "\n"))
1093
+ text = _fit(" " + " " * (entry.level - 2) + entry.title, width)
1094
+ result.append((style, text, self._make_outline_click_handler(row)))
1095
+ return result
1096
+
1097
+ def _make_outline_click_handler(self, row: int) -> Callable[[MouseEvent], object]:
1098
+ """A click on an entry jumps like `Enter`. Other mouse events fall
1099
+ through, so the wheel scrolls the list without changing the selection."""
1100
+
1101
+ def handler(event: MouseEvent) -> object:
1102
+ if event.event_type != MouseEventType.MOUSE_UP:
1103
+ return NotImplemented
1104
+ self._selected = row
1105
+ self._jump_to_selected()
1106
+ return None
1107
+
1108
+ return handler
1109
+
1110
+ def _move_selection(self, delta: int) -> None:
1111
+ """Move the selection by ``delta`` rows, within the listed entries."""
1112
+ if self._listed:
1113
+ self._selected = min(max(self._selected + delta, 0), len(self._listed) - 1)
1114
+ self._scroll_selection_into_view()
1115
+
1116
+ def _scroll_selection_into_view(self) -> None:
1117
+ """Scroll the list just enough to show the selected row."""
1118
+ if self._selected < 0:
1119
+ return
1120
+ info = self._outline_window.render_info
1121
+ # Before the sidebar's first render: the terminal's rows less the
1122
+ # frame, the footer, the filter field and the line below it.
1123
+ height = info.window_height if info is not None else max(1, self._app.output.get_size().rows - 5)
1124
+ top = self._outline_window.vertical_scroll
1125
+ if self._selected < top:
1126
+ self._outline_window.vertical_scroll = self._selected
1127
+ elif self._selected >= top + height:
1128
+ self._outline_window.vertical_scroll = self._selected - height + 1
1129
+
823
1130
  # ------------------------------------------------------------------
824
1131
  # Body content + footer text — called on each render.
825
1132
  # ------------------------------------------------------------------
@@ -860,12 +1167,19 @@ class DocViewer:
860
1167
  def _compute_footer_text(self) -> StyleAndTextTuples:
861
1168
  """Footer content depends on what's active:
862
1169
 
1170
+ - sidebar focused → the sidebar's keys
863
1171
  - link focused → show the focused target so the user knows
864
1172
  where ``f`` will take them
865
1173
  - search with matches → show the query + match counter
866
1174
  - search with no matches → friendly "no matches" line
867
- - none of the above → the default key hints
1175
+ - none of the above → the default key hints, with ``s`` on a
1176
+ document with a sidebar
1177
+
1178
+ While the sidebar stands beside the page, the hints say that
1179
+ ``Esc`` hides it before it closes the viewer.
868
1180
  """
1181
+ if self._sidebar_has_focus():
1182
+ return [("class:doc.footer", _SIDEBAR_HINTS)]
869
1183
  if self._current_link >= 0 and self._links:
870
1184
  _start, _end, target = self._links[self._current_link]
871
1185
  return [
@@ -873,7 +1187,10 @@ class DocViewer:
873
1187
  ("class:doc.footer.count", f"{self._current_link + 1}/{len(self._links)}"),
874
1188
  ("class:doc.footer", " → "),
875
1189
  ("class:doc.footer.count", target),
876
- ("class:doc.footer", " · f/Enter/click: follow · Tab/Shift-Tab: next/prev · q/Esc: close "),
1190
+ (
1191
+ "class:doc.footer",
1192
+ f" · f/Enter/click: follow · Tab/Shift-Tab: next/prev · {self._close_hint()} ",
1193
+ ),
877
1194
  ]
878
1195
  if self._search_query and self._matches:
879
1196
  return [
@@ -881,14 +1198,19 @@ class DocViewer:
881
1198
  ("class:doc.footer", self._search_query),
882
1199
  ("class:doc.footer", " "),
883
1200
  ("class:doc.footer.count", f"[{self._current_match + 1}/{len(self._matches)}]"),
884
- ("class:doc.footer", " · n/N: next/prev · /: new search · q/Esc: close "),
1201
+ ("class:doc.footer", f" · n/N: next/prev · /: new search · {self._close_hint()} "),
885
1202
  ]
886
1203
  if self._search_query:
887
1204
  return [
888
1205
  ("class:doc.footer.nomatch", f" /{self._search_query} — no matches "),
889
- ("class:doc.footer", " · /: search · q/Esc: close "),
1206
+ ("class:doc.footer", f" · /: search · {self._close_hint()} "),
890
1207
  ]
891
- return [("class:doc.footer", _HELP_HINTS)]
1208
+ if self._sidebar_visible:
1209
+ return [("class:doc.footer", _SIDEBAR_SHOWN_HELP_HINTS)]
1210
+ return [("class:doc.footer", _OUTLINE_HELP_HINTS if self._outline else _HELP_HINTS)]
1211
+
1212
+ def _close_hint(self) -> str:
1213
+ return "Esc: hide sidebar · q: close" if self._sidebar_visible else "q/Esc: close"
892
1214
 
893
1215
  # ------------------------------------------------------------------
894
1216
  # Search execution + navigation.
@@ -1020,15 +1342,7 @@ class DocViewer:
1020
1342
  return
1021
1343
  _start, _end, target = self._links[self._current_link]
1022
1344
  if target.startswith("#"):
1023
- line = self._anchor_to_line.get(target[1:])
1024
- if line is not None:
1025
- self._back_stack.append(self._current_state())
1026
- self._forward_stack.clear()
1027
- # `_max_scroll()` returns 0 before the first render —
1028
- # only clamp when we have real render_info, otherwise
1029
- # the jump always snaps to line 0.
1030
- max_scroll = self._max_scroll()
1031
- self._body_window.vertical_scroll = min(line, max_scroll) if max_scroll else line
1345
+ self._jump_to_anchor(target[1:])
1032
1346
  elif target.startswith(("http://", "https://")):
1033
1347
  try:
1034
1348
  webbrowser.open(target)
@@ -1041,6 +1355,20 @@ class DocViewer:
1041
1355
  self._forward_stack.clear()
1042
1356
  self._load_document(resolved[0], resolved[1])
1043
1357
 
1358
+ def _jump_to_anchor(self, anchor: str) -> None:
1359
+ """Scroll to the heading of ``anchor`` and push the current position
1360
+ onto the back stack. No-op if the anchor has no rendered line."""
1361
+ line = self._anchor_to_line.get(anchor)
1362
+ if line is None:
1363
+ return
1364
+ self._back_stack.append(self._current_state())
1365
+ self._forward_stack.clear()
1366
+ # `_max_scroll()` returns 0 before the first render —
1367
+ # only clamp when we have real render_info, otherwise
1368
+ # the jump always snaps to line 0.
1369
+ max_scroll = self._max_scroll()
1370
+ self._body_window.vertical_scroll = min(line, max_scroll) if max_scroll else line
1371
+
1044
1372
  def _go_back(self) -> bool:
1045
1373
  """Return to the previous position from the back stack.
1046
1374
 
@@ -1098,17 +1426,27 @@ class DocViewer:
1098
1426
  def _build_key_bindings(self) -> KeyBindings:
1099
1427
  kb = KeyBindings()
1100
1428
  # All viewer keys (scroll, close, search-open, n/N) are gated
1101
- # to "not in search mode" so they don't fire while the user is
1102
- # typing into the search bar.
1103
- not_searching = Condition(lambda: not self._in_search_mode)
1429
+ # to "the page has the focus" so they don't fire while the user
1430
+ # is typing into the search bar or the sidebar's filter field.
1431
+ on_page = Condition(lambda: not self._in_search_mode and not self._sidebar_has_focus())
1104
1432
  has_matches = Condition(lambda: bool(self._matches))
1105
1433
 
1106
- @kb.add("escape", eager=True, filter=not_searching)
1107
- @kb.add("q", filter=not_searching)
1434
+ @kb.add("q", filter=on_page)
1108
1435
  def _close(event: KeyPressEvent) -> None:
1109
1436
  event.app.exit()
1110
1437
 
1111
- @kb.add("enter", filter=not_searching)
1438
+ @kb.add("escape", eager=True, filter=on_page)
1439
+ def _escape(event: KeyPressEvent) -> None:
1440
+ # `Esc` closes what lies on top first: a sidebar shown beside
1441
+ # the page, then the viewer. (Over the page, the sidebar has
1442
+ # the focus while it is shown, and its own `Esc` hides it.)
1443
+ if self._sidebar_visible:
1444
+ self._hide_sidebar()
1445
+ event.app.invalidate()
1446
+ else:
1447
+ event.app.exit()
1448
+
1449
+ @kb.add("enter", filter=on_page)
1112
1450
  def _enter(event: KeyPressEvent) -> None:
1113
1451
  # With a link focused, follow it (same as `f`). Without
1114
1452
  # one, close the viewer — `q` / `Esc` do the same.
@@ -1118,58 +1456,58 @@ class DocViewer:
1118
1456
  else:
1119
1457
  event.app.exit()
1120
1458
 
1121
- @kb.add("j", filter=not_searching)
1122
- @kb.add("down", filter=not_searching)
1459
+ @kb.add("j", filter=on_page)
1460
+ @kb.add("down", filter=on_page)
1123
1461
  def _down(event: KeyPressEvent) -> None:
1124
1462
  self._scroll_by(1)
1125
1463
  event.app.invalidate()
1126
1464
 
1127
- @kb.add("k", filter=not_searching)
1128
- @kb.add("up", filter=not_searching)
1465
+ @kb.add("k", filter=on_page)
1466
+ @kb.add("up", filter=on_page)
1129
1467
  def _up(event: KeyPressEvent) -> None:
1130
1468
  self._scroll_by(-1)
1131
1469
  event.app.invalidate()
1132
1470
 
1133
- @kb.add("pagedown", filter=not_searching)
1134
- @kb.add("c-d", filter=not_searching)
1135
- @kb.add(" ", filter=not_searching)
1471
+ @kb.add("pagedown", filter=on_page)
1472
+ @kb.add("c-d", filter=on_page)
1473
+ @kb.add(" ", filter=on_page)
1136
1474
  def _page_down(event: KeyPressEvent) -> None:
1137
1475
  self._scroll_by(self._page_size())
1138
1476
  event.app.invalidate()
1139
1477
 
1140
- @kb.add("pageup", filter=not_searching)
1141
- @kb.add("c-u", filter=not_searching)
1142
- @kb.add("b", filter=not_searching)
1478
+ @kb.add("pageup", filter=on_page)
1479
+ @kb.add("c-u", filter=on_page)
1480
+ @kb.add("b", filter=on_page)
1143
1481
  def _page_up(event: KeyPressEvent) -> None:
1144
1482
  self._scroll_by(-self._page_size())
1145
1483
  event.app.invalidate()
1146
1484
 
1147
- @kb.add("g", filter=not_searching)
1148
- @kb.add("home", filter=not_searching)
1485
+ @kb.add("g", filter=on_page)
1486
+ @kb.add("home", filter=on_page)
1149
1487
  def _to_top(event: KeyPressEvent) -> None:
1150
1488
  self._body_window.vertical_scroll = 0
1151
1489
  event.app.invalidate()
1152
1490
 
1153
- @kb.add("G", filter=not_searching)
1154
- @kb.add("end", filter=not_searching)
1491
+ @kb.add("G", filter=on_page)
1492
+ @kb.add("end", filter=on_page)
1155
1493
  def _to_bottom(event: KeyPressEvent) -> None:
1156
1494
  self._body_window.vertical_scroll = self._max_scroll()
1157
1495
  event.app.invalidate()
1158
1496
 
1159
- @kb.add("/", filter=not_searching)
1497
+ @kb.add("/", filter=on_page)
1160
1498
  def _open_search(event: KeyPressEvent) -> None:
1161
1499
  self._search_buffer.reset()
1162
1500
  self._in_search_mode = True
1163
1501
  event.app.layout.focus(self._search_window)
1164
1502
  event.app.invalidate()
1165
1503
 
1166
- @kb.add("n", filter=not_searching & has_matches)
1504
+ @kb.add("n", filter=on_page & has_matches)
1167
1505
  def _next_match(event: KeyPressEvent) -> None:
1168
1506
  self._current_match = (self._current_match + 1) % len(self._matches)
1169
1507
  self._scroll_to_current_match()
1170
1508
  event.app.invalidate()
1171
1509
 
1172
- @kb.add("N", filter=not_searching & has_matches)
1510
+ @kb.add("N", filter=on_page & has_matches)
1173
1511
  def _prev_match(event: KeyPressEvent) -> None:
1174
1512
  self._current_match = (self._current_match - 1) % len(self._matches)
1175
1513
  self._scroll_to_current_match()
@@ -1184,7 +1522,7 @@ class DocViewer:
1184
1522
  # those keys on a doc that has no links.
1185
1523
  has_links = Condition(lambda: bool(self._links))
1186
1524
 
1187
- @kb.add("tab", filter=not_searching & has_links)
1525
+ @kb.add("tab", filter=on_page & has_links)
1188
1526
  def _next_link(event: KeyPressEvent) -> None:
1189
1527
  # When the focused link is already on screen, advance to
1190
1528
  # the next one. When the user has scrolled away from it
@@ -1203,7 +1541,7 @@ class DocViewer:
1203
1541
  self._scroll_link_into_view(self._current_link)
1204
1542
  event.app.invalidate()
1205
1543
 
1206
- @kb.add("s-tab", filter=not_searching & has_links)
1544
+ @kb.add("s-tab", filter=on_page & has_links)
1207
1545
  def _prev_link(event: KeyPressEvent) -> None:
1208
1546
  # Symmetric to Tab: when the focused link is on screen,
1209
1547
  # step backward; otherwise pick the last link at or
@@ -1222,7 +1560,7 @@ class DocViewer:
1222
1560
  self._scroll_link_into_view(self._current_link)
1223
1561
  event.app.invalidate()
1224
1562
 
1225
- @kb.add("f", filter=not_searching & has_links)
1563
+ @kb.add("f", filter=on_page & has_links)
1226
1564
  def _follow(event: KeyPressEvent) -> None:
1227
1565
  self._follow_current_link()
1228
1566
  event.app.invalidate()
@@ -1235,16 +1573,26 @@ class DocViewer:
1235
1573
  has_back = Condition(lambda: bool(self._back_stack))
1236
1574
  has_forward = Condition(lambda: bool(self._forward_stack))
1237
1575
 
1238
- @kb.add("[", filter=not_searching & has_back)
1576
+ @kb.add("[", filter=on_page & has_back)
1239
1577
  def _back(event: KeyPressEvent) -> None:
1240
1578
  if self._go_back():
1241
1579
  event.app.invalidate()
1242
1580
 
1243
- @kb.add("]", filter=not_searching & has_forward)
1581
+ @kb.add("]", filter=on_page & has_forward)
1244
1582
  def _forward(event: KeyPressEvent) -> None:
1245
1583
  if self._go_forward():
1246
1584
  event.app.invalidate()
1247
1585
 
1586
+ # ----- Sidebar ----------------------------------------------
1587
+ # `s` shows the sidebar, or moves the focus to a shown one. Only
1588
+ # documents loaded with `outline=True` have one.
1589
+ has_outline = Condition(lambda: bool(self._outline))
1590
+
1591
+ @kb.add("s", filter=on_page & has_outline)
1592
+ def _sidebar(event: KeyPressEvent) -> None:
1593
+ self._show_sidebar()
1594
+ event.app.invalidate()
1595
+
1248
1596
  return kb
1249
1597
 
1250
1598
  def _build_search_bindings(self) -> KeyBindings:
@@ -1259,3 +1607,28 @@ class DocViewer:
1259
1607
  event.app.invalidate()
1260
1608
 
1261
1609
  return kb
1610
+
1611
+ def _build_sidebar_bindings(self) -> KeyBindings:
1612
+ """Bindings attached to the sidebar's filter field — active only
1613
+ when it has the focus. Every other key is filter text."""
1614
+ kb = KeyBindings()
1615
+
1616
+ @kb.add("up")
1617
+ def _previous_entry(event: KeyPressEvent) -> None:
1618
+ self._move_selection(-1)
1619
+
1620
+ @kb.add("down")
1621
+ def _next_entry(event: KeyPressEvent) -> None:
1622
+ self._move_selection(1)
1623
+
1624
+ @kb.add("enter")
1625
+ def _jump(event: KeyPressEvent) -> None:
1626
+ self._jump_to_selected()
1627
+ event.app.invalidate()
1628
+
1629
+ @kb.add("escape", eager=True)
1630
+ def _hide(event: KeyPressEvent) -> None:
1631
+ self._hide_sidebar()
1632
+ event.app.invalidate()
1633
+
1634
+ return kb