pw_console: Remove helpers.py

Move helper.py functions into better locations. This CL is only a
refactor, no new functionality.

New files and their functions.

- text_formatting.py
    - remove_formatting
    - strip_ansi
    - get_line_height
- widgets/checkbox.py
    - to_checkbox
    - to_checkbox_text
- widgets/focus_on_click_overlay.py
    - FocusOnClickFloatContainer
    - create_overlay
- style.py
    - get_pane_style
    - get_pane_indicator
    - get_toolbar_style

No-Docs-Update-Reason: Refactor
Change-Id: I145bea0758eec599c00f2d399286ea130f07c694
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/51700
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index ef1a54a..f3f4e9a 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -23,19 +23,23 @@
     "pw_console/__main__.py",
     "pw_console/console_app.py",
     "pw_console/help_window.py",
-    "pw_console/helpers.py",
     "pw_console/key_bindings.py",
     "pw_console/log_container.py",
     "pw_console/log_pane.py",
+    "pw_console/mouse.py",
     "pw_console/pw_ptpython_repl.py",
     "pw_console/repl_pane.py",
     "pw_console/style.py",
+    "pw_console/text_formatting.py",
+    "pw_console/widgets/__init__.py",
+    "pw_console/widgets/checkbox.py",
+    "pw_console/widgets/focus_on_click_overlay.py",
   ]
   tests = [
     "console_app_test.py",
     "help_window_test.py",
-    "helpers_test.py",
     "repl_pane_test.py",
+    "text_formatting_test.py",
   ]
   python_deps = [
     "$dir_pw_cli/py",
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index e864cf9..fc5789b 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -47,7 +47,7 @@
 )
 
 import pw_console.key_bindings
-import pw_console.helpers
+import pw_console.widgets.checkbox
 import pw_console.style
 from pw_console.help_window import HelpWindow
 from pw_console.log_pane import LogPane
