From 9b19bb0f7a4d569370acd921e8d498710549c79c Mon Sep 17 00:00:00 2001 From: Matthias Bussonnier Date: Wed, 28 Feb 2024 11:29:10 +0100 Subject: [PATCH] Update textual app to use rich rendering. Textual does not seem to be able to use textual theming, so we'll have to convert things at some point. This should probably replace urwid and the IPython ext, see #386 --- papyri/browser.py | 730 ------------------------------------------ papyri/ipython.py | 27 +- papyri/render.py | 2 +- papyri/rich_render.py | 8 +- papyri/textual.py | 41 ++- pyproject.toml | 1 - 6 files changed, 36 insertions(+), 773 deletions(-) delete mode 100755 papyri/browser.py diff --git a/papyri/browser.py b/papyri/browser.py deleted file mode 100755 index c60516f54..000000000 --- a/papyri/browser.py +++ /dev/null @@ -1,730 +0,0 @@ -""" -papyri browser -""" - -import sys -from typing import List - -import urwid -import urwid.raw_display - -# from there import syslogprint as LOG -from urwid import Text -from urwid.canvas import CompositeCanvas, apply_text_layout -from urwid.command_map import CURSOR_DOWN, CURSOR_LEFT, CURSOR_RIGHT, CURSOR_UP -from urwid.text_layout import calc_coords -from urwid.widget import LEFT, SPACE - -from papyri.crosslink import RefInfo, encoder -from papyri.config import ingest_dir -from papyri.myst_ast import MParagraph - - -class Link: - def __init__(self, attr, text, cb): - self.attr = attr - self.text = text - self.cb = cb - - def selectable(self): - return True - - -class TextWithLink(urwid.Text): - # _selectable = True - # ignore_focus = False - signals = ["change", "postchange"] - - def get_cursor_coords(self, size): - """ - Return the (*x*, *y*) coordinates of cursor within widget. - - >>> Edit("? ","yes").get_cursor_coords((10,)) - (5, 0) - """ - - if not self._focusable: - return None - - (maxcol,) = size - - # LOG(len(self.get_text()[0])) - trans = self.get_line_translation(maxcol) - - current_len = 0 - k = 0 - for item in self.markup: - if isinstance(item, Link): - if k == self.link_index: - break - else: - k += 1 - current_len += len(item.text) - else: - if isinstance(item, tuple): - assert len(item) == 2 - item = item[1] - if isinstance(item, list): - for it in item: - assert isinstance(it, str) - current_len += sum(len(x) for x in item) - elif isinstance(item, str): - current_len += len(item) - else: - assert False, (repr(item), repr(self.markup)) - - elif isinstance(item, str): - current_len += len(item) - - # LOG("FOCUS at pos", current_len) - x, y = calc_coords(self.get_text()[0], trans, current_len) - - return (x, y) - - def compute_focused(self, markup, focus): - nm = [] - k = 0 - for item in markup: - if isinstance(item, Link): - if k == self.link_index and focus: - nm.append(("link_selected", item.text)) - else: - nm.append((item.attr, item.text)) - k += 1 - else: - nm.append(item) - - self.max_links = k - return nm - - def __init__(self, markup, align=LEFT, wrap=SPACE, layout=None, cb=None): - self.link_index = 0 - self.max_links = None - - self.markup = markup - - self._focusable = len([x for x in markup if isinstance(x, Link)]) > 0 - self._selectable = self._focusable - self.ignore_focus = not self._focusable - - self.__super.__init__( - self.compute_focused(markup, False), align=LEFT, wrap=SPACE, layout=None - ) - - def keypress(self, size, key): - if not self._focusable: - return key - text, attr = self.get_text() - if self._command_map[key] in (CURSOR_LEFT, CURSOR_UP): - self.link_index -= 1 - if self.link_index < 0: - self.link_index = 0 - self.set_text(self.compute_focused(self.markup, True)) - # self._invalidate() - return "up" - self._invalidate() - return None - elif self._command_map[key] in (CURSOR_RIGHT, CURSOR_DOWN): - self.link_index += 1 - self.set_text(self.compute_focused(self.markup, True)) - if self.link_index >= self.max_links: - self.link_index = self.max_links - 1 - self._invalidate() - return "down" - self._invalidate() - return None - elif key == "enter": - k = 0 - for it in self.markup: - if isinstance(it, Link): - if k == self.link_index: - it.cb() - return None - k += 1 - assert False - - else: - self._invalidate() - return key - - def _render(self, size, focus=False): - """ - Render contents with wrapping and alignment. Return canvas. - - See :meth:`Widget.render` for parameter details. - - >>> Text(u"important things").render((18,)).text # ... = b in Python 3 - [...'important things '] - >>> Text(u"important things").render((11,)).text - [...'important ', ...'things '] - """ - (maxcol,) = size - self.set_text(self.compute_focused(self.markup, focus)) - text, attr = self.get_text() - # assert isinstance(text, unicode) - trans = self.get_line_translation(maxcol, (text, attr)) - - return apply_text_layout(text, attr, trans, maxcol) - - def render(self, size, focus=False): - """ - Render edit widget and return canvas. Include cursor when in - focus. - - >>> c = Edit("? ","yes").render((10,), focus=True) - >>> c.text # ... = b in Python 3 - [...'? yes '] - >>> c.cursor - (5, 0) - """ - (maxcol,) = size - self._shift_view_to_cursor = bool(focus) - - canv = self._render(size, focus) - if focus: - canv = CompositeCanvas(canv) - canv.cursor = self.get_cursor_coords(size) - - # .. will need to FIXME if I want highlight to work again - # if self.highlight: - # hstart, hstop = self.highlight_coords() - # d.coords['highlight'] = [ hstart, hstop ] - return canv - - -blank = urwid.Divider() - - -def dedup(l): - acc = [] - bk = False - for item in l: - if item is blank: - if bk is not True: - acc.append(item) - else: - acc.append(Text("<...>")) - - bk = True - else: - bk = False - acc.append(item) - return acc - - -def load(file_path, walk, qa, gen_content, frame): - blob = encoder.decode(file_path.read_bytes()) - assert hasattr(blob, "arbitrary") - for i in gen_content(blob, frame): - walk.append(i) - - -def guess_load(rough, walk, gen_content, stack, frame): - stack.append(rough) - - candidates = list(ingest_dir.glob(f"*/*/module/{rough}")) - if candidates: - for _q in range(len(walk)): - walk.pop() - try: - load(candidates[0], walk, rough, gen_content, frame) - return True - except Exception as e: - raise ValueError(str(candidates)) from e - return False - - -class Renderer: - def __init__(self, frame, walk, gen_content, stack): - self.frame = frame - self.walk = walk - self.gen_content = gen_content - self.stack = stack - - def cb(self, value): - # self.frame.footer = urwid.AttrWrap( - # urwid.Text(["Enter ?...: ", str(value)]), "header" - # ) - if value.__class__.__name__ == "RefInfo": - guess_load(value.path, self.walk, self.gen_content, self.stack, self.frame) - elif isinstance(value, str): - guess_load(value, self.walk, self.gen_content, self.stack, self.frame) - - def render(self, obj): - name = obj.__class__.__name__ - method = getattr(self, "render_" + name, None) - if not method: - return urwid.Text(("unknown", "<" + obj.__class__.__name__ + ">")) - - return method(obj) - - def render_Math(self, d): - cont = "".join(d.value) - from flatlatex import converter - - c = converter() - return ("math", c.convert(cont)) - - def render_Directive(self, d): - cont = "".join(d.value) - if d.role == "math": - assert False - from flatlatex import converter - - c = converter() - - return ("math", c.convert(cont)) - return Text(("directive", f"{d.domain}:{d.role}:`{cont}`")) - - def render_Example(self, ex): - acc = [] - for line in ex.lines: - acc.append(Text(line._line)) - a = urwid.Pile(acc) - acc = [] - for line in ex.ind: - acc.append(Text(line._line)) - b = urwid.Padding(urwid.Pile(acc), left=4) - return urwid.Pile([a, b]) - - def render_Link(self, link): - if link.reference.kind == "local": - return Text(("local", link.value)) - return TextWithLink("link", link.value, lambda: self.cb(link.reference)) - - def render_BlockQuote(self, quote): - return urwid.Padding( - urwid.Pile([self.render(c) for c in quote.children]), left=4, right=4 - ) - - def render_MAdmonition(self, adm): - kind = adm.kind - if hasattr(adm, "title"): - title = (f"{kind} : {adm.title}",) - else: - title = f"{kind.capitalize()}" - if kind == "versionchanged": - title = "Changed in Version " + adm.title - if kind == "versionadded": - title = "Added in Version " + adm.title - if kind == "deprecated": - title = "Deprecated since " + adm.title - return urwid.Padding( - urwid.LineBox( - urwid.Pile([self.render(c) for c in adm.children]), - title=title, - title_align="left", - ), - ) - - def render_MText(self, text): - return urwid.Text(text.value) - - def render_MParagraph(self, paragraph): - stuff = [self.render(c) for c in paragraph.children] - return urwid.Pile(stuff) - - def render_BlockMath(self, math): - from flatlatex import converter - - c = converter() - return urwid.Padding(urwid.Text(("math", c.convert(math.value))), left=2) - - def render_BlockDirective(self, directive): - raise NotImplementedError("We should nt have block directive in the end") - - def render_SeeAlsoItem(self, sa): - return urwid.Pile( - [ - TextWithLink( - [ - Link( - "link" if sa.name.exists else "link-broken", - sa.name.value, - lambda: self.cb(sa.name.reference), - ) - ] - ), - urwid.Padding( - urwid.Pile([self.render(x) for x in sa.descriptions]), left=2 - ), - ] - ) - - def render_Paragraph(self, paragraph): - if any([isinstance(x, MParagraph) for x in paragraph.children]): - assert len(paragraph.children) == 1 - return self.render_Paragraph(paragraph.children[0]) - - cc = paragraph.children - if not cc: - return urwid.Text("EMPTY") - rr = None - try: - rr = [TextWithLink([self.render(o) for o in paragraph.children])] - return urwid.Pile(rr) - except Exception: - raise ValueError(cc, rr) - - def render_Section(self, section): - if section.title: - acc = [Text(("section", section.title))] - else: - acc = [] - for c in section.children: - acc.append(self.render(c)) - # acc.append(Text("
")) - acc.append(blank) - - return urwid.Padding( - urwid.Pile(dedup(acc)), - left=2, - right=2, - ) - - def render_DefList(self, deflist): - p = [blank] - for c in deflist.children: - assert c.__class__.__name__ == "DefListItem", c.__class__.__name__ - res = self.render(c) - assert isinstance(res, list) - p.extend(res) - return urwid.Pile(p) - - def render_EnumeratedList(self, elist): - p = [blank] - for i, c in enumerate(elist.children, start=1): - res = self.render(c) - p.extend([urwid.Columns([(3, urwid.Text(f"{i}.")), res])]) - return urwid.Pile(p) - - def render_DefListItem(self, item): - return [ - self.render(item.dt), - # urwid.Button(str(item.dt)), - urwid.Padding( - urwid.Pile([self.render(p) for p in item.dd]), - left=2, - ), - blank, - ] - - def render_Fig(self, fig): - def show_fig(name): - cand = next(ingest_dir.glob(f"*/*/assets/{name}")) - import subprocess - - subprocess.Popen( - ["qlmanage", "-p", cand], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - if sys.platform == "darwin": - - def _cb(): - show_fig(fig.value) - - msg = "Open with quicklook" - else: - - def _cb(): - pass - - msg = "Open in separate window (Not implemented on this platform)" - - return TextWithLink( - [ - ("", "Figure not available in terminal : "), - Link( - "verbatim", - msg, - _cb, - ), - ] - ) - - def render_Code2(self, code): - # entries/out/ce_status - - def insert_prompt(entries): - yield ( - "verbatim", - ">>>", - # lambda: self.cb("likely copy content to clipboard"), - ) - yield (None, " ") - for e in entries: - type_ = e.type - maybe_link = e.link - if maybe_link.__class__.__name__ == "Link": - assert isinstance(maybe_link.reference, RefInfo) - yield Link( - "pyg-" + str(type_), - maybe_link.value, - (lambda m: (lambda: self.cb(m.reference)))(maybe_link), - ) - else: - if maybe_link == "\n": - yield (None, "\n") - yield ("verbatim", "... ") - else: - yield ("pyg-" + str(type_), f"{maybe_link}") - - return urwid.Padding( - urwid.Pile( - [TextWithLink([x for x in insert_prompt(code.entries)])] - + ([Text(code.out)] if code.out else []), - ), - left=2, - ) - - def render_Code(self, code): - # entries/out/ce_status - - def insert_prompt(entries): - yield Link( - "verbatim", - ">>>", - lambda: self.cb("likely copy content to clipboard"), - ) - yield (None, " ") - for txt, _ref, css in entries: - if txt == "\n": - yield (None, "\n") - yield ("verbatim", "... ") - else: - yield ("pyg-" + str(css), f"{txt}") - - return urwid.Padding( - urwid.Pile( - [TextWithLink([x for x in insert_prompt(code.entries)])] - + ([Text(code.out)] if code.out else []), - ), - left=2, - ) - - def render_Parameters(self, parameters): - return urwid.Pile([self.render_Param(c) for c in parameters.children]) - - def render_Param(self, param): - return urwid.Pile( - [ - Text( - [ - ("param", param.param), - " : ", - ("type", param.type_), - ] - ), - urwid.Padding( - urwid.Pile([self.render(d) for d in param.desc]), - left=3, - right=2, - min_width=20, - ), - ] - ) - - def render_SignatureNode(self, sig): - if "Empty" in str(sig.return_annotation): - annotation = "None" - else: - annotation = ("signature", sig.return_annotation) - return [ - ("signature", "("), - [("param", f"{p.name}, ") for p in sig.parameters], - ("signature", ")"), - ("signature", " -> "), - annotation, - ] - - -def main(qualname: str): - if not isinstance(qualname, str): - from types import ModuleType - - if isinstance(qualname, ModuleType): - qualname = qualname.__name__ - else: - qualname = qualname.__module__ + "." + qualname.__qualname__ - - def gen_content(blob, frame): - R = Renderer(frame, walk, gen_content, stack) - doc = [] - doc.append(blank) - if blob.signature: - doc.append(Text(("section", "Signature"))) - doc.append(blank) - doc.append( - Text( - [ - ("signature", blob.signature.kind), - (" "), - ("bb", qualname), - (" "), - R.render(blob.signature), - ] - ), - ) - doc.append(blank) - - for k, v in blob.content.items(): - if not v.empty(): - if k not in ["Summary", "Extended Summary"]: - doc.append(Text([("section", k)])) - # doc.append(Text("")) - doc.append(blank) - doc.append(R.render(v)) - if blob.see_also: - doc.append(Text(("section", "See Also"))) - doc.append(blank) - for s in blob.see_also: - doc.append(urwid.Padding(R.render(s), left=2)) - doc.append(blank) - - if not blob.example_section_data.empty(): - doc.append(Text(("section", "Examples"))) - doc.append(blank) - doc.append(R.render(blob.example_section_data)) - - if blob.item_type and ("module" in blob.item_type): - for s in blob.arbitrary: - doc.append(R.render(s)) - - if []: # todo backrefs - doc.append(Text(("section", "Back References"))) - doc.append(Text("All the following items Refer to this page:")) - for b in []: # backrefs: - doc.append( - urwid.Padding( - TextWithLink( - [Link("link", b.path, (lambda x: lambda: R.cb(x))(b))] - ), - # TextWithLink([Link("param", b, lambda: R.cb(b))]), - left=2, - ) - ) - doc = dedup(doc) - - doc.append(blank) - doc.append(blank) - doc.append(blank) - - def cb(value): - def callback(): - assert isinstance(value, str) - frame.footer = urwid.AttrWrap( - urwid.Text(["Enter ?...: ", value]), "header" - ) - - return callback - - return doc - - stack: List[str] = [] - - walk = urwid.SimpleListWalker([]) - listbox = urwid.ListBox(walk) - frame = urwid.Frame(urwid.AttrWrap(listbox, "body")) # , header=header) - frame.footer = urwid.AttrWrap( - urwid.Text( - "q: quit | ?: classic IPython help screen | Arrow/Click: focus links & navigate | enter: follow link" - ), - "header", - ) - - found = guess_load(qualname, walk, gen_content, stack, frame) - if not found: - return False - - # header = urwid.AttrWrap(Text("numpy.geomspace"), "header") - # 'black' - # 'dark red' - # 'dark green' - # 'brown' - # 'dark blue' - # 'dark magenta' - # 'dark cyan' - # 'light gray' - # 'dark gray' - # 'light red' - # 'light green' - # 'yellow' - # 'light blue' - # 'light magenta' - # 'light cyan' - # 'white' - - palette = [ - ("body", "default", "default", "standout"), - ("reverse", "light gray", "black"), - ("header", "white", "dark blue", "bold"), - ("bb", "bold", "default", ("standout", "underline")), - ("important", "dark red,bold", "default", ("standout", "underline")), - ("editfc", "white", "dark blue", "bold"), - ("editbx", "light gray", "dark blue"), - ("editcp", "black", "light gray", "standout"), - ("bright", "dark gray", "light gray", ("bold", "standout")), - ("buttn", "black", "dark cyan"), - ("buttnf", "white", "dark blue", "bold"), - ("verbatim", "brown", "", "bold"), - ("emph", "dark blue", "", "underline"), - ("strgon", "dark blue", "", "bold"), - # ("link", "dark red,bold", "default", ("standout", "underline")), - ("local", "light magenta", "", "bold"), - ("link", "dark green,underline", "", "bold"), - ("link_selected", "dark green,bold", "", "bold"), - ("link_selected", "black,bold", "white"), - ("link-broken", "dark red,strikethrough", "", "bold"), - ("type", "dark cyan", "", "bold"), - ("signature", "yellow,bold", "", "bold"), - ("param", "dark blue", "", "bold"), - ("section", "dark magenta,bold", "", "bold"), - ("unknown", "white", "dark red", "bold"), - ("directive", "white", "dark red", "bold"), - ("math", "dark magenta,italics", "", "bold"), - # pygments - ("pyg-o", "dark blue", "", "bold"), # operator (+, .) - ("pyg-mi", "dark red", "", "bold"), # number literal 12, 55 - ("pyg-kc", "dark green", "", "bold"), - ("pyg-nb", "white", "", "bold"), - ("pyg-kn", "dark green", "", "bold"), # keyword import - ("pyg-nn", "dark blue", "", "bold"), # name - ("pyg-k", "dark green", "", "bold"), # keyword as - ("pyg-s2", "dark green", "", "bold"), # strings, like "this is a string s2" - ("pyg-sa", "dark green", "", "bold"), # string brefixes like b"", u"" r"" - ] - - screen = urwid.raw_display.Screen() - found = True - - def unhandled(key): - nonlocal found - if key == "?": - found = False - raise urwid.ExitMainLoop() - elif key == "q": - raise urwid.ExitMainLoop() - elif key == "backspace": - if len(stack) >= 2: - stack.pop() - old = stack.pop() - guess_load(old, walk, gen_content, stack, frame) - - urwid.MainLoop(frame, palette, screen, unhandled_input=unhandled).run() - return found - - -def setup() -> None: - import sys - - target: str - target = sys.argv[1] - assert isinstance(target, str) - res = main(target) - print(res) - - -if "__main__" == __name__: - setup() diff --git a/papyri/ipython.py b/papyri/ipython.py index 7be11b0cf..e1ced4082 100644 --- a/papyri/ipython.py +++ b/papyri/ipython.py @@ -6,33 +6,32 @@ # The class MUST call this class decorator at creation time @magics_class class Papyri(Magics): + @line_magic def pinfo(self, parameter_s="", namespaces=None): """Provide detailed information about an object. '%pinfo object' is just a synonym for object? or ?object.""" - from papyri.browser import main + from papyri.textual import main from papyri.gen import full_qual pinfo, qmark1, oname, qmark2 = re.match( r"(pinfo )?(\?*)(.*?)(\??$)", parameter_s ).groups() - if _ := main(parameter_s): - return - else: - parts_1 = oname.split(".") - other = [] - name, *other = parts_1 - obj = self.shell.user_ns.get(name, None) - for o in other: - obj = getattr(obj, o) - if obj is not None: - qa = full_qual(obj) - if _ := main(qa): - return + parts_1 = oname.split(".") + other = [] + name, *other = parts_1 + + obj = self.shell.user_ns.get(name, None) + for o in other: + obj = getattr(obj, o) + if obj is not None: + qa = full_qual(obj) + if _ := main(qa): + return # print 'pinfo par: <%s>' % parameter_s # dbg # detail_level: 0 -> obj? , 1 -> obj?? diff --git a/papyri/render.py b/papyri/render.py index 8dd583fb7..75696fd59 100644 --- a/papyri/render.py +++ b/papyri/render.py @@ -1470,7 +1470,7 @@ async def rich_render( ) try: for it in _rich_render(key, store): - console.print(it) + console.print(str(type(it))) except Exception as e: e.add_note(f"rendering {key=}") raise diff --git a/papyri/rich_render.py b/papyri/rich_render.py index c9478e6a0..9e2535085 100644 --- a/papyri/rich_render.py +++ b/papyri/rich_render.py @@ -175,7 +175,7 @@ def visit_MEmphasis(self, node): return self.generic_visit(node.children) def visit_MInlineCode(self, node): - return RToken(node.value, "m.inline_code").partition() + return RToken(node.value, "cyan").partition() def visit_MList(self, node): return [pad(RichBlocks(self.generic_visit(node.children), "mlist"))] @@ -200,7 +200,7 @@ def visit_Directive(self, node): content += f":{node.role}:" content += f"`{node.value}`" - return RToken(content, "m.directive").partition() + return RToken(content, "cyan").partition() def visit_MLink(self, node): return self.generic_visit(node.children) @@ -234,9 +234,9 @@ def visit_MHeading(self, node: MHeading): def visit_Param(self, node): cs = [ - RToken(node.param, "param"), + RToken(node.param, "cyan"), RToken(" : "), - RToken(node.type_, "param_type"), + RToken(node.type_, "cyan"), RToken("\n"), ] sub = self.generic_visit(node.desc) diff --git a/papyri/textual.py b/papyri/textual.py index 9c6b620ba..e2338f93d 100644 --- a/papyri/textual.py +++ b/papyri/textual.py @@ -56,13 +56,12 @@ def render(self) -> RenderResult: class Body(Static): - def __init__(self, title=None, body=None): + def __init__(self, item): super().__init__() - self.title = title - self.body = body + self.item = item def render(self) -> RenderResult: - return f"{self.title}: \n {self.body}" + return self.item class PapyriApp(App): @@ -73,27 +72,32 @@ class PapyriApp(App): ] CSS_PATH = Path("static/papyri.tcss") - def run(self, qualname=None, blob=None, **kwargs): - self.qualname = qualname - self.blob = blob + def run(self, name, **kwargs): + from papyri.graphstore import GraphStore + from papyri.render import _rich_render + store = GraphStore(ingest_dir, {}) + key = next(iter(store.glob((None, None, "module", name)))) + + self.things = _rich_render(key, store) super().run(**kwargs) def compose(self) -> ComposeResult: """ Renders the layout on screen. """ + from rich.console import Group - if self.blob.signature: - signature = self.blob.signature - else: - signature = None + #if self.blob.signature: + # signature = self.blob.signature + #else: + # signature = None # content = str(self.blob.content) yield Container( Header(), - Signature(qualname=self.qualname, signature=signature), + #Signature(qualname=self.qualname, signature=signature), VerticalScroll( - *[Body(title=k, body=v) for k, v in self.blob.content.items()] + *[Body(t) for t in self.things] ), Footer(), ) @@ -124,18 +128,9 @@ def guess_load(qualname): def main(qualname: str): - if not isinstance(qualname, str): - from types import ModuleType - - if isinstance(qualname, ModuleType): - qualname = qualname.__name__ - else: - qualname = qualname.__module__ + "." + qualname.__qualname__ - - blob = guess_load(qualname) app = PapyriApp() - app.run(qualname, blob) + app.run(qualname) def setup() -> None: diff --git a/pyproject.toml b/pyproject.toml index aeedc0a7b..ba4e7f8f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ dependencies = [ "rich", "tomli_w", "typer>=0.9", - "urwid", "velin", # "tree_sitter", # tree sitter does not currently provide builds for all platform. tree-sitter-builds is an # alternative build that provide more wheels.