Repository: ansible/ansible-navigator (language: python)

Issue:
Font-styles are not reflected in UI
Currently, a font-style like `bold` is simply disregarded and not returned to the UI and translated to a curses decoration.

See #1019 for where this happens, the colorization function should capture the font style, match it to a curses style and return that as part of the line part to the user interface.


Discussion / hints:

Fix patch:
diff --git a/src/ansible_navigator/ui_framework/colorize.py b/src/ansible_navigator/ui_framework/colorize.py
index 130567827..af5fce3e3 100644
--- a/src/ansible_navigator/ui_framework/colorize.py
+++ b/src/ansible_navigator/ui_framework/colorize.py
@@ -51,13 +51,15 @@ def __init__(self, schema: Dict[str, Union[str, List, Dict]]):
         self._schema = schema
 
     @functools.lru_cache(maxsize=None)
-    def get_color(self, scope: str) -> Optional[RgbTuple]:
-        """Get a color from the schema, from most specific to least.
+    def get_color_and_style(self, scope: str) -> Tuple[Optional[RgbTuple], Optional[str]]:
+        """Get a color from the schema, traverse all to aggregate color and style.
 
         :param scope: The scope, aka format
         :returns: The color in RGB format or nothing
         """
-        for name in reversed(scope):
+        found_color = None
+        found_style = None
+        for name in scope:
             for parts in range(0, len(name.split("."))):
                 prop = name.split()[-1].rsplit(".", parts)[0]
                 color = next(
@@ -72,15 +74,11 @@ def get_color(self, scope: str) -> Optional[RgbTuple]:
                     None,
                 )
                 if color:
-                    foreground = color.get("settings", {}).get("foreground", None)
-                    # Without decoration support, continue resolving to a until we find a
-                    # foreground color or ultimately return None. This currently completely
-                    # ignores font styles.
-                    # e.g. color={'scope': 'strong', 'settings': {'fontStyle': 'bold'}}
-                    if foreground is not None:
-                        return hex_to_rgb(foreground)
-                    self._logger.debug("Color resolution continued: %s", color)
-        return None
+                    found_color = color.get("settings", {}).get("foreground", None)
+                    found_style = color.get("settings", {}).get("fontStyle", None)
+        if found_color:
+            found_color = hex_to_rgb(found_color)
+        return found_color, found_style
 
 
 class Colorize:
@@ -156,7 +154,8 @@ def render(self, doc: str, scope: str) -> List[List[SimpleLinePart]]:
                 return columns_and_colors(lines, self._schema)
 
         res = [
-            [SimpleLinePart(column=0, chars=doc_line, color=None)] for doc_line in doc.splitlines()
+            [SimpleLinePart(column=0, chars=doc_line, color=None, style=None)]
+            for doc_line in doc.splitlines()
         ]
         return res
 
@@ -254,6 +253,7 @@ def columns_and_colors(
     lines: List[Tuple[Regions, str]],
     schema: ColorSchema,
 ) -> List[List[SimpleLinePart]]:
+    # pylint: disable=too-many-branches
     """Convert to colors and columns.
 
     :param lines: Lines of text and their regions
