@@ -269,6 +269,22 @@ class _RenderSnapshot(NamedTuple):
269269 anchor_to_line : Dict [str , int ]
270270
271271
272+ class _NavState (NamedTuple ):
273+ """One entry in the back / forward navigation stacks.
274+
275+ Captures everything needed to return to a position the user was at:
276+ the document itself (`title` + `md_source`) plus the scroll offset
277+ and focused link. Carrying the source means a follow that loaded a
278+ *different* document — e.g. a keyword page opened from a list — can
279+ be reversed by reloading the previous document, not just scrolling.
280+ """
281+
282+ title : str
283+ md_source : str
284+ scroll : int
285+ current_link : int
286+
287+
272288# Viewer-only style entries. The host interpreter's `_DEFAULT_STYLE`
273289# is for the prompt + log output; the viewer is its own Application
274290# with its own narrow palette, so we don't grow `_pt.components` for
@@ -459,7 +475,14 @@ class DocViewer:
459475 prompt's scrollback is undisturbed.
460476 """
461477
462- def __init__ (self ) -> None :
478+ def __init__ (
479+ self ,
480+ link_resolver : Optional [Callable [[str ], Optional [Tuple [str , str ]]]] = None ,
481+ ) -> None :
482+ # Resolves a followed link target that is neither an `#anchor`
483+ # nor an `http(s)://` URL into new `(title, markdown)` content,
484+ # loaded in place. Used to open a keyword's page from a list.
485+ self ._link_resolver = link_resolver
463486 self ._title = ""
464487
465488 # Body content: stored as fragments so we can re-render with
@@ -489,14 +512,13 @@ def __init__(self) -> None:
489512 self ._current_link = - 1
490513 self ._anchor_to_line : Dict [str , int ] = {}
491514
492- # Browser-style back / forward stacks for `#anchor` follows.
493- # Each entry is `(vertical_scroll, current_link)`; only the
494- # scroll position + focused link are restored, search state
495- # is intentionally untouched (going back shouldn't drop the
496- # user's current search). External URL follows don't push
497- # since the viewer itself doesn't move.
498- self ._back_stack : List [Tuple [int , int ]] = []
499- self ._forward_stack : List [Tuple [int , int ]] = []
515+ # Browser-style back / forward stacks for `#anchor` jumps and
516+ # in-place content follows (see `_NavState`). Search state is
517+ # intentionally untouched on back/forward (going back shouldn't
518+ # drop the user's current search). External URL follows don't
519+ # push since the viewer itself doesn't move.
520+ self ._back_stack : List [_NavState ] = []
521+ self ._forward_stack : List [_NavState ] = []
500522
501523 # Resize state. `_md_source` keeps the markdown around so we
502524 # can re-render at the new width when the terminal resizes.
@@ -601,6 +623,28 @@ def run(self, title: str, markdown: str) -> None:
601623 screen buffer, so the host prompt's terminal state survives
602624 the call untouched.
603625 """
626+ # A fresh top-level invocation starts with empty history.
627+ self ._back_stack = []
628+ self ._forward_stack = []
629+ self ._load_document (title , markdown )
630+
631+ try :
632+ self ._app .run ()
633+ finally :
634+ # Don't leak a pending reflow callback into the next
635+ # `.doc` invocation — if it fired against a closed app
636+ # the invalidate() at the end would be a no-op, but the
637+ # state shuffle would still run.
638+ if self ._resize_task is not None :
639+ self ._resize_task .cancel ()
640+ self ._resize_task = None
641+
642+ def _load_document (self , title : str , markdown : str ) -> None :
643+ """Adopt ``markdown`` as the current document and reset per-doc
644+ state (scroll, focused link, search). Leaves the back / forward
645+ stacks alone so it can be reused for in-place link follows;
646+ `run` clears them for a fresh top-level invocation.
647+ """
604648 self ._title = title
605649 self ._md_source = markdown
606650 # New doc → all cached renders are for the OLD markdown. Wipe.
@@ -610,27 +654,25 @@ def run(self, title: str, markdown: str) -> None:
610654 body_width = max (size .columns - 2 , 40 )
611655 self ._render_at_width (body_width )
612656
613- # Reset interaction state for a fresh document.
614657 self ._current_link = - 1
615- self ._back_stack = []
616- self ._forward_stack = []
617658 self ._in_search_mode = False
618659 self ._search_query = ""
619660 self ._matches = []
620661 self ._current_match = - 1
621662 self ._search_buffer .reset ()
622663 self ._body_window .vertical_scroll = 0
623664
624- try :
625- self ._app .run ()
626- finally :
627- # Don't leak a pending reflow callback into the next
628- # `.doc` invocation — if it fired against a closed app
629- # the invalidate() at the end would be a no-op, but the
630- # state shuffle would still run.
631- if self ._resize_task is not None :
632- self ._resize_task .cancel ()
633- self ._resize_task = None
665+ def _current_state (self ) -> _NavState :
666+ """Snapshot the current document + position for the nav stacks."""
667+ return _NavState (self ._title , self ._md_source , self ._body_window .vertical_scroll , self ._current_link )
668+
669+ def _restore_state (self , state : _NavState ) -> None :
670+ """Return to a `_NavState`, reloading its document first if the
671+ current one differs."""
672+ if state .md_source != self ._md_source :
673+ self ._load_document (state .title , state .md_source )
674+ self ._body_window .vertical_scroll = state .scroll
675+ self ._current_link = state .current_link
634676
635677 def _render_at_width (self , body_width : int ) -> None :
636678 """Render the markdown at ``body_width`` and adopt the result.
@@ -923,21 +965,22 @@ def _follow_current_link(self) -> None:
923965 didn't match the markdown source title). For `http(s)://`
924966 targets, hand off to the OS's default browser via
925967 `webbrowser.open` — caught so an SSH session or headless
926- environment can't take the viewer down.
927-
928- Anchor jumps push the current scroll + focused link onto the
929- back stack so `[` returns to the previous position
930- (browser-style). The forward stack is cleared because the
931- new jump branches the history. External URL follows don't
932- push — the viewer didn't move.
968+ environment can't take the viewer down. Any other target is
969+ handed to `link_resolver` (if configured); when it returns
970+ content, that document is loaded in place.
971+
972+ Anchor jumps and content follows push the current position onto
973+ the back stack so `[` returns to it (browser-style). The forward
974+ stack is cleared because the new jump branches the history.
975+ External URL follows don't push — the viewer didn't move.
933976 """
934977 if self ._current_link < 0 or not self ._links :
935978 return
936979 _start , _end , target = self ._links [self ._current_link ]
937980 if target .startswith ("#" ):
938981 line = self ._anchor_to_line .get (target [1 :])
939982 if line is not None :
940- self ._back_stack .append (( self ._body_window . vertical_scroll , self . _current_link ))
983+ self ._back_stack .append (self ._current_state ( ))
941984 self ._forward_stack .clear ()
942985 # `_max_scroll()` returns 0 before the first render —
943986 # only clamp when we have real render_info, otherwise
@@ -949,31 +992,34 @@ def _follow_current_link(self) -> None:
949992 webbrowser .open (target )
950993 except Exception :
951994 pass
995+ elif self ._link_resolver is not None :
996+ resolved = self ._link_resolver (target )
997+ if resolved is not None :
998+ self ._back_stack .append (self ._current_state ())
999+ self ._forward_stack .clear ()
1000+ self ._load_document (resolved [0 ], resolved [1 ])
9521001
9531002 def _go_back (self ) -> bool :
954- """Restore the previous scroll position from the back stack.
1003+ """Return to the previous position from the back stack.
9551004
9561005 Returns True if there was something to restore (so the
9571006 keybinding can decide whether to invalidate the layout).
9581007 Symmetric with `_go_forward` — pushing the *current* state
9591008 onto the opposite stack before popping, so back→forward
960- round-trips return the user to where they were.
1009+ round-trips return the user to where they were. The previous
1010+ document is reloaded if a content follow had replaced it.
9611011 """
9621012 if not self ._back_stack :
9631013 return False
964- self ._forward_stack .append ((self ._body_window .vertical_scroll , self ._current_link ))
965- scroll , link = self ._back_stack .pop ()
966- self ._body_window .vertical_scroll = scroll
967- self ._current_link = link
1014+ self ._forward_stack .append (self ._current_state ())
1015+ self ._restore_state (self ._back_stack .pop ())
9681016 return True
9691017
9701018 def _go_forward (self ) -> bool :
9711019 if not self ._forward_stack :
9721020 return False
973- self ._back_stack .append ((self ._body_window .vertical_scroll , self ._current_link ))
974- scroll , link = self ._forward_stack .pop ()
975- self ._body_window .vertical_scroll = scroll
976- self ._current_link = link
1021+ self ._back_stack .append (self ._current_state ())
1022+ self ._restore_state (self ._forward_stack .pop ())
9771023 return True
9781024
9791025 # ------------------------------------------------------------------
0 commit comments