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.
- robotcode/repl/Repl/repl.py +10 -1
- robotcode/repl/__version__.py +1 -1
- robotcode/repl/_debug/controller.py +47 -5
- robotcode/repl/_debug/types.py +6 -3
- robotcode/repl/_indent.py +8 -1
- robotcode/repl/_pt/components.py +2 -1
- robotcode/repl/_pt/doc_viewer.py +457 -84
- robotcode/repl/_pt/lexer.py +4 -1
- robotcode/repl/base_interpreter.py +281 -13
- robotcode/repl/cli.py +70 -13
- robotcode/repl/console_interpreter.py +356 -111
- robotcode/repl/doc_cli.py +651 -0
- robotcode/repl/hooks.py +2 -1
- robotcode/repl/prompt_toolkit_interpreter.py +35 -2
- robotcode/repl/run.py +51 -8
- {robotcode_repl-2.6.2.dist-info → robotcode_repl-2.8.0.dev548.dist-info}/METADATA +4 -3
- robotcode_repl-2.8.0.dev548.dist-info/RECORD +30 -0
- {robotcode_repl-2.6.2.dist-info → robotcode_repl-2.8.0.dev548.dist-info}/WHEEL +1 -1
- robotcode_repl-2.6.2.dist-info/RECORD +0 -29
- {robotcode_repl-2.6.2.dist-info → robotcode_repl-2.8.0.dev548.dist-info}/entry_points.txt +0 -0
robotcode/repl/_pt/doc_viewer.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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[
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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 /
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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(
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
(
|
|
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 ·
|
|
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 ·
|
|
1206
|
+
("class:doc.footer", f" · /: search · {self._close_hint()} "),
|
|
890
1207
|
]
|
|
891
|
-
|
|
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
|
-
|
|
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 "
|
|
1102
|
-
# typing into the search bar.
|
|
1103
|
-
|
|
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("
|
|
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("
|
|
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=
|
|
1122
|
-
@kb.add("down", filter=
|
|
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=
|
|
1128
|
-
@kb.add("up", filter=
|
|
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=
|
|
1134
|
-
@kb.add("c-d", filter=
|
|
1135
|
-
@kb.add(" ", filter=
|
|
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=
|
|
1141
|
-
@kb.add("c-u", filter=
|
|
1142
|
-
@kb.add("b", filter=
|
|
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=
|
|
1148
|
-
@kb.add("home", filter=
|
|
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=
|
|
1154
|
-
@kb.add("end", filter=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|