@@ -265,28 +265,32 @@ def columns_and_colors(
     for line in lines:
         # Break the into 1 character parts, temporarily set the column to 0
         line_parts = [
-            SimpleLinePart(chars=character, color=None, column=0) for character in line[1]
+            SimpleLinePart(chars=character, color=None, column=0, style=None)
+            for character in line[1]
         ]
 
         # Replace the color with the RgbTuple
         for region in line[0]:
-            color = schema.get_color(region.scope)
+            color, style = schema.get_color_and_style(region.scope)
             if color:
                 for idx in range(region.start, region.end):
                     line_parts[idx].color = color
+            if style:
+                for idx in range(region.start, region.end):
+                    line_parts[idx].style = style
 
-        # Compress the line, grouping characters of like color
+        # Compress the line, grouping characters of like color and decoration
         if line_parts:
             grouped = [line_parts.pop(0)]
             while line_parts:
                 entry = line_parts.pop(0)
-                if entry.color == grouped[-1].color:
+                if entry.color == grouped[-1].color and entry.style == grouped[-1].style:
                     grouped[-1].chars += entry.chars
                 else:
                     grouped.append(entry)
             results.append(grouped)
         else:
-            results.append([SimpleLinePart(chars=line[1], color=None, column=0)])
+            results.append([SimpleLinePart(chars=line[1], color=None, column=0, style=None)])
 
     # Update the column in each line part, based on the total of the preceding text lengths
     for result in results:
diff --git a/src/ansible_navigator/ui_framework/curses_defs.py b/src/ansible_navigator/ui_framework/curses_defs.py
index 3fa1cf1f5..155eb059a 100644
--- a/src/ansible_navigator/ui_framework/curses_defs.py
+++ b/src/ansible_navigator/ui_framework/curses_defs.py
@@ -43,3 +43,5 @@ class SimpleLinePart:
     column: int
     #: The color for these characters
     color: Optional[RgbTuple]
+    #: The style for these characters
+    style: Optional[str]
diff --git a/src/ansible_navigator/ui_framework/ui.py b/src/ansible_navigator/ui_framework/ui.py
index d4f168eb2..0a48953f8 100644
--- a/src/ansible_navigator/ui_framework/ui.py
+++ b/src/ansible_navigator/ui_framework/ui.py
@@ -44,6 +44,7 @@
 from .form_utils import warning_notification
 from .menu_builder import MenuBuilder
 from .ui_config import UIConfig
+from .ui_constants import Decoration
 
 
 STANDARD_KEYS = {
@@ -566,17 +567,16 @@ def _serialize_color(self, obj: Any) -> CursesLines:
             scope = self.content_format().value.scope
 
         rendered = self._colorizer.render(doc=string, scope=scope)
-        return self._color_lines_for_term(rendered)
+        self._cache_init_colors(rendered)
+        return self._color_decorate_lines(rendered)
 
-    def _color_lines_for_term(self, lines: List) -> CursesLines:
-        """Give a list of dicts from tokenized lines
-        transform them into lines for curses
-        add colors as needed, maintain a mapping of RGB colors
+    def _cache_init_colors(self, lines: List):
+        """Cache and init the unique colors for future use
+
+        Maintain a mapping of RGB colors
         to curses colors in self._rgb_to_curses_color_idx
 
-        :params lines: The lines to transform
-            Lines[LinePart[{"color": RGB, "chars": text, "column": n},...]]
-        :returns: the lines ready for curses
+        :param lines: The from which colors will be cached and initialized
         """
         if curses.COLORS > 16 and self._term_osc4_support:
             unique_colors = list(
@@ -605,29 +605,25 @@ def _color_lines_for_term(self, lines: List) -> CursesLines:
                         curses.color_content(curses_colors_idx),
                     )
                     curses.init_pair(curses_colors_idx, curses_colors_idx, -1)
-        colored_lines = self._colored_lines(lines)
-        return colored_lines
 
-    def _colored_lines(self, lines: List[List[SimpleLinePart]]) -> CursesLines:
-        """Color each of the lines.
+    def _color_decorate_lines(self, lines: List[List[SimpleLinePart]]) -> CursesLines:
+        """Color and decorate each of the lines.
 
         :params lines: The lines to transform
         :returns: All lines colored
         """
-        return CursesLines(tuple(self._colored_line(line) for line in lines))
+        return CursesLines(tuple(self._color_decorate_line(line) for line in lines))
 
-    def _colored_line(self, line: List[SimpleLinePart]) -> CursesLine:
-        """Color one line.
+    def _color_decorate_line(self, line: List[SimpleLinePart]) -> CursesLine:
+        """Color and decorate one line.
 
         :param line: The line to color
         :returns: One line colored
         """
-        return CursesLine(
-            tuple(self._colored_line_part(line_part) for line_part in line),
-        )
+        return CursesLine(tuple(self._color_decorate_line_part(line_part) for line_part in line))
 
-    def _colored_line_part(self, line_part: SimpleLinePart) -> CursesLinePart:
-        """Color one line part.
+    def _color_decorate_line_part(self, line_part: SimpleLinePart) -> CursesLinePart:
+        """Color and decorate one line part.
 
         :param line_part: One line part
         :returns: One line part colored
@@ -640,11 +636,13 @@ def _colored_line_part(self, line_part: SimpleLinePart) -> CursesLinePart:
                 color = rgb_to_ansi(red, green, blue, curses.COLORS)
         else:
             color = 0
+
+        decoration = Decoration.get_best(line_part.style)
         return CursesLinePart(
             column=line_part.column,
             string=line_part.chars,
             color=color,
-            decoration=0,
+            decoration=decoration,
         )
 
     def _filter_and_serialize(self, obj: Any) -> Tuple[Optional[CursesLines], CursesLines]:
diff --git a/src/ansible_navigator/ui_framework/ui_constants.py b/src/ansible_navigator/ui_framework/ui_constants.py
index 18f363a66..aae292dbe 100644
--- a/src/ansible_navigator/ui_framework/ui_constants.py
+++ b/src/ansible_navigator/ui_framework/ui_constants.py
@@ -2,6 +2,7 @@
 import curses
 
 from enum import IntEnum
+from typing import Optional
 
 
 class Color(IntEnum):
@@ -47,7 +48,25 @@ class Decoration(IntEnum):
     These are rarely used within the interface.
     """
 
+    BOLD = curses.A_BOLD
+    """2097152"""
+    ITALIC = curses.A_ITALIC
+    """2147483648"""
     NORMAL = curses.A_NORMAL
     """0"""
     UNDERLINE = curses.A_UNDERLINE
     """131072"""
+
+    @classmethod
+    def get_best(cls, name: Optional[str]) -> int:
+        """Return the default value for a missing value.
+
+        :param name: The name of the value
+        :return: The match or default value
+        """
+        if isinstance(name, str):
+            try:
+                return cls[name.upper()]
+            except KeyError:
+                return cls.NORMAL
+        return cls.NORMAL


Test patch:
diff --git a/tests/unit/ui_framework/test_colorize.py b/tests/unit/ui_framework/test_colorize.py
index 681a7e551..7a63ff919 100644
--- a/tests/unit/ui_framework/test_colorize.py
+++ b/tests/unit/ui_framework/test_colorize.py
@@ -121,33 +121,33 @@ def test_graceful_failure(mocked_func, caplog):
 """
 
 YAML_TXT_EXPECTED = [
-    [SimpleLinePart(chars="\n", column=0, color=None)],
+    [SimpleLinePart(chars="\n", column=0, color=None, style=None)],
     [
-        SimpleLinePart(chars="- ", column=0, color=None),
-        SimpleLinePart(chars="ansible.builtin.debug", column=2, color=(86, 156, 214)),
-        SimpleLinePart(chars=":\n", column=23, color=None),
+        SimpleLinePart(chars="- ", column=0, color=None, style=None),
+        SimpleLinePart(chars="ansible.builtin.debug", column=2, color=(86, 156, 214), style=None),
+        SimpleLinePart(chars=":\n", column=23, color=None, style=None),
     ],
     [
-        SimpleLinePart(chars="    ", column=0, color=None),
-        SimpleLinePart(chars="var", column=4, color=(86, 156, 214)),
-        SimpleLinePart(chars=": ", column=7, color=None),
-        SimpleLinePart(chars="before", column=9, color=(206, 145, 120)),
-        SimpleLinePart(chars="\n", column=15, color=None),
+        SimpleLinePart(chars="    ", column=0, color=None, style=None),
+        SimpleLinePart(chars="var", column=4, color=(86, 156, 214), style=None),
+        SimpleLinePart(chars=": ", column=7, color=None, style=None),
+        SimpleLinePart(chars="before", column=9, color=(206, 145, 120), style=None),
+        SimpleLinePart(chars="\n", column=15, color=None, style=None),
     ],
-    [SimpleLinePart(chars="\n", column=0, color=None)],
-    [SimpleLinePart(chars="# this is a comment\n", column=0, color=(106, 153, 85))],
-    [SimpleLinePart(chars="\n", column=0, color=None)],
+    [SimpleLinePart(chars="\n", column=0, color=None, style=None)],
+    [SimpleLinePart(chars="# this is a comment\n", column=0, color=(106, 153, 85), style=None)],
+    [SimpleLinePart(chars="\n", column=0, color=None, style=None)],
     [
-        SimpleLinePart(chars="- ", column=0, color=None),
-        SimpleLinePart(chars="ansible.builtin.debug", column=2, color=(86, 156, 214)),
-        SimpleLinePart(chars=":\n", column=23, color=None),
+        SimpleLinePart(chars="- ", column=0, color=None, style=None),
+        SimpleLinePart(chars="ansible.builtin.debug", column=2, color=(86, 156, 214), style=None),
+        SimpleLinePart(chars=":\n", column=23, color=None, style=None),
     ],
     [
-        SimpleLinePart(chars="    ", column=0, color=None),
-        SimpleLinePart(chars="var", column=4, color=(86, 156, 214)),
-        SimpleLinePart(chars=": ", column=7, color=None),
-        SimpleLinePart(chars="after", column=9, color=(206, 145, 120)),
-        SimpleLinePart(chars="\n", column=14, color=None),
+        SimpleLinePart(chars="    ", column=0, color=None, style=None),
+        SimpleLinePart(chars="var", column=4, color=(86, 156, 214), style=None),
+        SimpleLinePart(chars=": ", column=7, color=None, style=None),
+        SimpleLinePart(chars="after", column=9, color=(206, 145, 120), style=None),
+        SimpleLinePart(chars="\n", column=14, color=None, style=None),
     ],
 ]
 
@@ -161,3 +161,28 @@ def test_basic_success_yaml_text():
         scope=content_format.value.scope,
     )
     assert result == YAML_TXT_EXPECTED
+
+
+STYLED_MARKDOWN = """\
+# This is a header
+"""
+
+
+def test_styled_markdown():
+    """Ensure style is captured from markdown correctly."""
+    content_format = ContentFormat.MARKDOWN
+
+    result = Colorize(grammar_dir=GRAMMAR_DIR, theme_path=THEME_PATH).render(
+        doc=STYLED_MARKDOWN,
+        scope=content_format.value.scope,
+    )
+    assert result == [
+        [
+            SimpleLinePart(
+                chars="# This is a header\n",
+                column=0,
+                color=(86, 156, 214),
+                style="bold",
+            ),
+        ],
+    ]