diff --git a/pudb/debugger.py b/pudb/debugger.py index 45f1ca8e..662bc6d1 100644 --- a/pudb/debugger.py +++ b/pudb/debugger.py @@ -529,7 +529,8 @@ def _runmodule(self, module_name): # UI stuff -------------------------------------------------------------------- from pudb.ui_tools import make_hotkey_markup, labelled_value, \ - SelectableText, SignalWrap, StackFrame, BreakpointFrame + SelectableText, SignalWrap, StackFrame, BreakpointFrame, \ + Caption, CaptionParts from pudb.var_view import FrameVarInfoKeeper @@ -858,7 +859,7 @@ def helpside(w, size, key): ], dividechars=1) - self.caption = urwid.Text("") + self.caption = Caption(CaptionParts._make([(None, "")]*4)) header = urwid.AttrMap(self.caption, "header") self.top = SignalWrap(urwid.Frame( urwid.AttrMap(self.columns, "background"), @@ -2617,26 +2618,26 @@ def interaction(self, exc_tuple, show_exc_dialog=True): self.current_exc_tuple = exc_tuple from pudb import VERSION - caption = [(None, - "PuDB %s - ?:help n:next s:step into b:breakpoint " - "!:python command line" - % VERSION)] + pudb_version = (None, "PuDB %s" % VERSION) + hotkey = (None, "?:help") + if self.source_code_provider.get_source_identifier(): + filename = (None, self.source_code_provider.get_source_identifier()) + else: + filename = (None, "source filename is unavailable") + optional_alert = (None, "") if self.debugger.post_mortem: if show_exc_dialog and exc_tuple is not None: self.show_exception_dialog(exc_tuple) - caption.extend([ - (None, " "), - ("warning", "[POST-MORTEM MODE]") - ]) + optional_alert = ("warning", "[POST-MORTEM MODE]") + elif exc_tuple is not None: - caption.extend([ - (None, " "), - ("warning", "[PROCESSING EXCEPTION - hit 'e' to examine]") - ]) + optional_alert = \ + ("warning", "[PROCESSING EXCEPTION, hit 'e' to examine]") - self.caption.set_text(caption) + self.caption.set_text(CaptionParts( + pudb_version, hotkey, filename, optional_alert)) self.event_loop() def set_source_code_provider(self, source_code_provider, force_update=False): diff --git a/pudb/ui_tools.py b/pudb/ui_tools.py index 4931ff41..9bf212e7 100644 --- a/pudb/ui_tools.py +++ b/pudb/ui_tools.py @@ -332,4 +332,94 @@ def keypress(self, size, key): return result + +from collections import namedtuple +caption_parts = ["pudb_version", "hotkey", "full_source_filename", "optional_alert"] +CaptionParts = namedtuple( + "CaptionParts", + caption_parts, + ) + + +class Caption(urwid.Text): + """ + A text widget that will automatically shorten its content + to fit in 1 row if needed + """ + + def __init__(self, caption_parts, separator=(None, " - ")): + self.separator = separator + super().__init__(caption_parts) + + def __str__(self): + caption_text = self.separator[1].join( + [part[1] for part in self.caption_parts]).rstrip(self.separator[1]) + return caption_text + + @property + def markup(self): + """ + Returns markup of str(self) by inserting the markup of + self.separator between each item in self.caption_parts + """ + + # Reference: https://stackoverflow.com/questions/5920643/add-an-item-between-each-item-already-in-the-list # noqa + markup = [self.separator] * (len(self.caption_parts) * 2 - 1) + markup[0::2] = self.caption_parts + if not self.caption_parts.optional_alert[1]: + markup = markup[:-2] + return markup + + def render(self, size, focus=False): + markup = self._get_fit_width_markup(size) + return urwid.Text(markup).render(size) + + def set_text(self, caption_parts): + markup = [(attr, str(content)) for (attr, content) in caption_parts] + self.caption_parts = CaptionParts._make(markup) + super().set_text(markup) + + def rows(self, size, focus=False): + # Always return 1 to avoid + # AssertionError: `assert head.rows() == hrows, "rows, render mismatch")` + # in urwid.Frame.render() in urwid/container.py + return 1 + + def _get_fit_width_markup(self, size): + if urwid.Text(str(self)).rows(size) == 1: + return self.markup + filename_markup_index = 4 + maxcol = size[0] + markup = self.markup + markup[filename_markup_index] = ( + markup[filename_markup_index][0], + self._get_shortened_source_filename(size)) + caption = urwid.Text(markup) + while True: + if caption.rows(size) == 1: + return markup + else: + for i in range(len(markup)): + clip_amount = len(caption.get_text()[0]) - maxcol + markup[i] = (markup[i][0], markup[i][1][clip_amount:]) + caption = urwid.Text(markup) + + def _get_shortened_source_filename(self, size): + import os + maxcol = size[0] + + occupied_width = len(str(self)) - \ + len(self.caption_parts.full_source_filename[1]) + available_width = max(0, maxcol - occupied_width) + trim_index = len( + self.caption_parts.full_source_filename[1]) - available_width + filename = self.caption_parts.full_source_filename[1][trim_index:] + + if self.caption_parts.full_source_filename[1][trim_index-1] == os.sep: + #filename starts with the full name of a directory or file + return filename + else: + first_path_sep_index = filename.find(os.sep) + filename = filename[first_path_sep_index + 1:] + return filename # }}} diff --git a/test/test_caption.py b/test/test_caption.py new file mode 100644 index 00000000..232da96e --- /dev/null +++ b/test/test_caption.py @@ -0,0 +1,175 @@ +from pudb.ui_tools import Caption, CaptionParts +import pytest +import urwid + + +@pytest.fixture +def text_markups(): + from collections import namedtuple + Markups = namedtuple("Markups", + ["pudb_version", "hotkey", "full_source_filename", + "alert", "default_separator", "custom_separator"]) + + pudb_version = (None, "PuDB VERSION") + hotkey = (None, "?:help") + full_source_filename = (None, "/home/foo - bar/baz.py") + alert = ("warning", "[POST-MORTEM MODE]") + default_separator = (None, " - ") + custom_separator = (None, " | ") + return Markups(pudb_version, hotkey, full_source_filename, + alert, default_separator, custom_separator) + + +@pytest.fixture +def captions(text_markups): + empty = CaptionParts._make([(None, "")]*4) + always_display = [ + text_markups.pudb_version, text_markups.hotkey, + text_markups.full_source_filename] + return {"empty": Caption(empty), + "without_alert": Caption(CaptionParts._make( + always_display + [(None, "")])), + "with_alert": Caption(CaptionParts._make( + always_display + [text_markups.alert])), + "custom_separator": Caption(CaptionParts._make( + always_display + [(None, "")]), + separator=text_markups.custom_separator), + } + + +@pytest.fixture +def term_sizes(): + def _term_sizes(caption): + caption_length = len(str(caption)) + full_source_filename = caption.caption_parts.full_source_filename[1] + cut_only_filename = ( + max(1, caption_length - len(full_source_filename) + 5), ) + cut_more_than_filename = (max(1, caption_length + - len(full_source_filename) - len("PuDB VE")), ) + return {"wider_than_caption": (caption_length + 1, ), + "equals_caption": (max(1, caption_length), ), + "narrower_than_caption": (max(1, caption_length - 10), ), + "cut_only_filename": cut_only_filename, + "cut_more_than_filename": cut_more_than_filename, + "one_col": (1, ), + "cut_at_path_sep": (max(1, caption_length - 1), ), + "lose_some_dir": (max(1, caption_length - 2), ), + "lose_all_dir": (max(1, + caption_length - len("/home/foo - bar/")), ), + "lose_some_filename_chars": (max(1, + caption_length - len("/home/foo - bar/ba")), ), + "lose_all_source": (max(1, + caption_length - len("/home/foo - bar/baz.py")), ), + } + return _term_sizes + + +def test_init(captions): + for key in ["empty", "without_alert", "with_alert"]: + assert captions[key].separator == (None, " - ") + assert captions["custom_separator"].separator == (None, " | ") + + +def test_str(captions): + assert str(captions["empty"]) == "" + assert str(captions["without_alert"]) \ + == "PuDB VERSION - ?:help - /home/foo - bar/baz.py" + assert str(captions["with_alert"]) \ + == "PuDB VERSION - ?:help - /home/foo - bar/baz.py - [POST-MORTEM MODE]" + assert str(captions["custom_separator"]) \ + == "PuDB VERSION | ?:help | /home/foo - bar/baz.py" + + +def test_markup(captions): + assert captions["empty"].markup \ + == [(None, ""), (None, " - "), + (None, ""), (None, " - "), + (None, "")] + + assert captions["without_alert"].markup \ + == [(None, "PuDB VERSION"), (None, " - "), + (None, "?:help"), (None, " - "), + (None, "/home/foo - bar/baz.py")] + + assert captions["with_alert"].markup \ + == [(None, "PuDB VERSION"), (None, " - "), + (None, "?:help"), (None, " - "), + (None, "/home/foo - bar/baz.py"), (None, " - "), + ("warning", "[POST-MORTEM MODE]")] + + assert captions["custom_separator"].markup \ + == [(None, "PuDB VERSION"), (None, " | "), + (None, "?:help"), (None, " | "), + (None, "/home/foo - bar/baz.py")] + + +def test_render(captions, term_sizes): + for caption in captions.values(): + for size in term_sizes(caption).values(): + got = caption.render(size) + markup = caption._get_fit_width_markup(size) + expected = urwid.Text(markup).render(size) + assert list(expected.content()) == list(got.content()) + + +def test_set_text(captions): + assert captions["empty"].caption_parts == CaptionParts._make([(None, "")]*4) + assert captions["without_alert"].caption_parts \ + == CaptionParts( + (None, "PuDB VERSION"), + (None, "?:help"), + (None, "/home/foo - bar/baz.py"), + (None, "")) + assert captions["with_alert"].caption_parts \ + == CaptionParts( + (None, "PuDB VERSION"), + (None, "?:help"), + (None, "/home/foo - bar/baz.py"), + ("warning", "[POST-MORTEM MODE]")) + + +def test_rows(captions): + for caption in captions.values(): + assert caption.rows(size=(99999, 99999)) == 1 + assert caption.rows(size=(80, 24)) == 1 + assert caption.rows(size=(1, 1)) == 1 + + +def test_get_fit_width_markup(captions, term_sizes): + # No need to check empty caption because + # len(str(caption)) == 0 always smaller than min terminal column == 1 + caption = captions["with_alert"] + sizes = term_sizes(caption) + assert caption._get_fit_width_markup(sizes["equals_caption"]) \ + == [(None, "PuDB VERSION"), (None, " - "), + (None, "?:help"), (None, " - "), + (None, "/home/foo - bar/baz.py"), (None, " - "), + ("warning", "[POST-MORTEM MODE]")] + assert caption._get_fit_width_markup(sizes["cut_only_filename"]) \ + == [(None, "PuDB VERSION"), (None, " - "), + (None, "?:help"), (None, " - "), + (None, "az.py"), (None, " - "), ("warning", "[POST-MORTEM MODE]")] + assert caption._get_fit_width_markup(sizes["cut_more_than_filename"]) \ + == [(None, "RSION"), (None, " - "), + (None, "?:help"), (None, " - "), + (None, ""), (None, " - "), ("warning", "[POST-MORTEM MODE]")] + assert caption._get_fit_width_markup(sizes["one_col"]) \ + == [(None, "")]*6 + [("warning", "]")] + + +def test_get_shortened_source_filename(captions, term_sizes): + # No need to check empty caption because + # len(str(caption)) == 0 always smaller than min terminal column == 1 + for k in ["with_alert", "without_alert", "custom_separator"]: + sizes = term_sizes(captions[k]) + assert captions[k]._get_shortened_source_filename(sizes["cut_at_path_sep"]) \ + == "home/foo - bar/baz.py" + assert captions[k]._get_shortened_source_filename(sizes["lose_some_dir"]) \ + == "foo - bar/baz.py" + assert captions[k]._get_shortened_source_filename(sizes["lose_all_dir"]) \ + == "baz.py" + assert captions[k]._get_shortened_source_filename( + sizes["lose_some_filename_chars"]) \ + == "z.py" + assert captions[k]._get_shortened_source_filename(sizes["lose_all_source"]) \ + == ""