@@ -307,13 +307,13 @@
                 '[Window]',
                 children=[
                     MenuItem('{check} Vertical Window Spliting'.format(
-                        check=pw_console.helpers.to_checkbox_text(
+                        check=pw_console.widgets.checkbox.to_checkbox_text(
                             self.vertical_split)),
                              handler=self.toggle_vertical_split),
                     MenuItem('-'),
                 ] + [
                     MenuItem('{check} {index}: {title} {subtitle}'.format(
-                        check=pw_console.helpers.to_checkbox_text(
+                        check=pw_console.widgets.checkbox.to_checkbox_text(
                             pane.show_pane),
                         index=index + 1,
                         title=pane.pane_title(),
diff --git a/pw_console/py/pw_console/helpers.py b/pw_console/py/pw_console/helpers.py
deleted file mode 100644
index b8ac373..0000000
--- a/pw_console/py/pw_console/helpers.py
+++ /dev/null
@@ -1,100 +0,0 @@
-# Copyright 2021 The Pigweed Authors
-#
-# Licensed under the Apache License, Version 2.0 (the "License"); you may not
-# use this file except in compliance with the License. You may obtain a copy of
-# the License at
-#
-#     https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations under
-# the License.
-"""Helper functions."""
-
-from prompt_toolkit.filters import has_focus
-
-
-def remove_formatting(formatted_text):
-    """Throw away style info from formatted text tuples."""
-    return ''.join([formatted_tuple[1] for formatted_tuple in formatted_text])  # pylint: disable=not-an-iterable
-
-
-def get_line_height(text_width, screen_width, prefix_width):
-    """Calculates line height for a string with line wrapping enabled."""
-    if text_width == 0:
-        return 0
-
-    # If text will fit on the screen without wrapping.
-    if text_width <= screen_width:
-        return 1, screen_width - text_width
-
-    # Assume zero width prefix if it's >= width of the screen.
-    if prefix_width >= screen_width:
-        prefix_width = 0
-
-    # Start with height of 1 row.
-    total_height = 1
-
-    # One screen_width of characters (with no prefix) is displayed first.
-    remaining_width = text_width - screen_width
-
-    # While we have caracters remaining to be displayed
-    while remaining_width > 0:
-        # Add the new indentation prefix
-        remaining_width += prefix_width
-        # Display this line
-        remaining_width -= screen_width
-        # Add a line break
-        total_height += 1
-
-    # Remaining characters is what's left below zero.
-    return (total_height, abs(remaining_width))
-
-
-def get_toolbar_style(pt_container) -> str:
-    """Return the style class for a toolbar if pt_container is in focus."""
-    if has_focus(pt_container.__pt_container__())():
-        return 'class:toolbar_active'
-    return 'class:toolbar_inactive'
-
-
-def get_pane_style(pt_container) -> str:
-    """Return the style class for a pane title if pt_container is in focus."""
-    if has_focus(pt_container.__pt_container__())():
-        return 'class:pane_active'
-    return 'class:pane_inactive'
-
-
-def get_pane_indicator(pt_container, title, mouse_handler=None):
-    """Return formatted text for a pane indicator and title."""
-    if mouse_handler:
-        inactive_indicator = ('class:pane_indicator_inactive', ' ',
-                              mouse_handler)
-        active_indicator = ('class:pane_indicator_active', ' ', mouse_handler)
-        inactive_title = ('class:pane_title_inactive', title, mouse_handler)
-        active_title = ('class:pane_title_active', title, mouse_handler)
-    else:
-        inactive_indicator = ('class:pane_indicator_inactive', ' ')
-        active_indicator = ('class:pane_indicator_active', ' ')
-        inactive_title = ('class:pane_title_inactive', title)
-        active_title = ('class:pane_title_active', title)
-
-    if has_focus(pt_container.__pt_container__())():
-        return [active_indicator, active_title]
-    return [inactive_indicator, inactive_title]
-
-
-def to_checkbox(checked: bool, mouse_handler=None):
-    default_style = 'class:checkbox'
-    checked_style = 'class:checkbox-checked'
-    text = '[x] ' if checked else '[ ] '
-    style = checked_style if checked else default_style
-    if mouse_handler:
-        return (style, text, mouse_handler)
-    return (style, text)
-
-
-def to_checkbox_text(checked: bool):
-    return to_checkbox(checked)[1]
diff --git a/pw_console/py/pw_console/log_container.py b/pw_console/py/pw_console/log_container.py
index cd92ef3..482473d 100644
--- a/pw_console/py/pw_console/log_container.py
+++ b/pw_console/py/pw_console/log_container.py
@@ -15,7 +15,6 @@
 
 import collections
 import logging
-import re
 import sys
 import time
 from dataclasses import dataclass
@@ -32,12 +31,10 @@
 import pw_cli.color
 from pw_log_tokenized import FormatStringWithMetadata
 
-import pw_console.helpers
+import pw_console.text_formatting
 
 _LOG = logging.getLogger(__package__)
 
-_ANSI_SEQUENCE_REGEX = re.compile(r'\x1b[^m]*m')
-
 
 @dataclass
 class LogLine:
@@ -216,8 +213,9 @@
                 asctime=record.asctime, levelname=record.levelname)
 
             # Delete ANSI escape sequences.
-            ansi_stripped_time_and_level = _ANSI_SEQUENCE_REGEX.sub(
-                '', formatted_time_and_level)
+            ansi_stripped_time_and_level = (
+                pw_console.text_formatting.strip_ansi(formatted_time_and_level)
+            )
 
             self.channel_formatted_prefix_widths[record.name] = len(
                 ansi_stripped_time_and_level)
@@ -230,7 +228,8 @@
         """Add a new log event."""
         # Format incoming log line.
         formatted_log = self.format(record)
-        ansi_stripped_log = _ANSI_SEQUENCE_REGEX.sub('', formatted_log)
+        ansi_stripped_log = pw_console.text_formatting.strip_ansi(
+            formatted_log)
         # Save this log.
         self.logs.append(
             LogLine(record=record,
@@ -438,7 +437,7 @@
             remaining_width = 0
             if self.wrap_lines_enabled() and (fragment_width > window_width):
                 line_height, remaining_width = (
-                    pw_console.helpers.get_line_height(
+                    pw_console.text_formatting.get_line_height(
                         fragment_width, window_width,
                         self.get_line_wrap_prefix_width()))
 
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
index 2f1ec8c..5d2b4f9 100644
--- a/pw_console/py/pw_console/log_pane.py
+++ b/pw_console/py/pw_console/log_pane.py
@@ -40,7 +40,8 @@
 from prompt_toolkit.layout.dimension import AnyDimension
 from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 
-import pw_console.helpers
+import pw_console.widgets.checkbox
+import pw_console.style
 from pw_console.log_container import LogContainer
 
 
@@ -115,8 +116,8 @@
         title = ' {} '.format(log_pane.pane_title())
         mouse_handler = functools.partial(
             LogPaneBottomToolbarBar.mouse_handler_focus, log_pane)
-        return pw_console.helpers.get_pane_indicator(log_pane, title,
-                                                     mouse_handler)
+        return pw_console.style.get_pane_indicator(log_pane, title,
+                                                   mouse_handler)
 
     @staticmethod
     def get_center_text_tokens(log_pane):
@@ -134,34 +135,38 @@
             LogPaneBottomToolbarBar.mouse_handler_toggle_follow, log_pane)
 
         # FormattedTextTuple contents: (Style, Text, Mouse handler)
-        separator_text = ('', ' ')  # 1 space of separaton between keybinds.
+        separator_text = ('', '  ')  # 2 space of separaton between keybinds.
 
-        # If the log_pane is in focus, show keybinds in the toolbar.
-        if has_focus(log_pane.__pt_container__())():
-            return [
-                separator_text,
-                pw_console.helpers.to_checkbox(log_pane.wrap_lines,
-                                               toggle_wrap_lines),
-                ('class:keybind', 'w', toggle_wrap_lines),
-                ('class:keyhelp', ':Wrap', toggle_wrap_lines),
-                separator_text,
-                pw_console.helpers.to_checkbox(log_pane.log_container.follow,
-                                               toggle_follow),
-                ('class:keybind', 'f', toggle_follow),
-                ('class:keyhelp', ':Follow', toggle_follow),
-                separator_text,
-                ('class:keybind', 'C', clear_history),
-                ('class:keyhelp', ':Clear', clear_history),
-            ]
         # Show the click to focus button if log pane isn't in focus.
         return [
-            ('class:keyhelp', '[click to focus] ', focus),
+            separator_text,
+            pw_console.widgets.checkbox.to_checkbox(log_pane.wrap_lines,
+                                                    toggle_wrap_lines,
+                                                    end=''),
+            ('class:keyhelp', 'Wrap:', toggle_wrap_lines),
+            ('class:keybind', 'w', toggle_wrap_lines),
+            separator_text,
+            pw_console.widgets.checkbox.to_checkbox(
+                log_pane.log_container.follow, toggle_follow, end=''),
+            ('class:keyhelp', 'Follow:', toggle_follow),
+            ('class:keybind', 'f', toggle_follow),
+            separator_text,
+            ('class:keyhelp', 'Clear:', clear_history),
+            ('class:keybind', 'C', clear_history),
+            # Remaining whitespace should focus on click.
+            ('class:keybind', ' ', focus),
         ]
 
     @staticmethod
     def get_right_text_tokens(log_pane):
         """Return formatted text tokens for display."""
-        return [('', ' {} '.format(log_pane.pane_subtitle()))]
+        focus = functools.partial(LogPaneBottomToolbarBar.mouse_handler_focus,
+                                  log_pane)
+        fragments = []
+        if not has_focus(log_pane.__pt_container__())():
+            fragments.append(('class:keyhelp', '[click to focus] ', focus))
+        fragments.append(('', ' {} '.format(log_pane.pane_subtitle())))
+        return fragments
 
     def __init__(self, log_pane):
         title_section_window = Window(
@@ -199,7 +204,7 @@
                 log_source_name,
             ],
             height=LogPaneBottomToolbarBar.TOOLBAR_HEIGHT,
-            style=functools.partial(pw_console.helpers.get_toolbar_style,
+            style=functools.partial(pw_console.style.get_toolbar_style,
                                     log_pane),
             align=WindowAlign.LEFT,
         )
@@ -427,7 +432,7 @@
             dont_extend_width=False,
             # Needed for log lines ANSI sequences that don't specify foreground
             # or background colors.
-            style=functools.partial(pw_console.helpers.get_pane_style, self),
+            style=functools.partial(pw_console.style.get_pane_style, self),
         )
 
         # Root level container
@@ -444,7 +449,7 @@
                     align=VerticalAlign.BOTTOM,
                     height=self.height,
                     width=self.width,
-                    style=functools.partial(pw_console.helpers.get_pane_style,
+                    style=functools.partial(pw_console.style.get_pane_style,
                                             self),
                 ),
                 floats=[
diff --git a/pw_console/py/pw_console/mouse.py b/pw_console/py/pw_console/mouse.py
new file mode 100644
index 0000000..c40897a
--- /dev/null
+++ b/pw_console/py/pw_console/mouse.py
@@ -0,0 +1,27 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Mouse handler fuctions."""
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.filters import has_focus
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+
+
+def focus_handler(container, mouse_event: MouseEvent):
+    """Focus container on click."""
+    if not has_focus(container)():
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            get_app().layout.focus(container)
+            return None
+    return NotImplemented
diff --git a/pw_console/py/pw_console/pw_ptpython_repl.py b/pw_console/py/pw_console/pw_ptpython_repl.py
index e3e7674..ea5b6fe 100644
--- a/pw_console/py/pw_console/pw_ptpython_repl.py
+++ b/pw_console/py/pw_console/pw_ptpython_repl.py
@@ -28,7 +28,7 @@
 
 from ptpython.completer import CompletePrivateAttributes  # type: ignore
 
-import pw_console.helpers
+import pw_console.text_formatting
 
 _LOG = logging.getLogger(__package__)
 
@@ -79,7 +79,7 @@
 
     def _save_result(self, formatted_text):
         """Save the last repl execution result."""
-        unformatted_result = pw_console.helpers.remove_formatting(
+        unformatted_result = pw_console.text_formatting.remove_formatting(
             formatted_text)
         self._last_result = unformatted_result
 
@@ -127,7 +127,8 @@
 
             if result_value is not None:
                 formatted_result = self._format_result_output(result_value)
-                result = pw_console.helpers.remove_formatting(formatted_result)
+                result = pw_console.text_formatting.remove_formatting(
+                    formatted_result)
 
         # Job is finished, append the last result.
         self.repl_pane.append_result_to_executed_code(input_text, future,
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
index a25d95c..194a4d1 100644
--- a/pw_console/py/pw_console/repl_pane.py
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -32,13 +32,11 @@
     has_focus,
 )
 from prompt_toolkit.document import Document
-from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 from prompt_toolkit.layout.dimension import AnyDimension
 from prompt_toolkit.widgets import TextArea
 from prompt_toolkit.layout import (
     ConditionalContainer,
     Dimension,
-    Float,
     FloatContainer,
     FormattedTextControl,
     HSplit,
@@ -49,11 +47,9 @@
 from prompt_toolkit.lexers import PygmentsLexer  # type: ignore
 from pygments.lexers.python import PythonLexer  # type: ignore
 
-from pw_console.helpers import (
-    get_pane_indicator,
-    get_pane_style,
-    get_toolbar_style,
-)
+import pw_console.mouse
+import pw_console.style
+import pw_console.widgets.focus_on_click_overlay
 from pw_console.pw_ptpython_repl import PwPtPythonRepl
 
 _LOG = logging.getLogger(__package__)
@@ -67,38 +63,6 @@
     OUTPUT_TEMPLATE = tmpl.read()
 
 
-def mouse_focus_handler(repl_pane, mouse_event: MouseEvent):
-    """Focus the repl_pane on click."""
-    if not has_focus(repl_pane)():
-        if mouse_event.event_type == MouseEventType.MOUSE_UP:
-            repl_pane.application.application.layout.focus(repl_pane)
-            return None
-    return NotImplemented
-
-
-class FocusOnClickFloatContainer(ConditionalContainer):
-    """Empty container rendered if the repl_pane is not in focus.
-
-    This container should be rendered with transparent=True so nothing is shown
-    to the user. Container is not rendered if the repl_pane is already in focus.
-    """
-    def __init__(self, repl_pane):
-
-        empty_text = FormattedTextControl([(
-            # Style
-            'class:pane_inactive',
-            # Text
-            ' ',
-            # Mouse handler
-            functools.partial(mouse_focus_handler, repl_pane),
-        )])
-
-        super().__init__(
-            Window(empty_text),
-            filter=Condition(lambda: not has_focus(repl_pane)()),
-        )
-
-
 class ReplPaneBottomToolbarBar(ConditionalContainer):
     """Repl pane bottom toolbar."""
     @staticmethod
@@ -106,8 +70,10 @@
         """Return toolbar indicator and title."""
 
         title = ' Python Input '
-        mouse_handler = functools.partial(mouse_focus_handler, repl_pane)
-        return get_pane_indicator(repl_pane, title, mouse_handler)
+        mouse_handler = functools.partial(pw_console.mouse.focus_handler,
+                                          repl_pane)
+        return pw_console.style.get_pane_indicator(repl_pane, title,
+                                                   mouse_handler)
 
     @staticmethod
     def get_center_text_tokens(repl_pane):
@@ -119,24 +85,15 @@
                 # Text
                 ' ',
                 # Mouse handler
-                functools.partial(mouse_focus_handler, repl_pane),
+                functools.partial(pw_console.mouse.focus_handler, repl_pane),
             ),
             ('class:keybind', 'enter'),
             ('class:keyhelp', ':Run code'),
         ]
 
-        out_of_focus_text = [(
-            # Style
-            'class:keyhelp',
-            # Text
-            '[click to focus] ',
-            # Mouse handler
-            functools.partial(mouse_focus_handler, repl_pane),
-        )]
-
         if has_focus(repl_pane)():
             return focused_text
-        return out_of_focus_text
+        return [('', '')]
 
     @staticmethod
     def get_right_text_tokens(repl_pane):
@@ -148,7 +105,15 @@
                 ('class:keybind', 'F3'),
                 ('class:keyhelp', ':History '),
             ]
-        return []
+
+        return [(
+            # Style
+            'class:keyhelp',
+            # Text
+            '[click to focus] ',
+            # Mouse handler
+            functools.partial(pw_console.mouse.focus_handler, repl_pane),
+        )]
 
     def __init__(self, repl_pane):
         left_section_window = Window(
@@ -192,7 +157,8 @@
                 right_section_window,
             ],
             height=1,
-            style=functools.partial(get_toolbar_style, repl_pane),
+            style=functools.partial(pw_console.style.get_toolbar_style,
+                                    repl_pane),
             align=WindowAlign.LEFT,
         )
 
@@ -273,6 +239,7 @@
                                     Window(
                                         content=FormattedTextControl(
                                             functools.partial(
+                                                pw_console.style.
                                                 get_pane_indicator, self,
                                                 ' Python Results ')),
                                         align=WindowAlign.LEFT,
@@ -281,7 +248,7 @@
                                     ),
                                 ],
                                 style=functools.partial(
-                                    get_toolbar_style, self),
+                                    pw_console.style.get_toolbar_style, self),
                             ),
                         ]),
                         HSplit([
@@ -293,25 +260,14 @@
                     ],
                     height=self.height,
                     width=self.width,
-                    style=functools.partial(get_pane_style, self),
+                    style=functools.partial(pw_console.style.get_pane_style,
+                                            self),
                 ),
                 floats=[
-                    # Transparent float container that will focus on the
-                    # repl_pane when clicked. It is hidden if already in focus.
-                    Float(
-                        # This is drawn as the full size of the ReplPane
-                        FocusOnClickFloatContainer(self),
-                        transparent=True,
-                        # Draw the empty space in the bottom right corner.
-                        # Distance to the right edge
-                        right=1,
-                        # Distance to the bottom edge
-                        bottom=1,
-                        # Don't specify left or top to 0, it would override
-                        # right+bottom and move it to the top left.
-                        #   left=0,
-                        #   top=0,
-                    ),
+                    # Transparent float container that will focus on this
+                    # ReplPane when clicked.
+                    pw_console.widgets.focus_on_click_overlay.create_overlay(
+                        self),
                 ]),
             filter=Condition(lambda: self.show_pane))
 
diff --git a/pw_console/py/pw_console/style.py b/pw_console/py/pw_console/style.py
index 98164aa..5c80bde 100644
--- a/pw_console/py/pw_console/style.py
+++ b/pw_console/py/pw_console/style.py
@@ -17,6 +17,7 @@
 from dataclasses import dataclass
 
 from prompt_toolkit.styles import Style
+from prompt_toolkit.filters import has_focus
 
 _LOG = logging.getLogger(__package__)
 
@@ -149,3 +150,36 @@
     } # yapf: disable
 
     return Style.from_dict(pw_console_styles)
+
+
+def get_toolbar_style(pt_container) -> str:
+    """Return the style class for a toolbar if pt_container is in focus."""
+    if has_focus(pt_container.__pt_container__())():
+        return 'class:toolbar_active'
+    return 'class:toolbar_inactive'
+
+
+def get_pane_style(pt_container) -> str:
+    """Return the style class for a pane title if pt_container is in focus."""
+    if has_focus(pt_container.__pt_container__())():
+        return 'class:pane_active'
+    return 'class:pane_inactive'
+
+
+def get_pane_indicator(pt_container, title, mouse_handler=None):
+    """Return formatted text for a pane indicator and title."""
+    if mouse_handler:
+        inactive_indicator = ('class:pane_indicator_inactive', ' ',
+                              mouse_handler)
+        active_indicator = ('class:pane_indicator_active', ' ', mouse_handler)
+        inactive_title = ('class:pane_title_inactive', title, mouse_handler)
+        active_title = ('class:pane_title_active', title, mouse_handler)
+    else:
+        inactive_indicator = ('class:pane_indicator_inactive', ' ')
+        active_indicator = ('class:pane_indicator_active', ' ')
+        inactive_title = ('class:pane_title_inactive', title)
+        active_title = ('class:pane_title_active', title)
+
+    if has_focus(pt_container.__pt_container__())():
+        return [active_indicator, active_title]
+    return [inactive_indicator, inactive_title]
diff --git a/pw_console/py/pw_console/text_formatting.py b/pw_console/py/pw_console/text_formatting.py
new file mode 100644
index 0000000..d818893
--- /dev/null
+++ b/pw_console/py/pw_console/text_formatting.py
@@ -0,0 +1,60 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Text formatting functions."""
+
+import re
+
+_ANSI_SEQUENCE_REGEX = re.compile(r'\x1b[^m]*m')
+
+
+def strip_ansi(text: str):
+    """Strip out ANSI escape sequences."""
+    return _ANSI_SEQUENCE_REGEX.sub('', text)
+
+
+def remove_formatting(formatted_text):
+    """Throw away style info from prompt_toolkit formatted text tuples."""
+    return ''.join([formatted_tuple[1] for formatted_tuple in formatted_text])  # pylint: disable=not-an-iterable
+
+
+def get_line_height(text_width, screen_width, prefix_width):
+    """Calculates line height for a string with line wrapping enabled."""
+    if text_width == 0:
+        return 0
+
+    # If text will fit on the screen without wrapping.
+    if text_width <= screen_width:
+        return 1, screen_width - text_width
+
+    # Assume zero width prefix if it's >= width of the screen.
+    if prefix_width >= screen_width:
+        prefix_width = 0
+
+    # Start with height of 1 row.
+    total_height = 1
+
+    # One screen_width of characters (with no prefix) is displayed first.
+    remaining_width = text_width - screen_width
+
+    # While we have caracters remaining to be displayed
+    while remaining_width > 0:
+        # Add the new indentation prefix
+        remaining_width += prefix_width
+        # Display this line
+        remaining_width -= screen_width
+        # Add a line break
+        total_height += 1
+
+    # Remaining characters is what's left below zero.
+    return (total_height, abs(remaining_width))
diff --git a/pw_console/py/pw_console/widgets/__init__.py b/pw_console/py/pw_console/widgets/__init__.py
new file mode 100644
index 0000000..62a3c11
--- /dev/null
+++ b/pw_console/py/pw_console/widgets/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Pigweed Console UI widgets."""
diff --git a/pw_console/py/pw_console/widgets/checkbox.py b/pw_console/py/pw_console/widgets/checkbox.py
new file mode 100644
index 0000000..d9e873c
--- /dev/null
+++ b/pw_console/py/pw_console/widgets/checkbox.py
@@ -0,0 +1,29 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Functions to create checkboxes for menus and toolbars."""
+
+
+def to_checkbox(checked: bool, mouse_handler=None, end=' '):
+    default_style = 'class:checkbox'
+    checked_style = 'class:checkbox-checked'
+    text = '[x]' if checked else '[ ]'
+    text += end
+    style = checked_style if checked else default_style
+    if mouse_handler:
+        return (style, text, mouse_handler)
+    return (style, text)
+
+
+def to_checkbox_text(checked: bool):
+    return to_checkbox(checked)[1]
diff --git a/pw_console/py/pw_console/widgets/focus_on_click_overlay.py b/pw_console/py/pw_console/widgets/focus_on_click_overlay.py
new file mode 100644
index 0000000..8d7f41c
--- /dev/null
+++ b/pw_console/py/pw_console/widgets/focus_on_click_overlay.py
@@ -0,0 +1,79 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Float container that will focus on a given container on click."""
+
+import functools
+
+from prompt_toolkit.filters import (
+    Condition,
+    has_focus,
+)
+from prompt_toolkit.layout import (
+    ConditionalContainer,
+    Float,
+    FormattedTextControl,
+    Window,
+    WindowAlign,
+)
+
+import pw_console.mouse
+
+
+class FocusOnClickFloatContainer(ConditionalContainer):
+    """Empty container rendered if the repl_pane is not in focus.
+
+    This container should be rendered with transparent=True so nothing is shown
+    to the user. Container is not rendered if the repl_pane is already in focus.
+    """
+    def __init__(self, target_container):
+
+        empty_text = FormattedTextControl([(
+            'class:pane_inactive',  # Style
+            # Text here must be a printable character or the mouse handler won't
+            # work.
+            ' ',
+            functools.partial(pw_console.mouse.focus_handler,
+                              target_container),  # Mouse handler
+        )])
+
+        super().__init__(
+            Window(
+                empty_text,
+                # Draw the empty space in the upper right corner
+                align=WindowAlign.RIGHT,
+                # Make sure window fills all available space.
+                dont_extend_width=False,
+                dont_extend_height=False,
+            ),
+            filter=Condition(lambda: not has_focus(target_container)()),
+        )
+
+
+def create_overlay(target_container):
+    """Create a transparent FocusOnClickFloatContainer.
+
+    The target_container will be focused when clicked. The overlay float will be
+    hidden if target_container is already in focus.
+    """
+    return Float(
+        # This is drawn as the full size of the ReplPane
+        FocusOnClickFloatContainer(target_container),
+        transparent=True,
+        # Draw the empty space in the bottom right corner.
+        # Distance to each edge, fill the whole container.
+        left=0,
+        top=0,
+        right=0,
+        bottom=0,
+    )
diff --git a/pw_console/py/helpers_test.py b/pw_console/py/text_formatting_test.py
similarity index 97%
rename from pw_console/py/helpers_test.py
rename to pw_console/py/text_formatting_test.py
index 435141f..600b5ea 100644
--- a/pw_console/py/helpers_test.py
+++ b/pw_console/py/text_formatting_test.py
@@ -11,12 +11,12 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Tests for pw_console.console_app"""
+"""Tests for pw_console.text_formatting"""
 
 import unittest
 from parameterized import parameterized  # type: ignore
 
-from pw_console.helpers import get_line_height
+from pw_console.text_formatting import get_line_height
 
 
 class TestHelperFunctions(unittest.TestCase):