Plugin Guide#

Pigweed Console supports extending the user interface with custom widgets. For example: Toolbars that display device information and provide buttons for interacting with the device.

Writing Plugins#

Creating new plugins has a few high level steps:

  1. Create a new Python class inheriting from either WindowPane or WindowPaneToolbar.

    • Optionally inherit from The PluginMixin class as well for running background tasks.

  2. Enable the plugin before pw_console startup by calling add_window_plugin, add_floating_window_plugin, add_top_toolbar or add_bottom_toolbar. See the Adding Plugins section of the Embedding Guide for an example.

  3. Run the console and enjoy!

    • Debugging Plugin behavior can be done by logging to a dedicated Python logger and viewing in-app. See Debugging Plugin Behavior below.

Background Tasks#

Plugins may need to have long running background tasks which could block or slow down the Pigweed Console user interface. For those situations use the PluginMixin class. Plugins can inherit from this and setup the callback that should be executed in the background.

class pw_console.plugin_mixin.PluginMixin#

Bases: object

Handles background task management in a Pigweed Console plugin.

Pigweed Console plugins can inherit from this class if they require running tasks in the background. This is important as any plugin code not in its own dedicated thread can potentially block the user interface

Example usage:

import logging
from pw_console.plugin_mixin import PluginMixin
from pw_console.widgets import WindowPaneToolbar

class AwesomeToolbar(WindowPaneToolbar, PluginMixin):
    TOOLBAR_HEIGHT = 1

    def __init__(self, *args, **kwargs):
        # Call parent class WindowPaneToolbar.__init__
        super().__init__(*args, **kwargs)

        # Set PluginMixin to execute
        # self._awesome_background_task every 10 seconds.
        self.plugin_init(
            plugin_callback=self._awesome_background_task,
            plugin_callback_frequency=10.0,
            plugin_logger_name='awesome_toolbar_plugin')

    # This function will be run in a separate thread every 10 seconds.
    def _awesome_background_task(self) -> bool:
        time.sleep(1)  # Do real work here.

        if self.new_data_processed:
            # If new data was processed, and the user interface
            # should be updated return True.

            # Log using self.plugin_logger for debugging.
            self.plugin_logger.debug('New data processed')

            # Return True to signal a UI redraw.
            return True

        # Returning False means no updates needed.
        return False
plugin_callback#

Callable that is run in a background thread.

plugin_callback_frequency#

Number of seconds to wait between executing plugin_callback.

plugin_logger#

logging instance for this plugin. Useful for debugging code running in a separate thread.

plugin_callback_future#

Future object for the plugin background task.

plugin_event_loop#

asyncio event loop running in the background thread.

plugin_enable_background_task#

If True, keep periodically running plugin_callback at the desired frequency. If False the background task will stop.

plugin_init(plugin_callback: Optional[Callable[[...], bool]] = None, plugin_callback_frequency: float = 30.0, plugin_logger_name: Optional[str] = 'pw_console_plugins') None#

Call this on __init__() to set plugin background task variables.

Parameters
  • plugin_callback – Callable to run in a separate thread from the Pigweed Console UI. This function should return True if the UI should be redrawn after execution.

  • plugin_callback_frequency – Number of seconds to wait between executing plugin_callback.

  • plugin_logger_name – Unique name for this plugin’s Python logger. Useful for debugging code running in a separate thread.

plugin_start()#

Function used to start this plugin’s background thead and task.

Debugging Plugin Behavior#

If your plugin uses background threads for updating it can be difficult to see errors. Often, nothing will appear to be happening and exceptions may not be visible. When using PluginMixin you can specify a name for a Python logger to use with the plugin_logger_name keyword argument.

class AwesomeToolbar(WindowPaneToolbar, PluginMixin):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.update_count = 0

        self.plugin_init(
            plugin_callback=self._background_task,
            plugin_callback_frequency=1.0,
            plugin_logger_name='my_awesome_plugin',
        )

    def _background_task(self) -> bool:
        self.update_count += 1
        self.plugin_logger.debug('background_task_update_count: %s',
                                 self.update_count)
        return True

This will let you open up a new log window while the console is running to see what the plugin is doing. Open up the logger name provided above by clicking in the main menu: File > Open Logger > my_awesome_plugin.

Sample Plugins#

Pigweed Console will provide a few sample plugins to serve as templates for creating your own plugins. These are a work in progress at the moment and not available at this time.

Calculator#

This plugin is similar to the full-screen calculator.py example provided in prompt_toolkit. It’s a full window that can be moved around the user interface like other Pigweed Console window panes. An input prompt is displayed on the bottom of the window where the user can type in some math equation. When the enter key is pressed the input is processed and the result shown in the top half of the window.

Both input and output fields are prompt_toolkit TextArea objects which can have their own options like syntax highlighting.

Screenshot of the CalcPane plugin showing some math calculations.

Screenshot of the CalcPane plugin showing some math calculations.#

The code is heavily commented and describes what each line is doing. See the Code Listing: calc_pane.py for the full source.

Clock#

The ClockPane is another WindowPane based plugin that displays a clock and some formatted text examples. It inherits from both WindowPane and PluginMixin.

ClockPane plugin screenshot showing the clock text.

ClockPane plugin screenshot showing the clock text.#

This plugin makes use of PluginMixin to run a task a background thread that triggers UI re-draws. There are also two toolbar buttons to toggle view mode (between the clock and some sample text) and line wrapping. pressing the v key or mouse clicking on the View Mode button will toggle the view to show some formatted text samples:

ClockPane plugin screenshot showing formatted text examples.

ClockPane plugin screenshot showing formatted text examples.#

Like the CalcPane example the code is heavily commented to guide plugin authors through developmenp. See the Code Listing: clock_pane.py below for the full source.

2048 Game#

This is a plugin that demonstrates more complex user interaction by playing a game of 2048.

Similar to the ClockPane the Twenty48Pane class inherits from PluginMixin to manage background tasks. With a few differences:

  • Uses FloatingWindowPane to create a floating window instead of a standard tiled window.

  • Implements the get_top_level_menus function to create a new [2048] menu in Pigweed Console’s own main menu bar.

  • Adds custom game keybindings which are set within the Twenty48Control class. That is the prompt_toolkit FormattedTextControl widget which receives keyboard input when the game is in focus.

The Twenty48Game class is separate from the user interface and handles managing the game state as well as printing the game board. The Twenty48Game.__pt_formatted_text__() function is responsible for drawing the game board using prompt_toolkit style and text tuples.

Twenty48Pane plugin screenshot showing the game board.

Twenty48Pane plugin screenshot showing the game board.#

Appendix#

Code Listing: calc_pane.py#

  1# Copyright 2021 The Pigweed Authors
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
  4# use this file except in compliance with the License. You may obtain a copy of
  5# the License at
  6#
  7#     https://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 12# License for the specific language governing permissions and limitations under
 13# the License.
 14"""Example text input-output Plugin."""
 15
 16from typing import TYPE_CHECKING
 17
 18from prompt_toolkit.document import Document
 19from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
 20from prompt_toolkit.layout import Window
 21from prompt_toolkit.widgets import SearchToolbar, TextArea
 22
 23from pw_console.widgets import ToolbarButton, WindowPane, WindowPaneToolbar
 24
 25if TYPE_CHECKING:
 26    from pw_console.console_app import ConsoleApp
 27
 28
 29class CalcPane(WindowPane):
 30    """Example plugin that accepts text input and displays output.
 31
 32    This plugin is similar to the full-screen calculator example provided in
 33    prompt_toolkit:
 34    https://github.com/prompt-toolkit/python-prompt-toolkit/blob/3.0.23/examples/full-screen/calculator.py
 35
 36    It's a full window that can be moved around the user interface like other
 37    Pigweed Console window panes. An input prompt is displayed on the bottom of
 38    the window where the user can type in some math equation. When the enter key
 39    is pressed the input is processed and the result shown in the top half of
 40    the window.
 41
 42    Both input and output fields are prompt_toolkit TextArea objects which can
 43    have their own options like syntax highlighting.
 44    """
 45
 46    def __init__(self):
 47        # Call WindowPane.__init__ and set the title to 'Calculator'
 48        super().__init__(pane_title='Calculator')
 49
 50        # Create a TextArea for the output-field
 51        # TextArea is a prompt_toolkit widget that can display editable text in
 52        # a buffer. See the prompt_toolkit docs for all possible options:
 53        # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.widgets.TextArea
 54        self.output_field = TextArea(
 55            # Optional Styles to apply to this TextArea
 56            style='class:output-field',
 57            # Initial text to put into the buffer.
 58            text='Calculator Output',
 59            # Allow this buffer to be in focus. This lets you drag select text
 60            # contained inside, and edit the contents unless readonly.
 61            focusable=True,
 62            # Focus on mouse click.
 63            focus_on_click=True,
 64        )
 65
 66        # This is the search toolbar and only appears if the user presses ctrl-r
 67        # to do reverse history search (similar to bash or zsh). Its used by the
 68        # input_field below.
 69        self.search_field = SearchToolbar()
 70
 71        # Create a TextArea for the user input.
 72        self.input_field = TextArea(
 73            # The height is set to 1 line
 74            height=1,
 75            # Prompt string that appears before the cursor.
 76            prompt='>>> ',
 77            # Optional Styles to apply to this TextArea
 78            style='class:input-field',
 79            # We only allow one line input for this example but multiline is
 80            # supported by prompt_toolkit.
 81            multiline=False,
 82            wrap_lines=False,
 83            # Allow reverse history search
 84            search_field=self.search_field,
 85            # Allow this input to be focused.
 86            focusable=True,
 87            # Focus on mouse click.
 88            focus_on_click=True,
 89        )
 90
 91        # The TextArea accept_handler function is called by prompt_toolkit (the
 92        # UI) when the user presses enter. Here we override it to our own accept
 93        # handler defined in this CalcPane class.
 94        self.input_field.accept_handler = self.accept_input
 95
 96        # Create a toolbar for display at the bottom of this window. It will
 97        # show the window title and toolbar buttons.
 98        self.bottom_toolbar = WindowPaneToolbar(self)
 99        self.bottom_toolbar.add_button(
100            ToolbarButton(
101                key='Enter',  # Key binding for this function
102                description='Run Calculation',  # Button name
103                # Function to run when clicked.
104                mouse_handler=self.run_calculation,
105            )
106        )
107        self.bottom_toolbar.add_button(
108            ToolbarButton(
109                key='Ctrl-c',  # Key binding for this function
110                description='Copy Output',  # Button name
111                # Function to run when clicked.
112                mouse_handler=self.copy_all_output,
113            )
114        )
115
116        # self.container is the root container that contains objects to be
117        # rendered in the UI, one on top of the other.
118        self.container = self._create_pane_container(
119            # Show the output_field on top
120            self.output_field,
121            # Draw a separator line with height=1
122            Window(height=1, char='─', style='class:line'),
123            # Show the input field just below that.
124            self.input_field,
125            # If ctrl-r reverse history is active, show the search box below the
126            # input_field.
127            self.search_field,
128            # Lastly, show the toolbar.
129            self.bottom_toolbar,
130        )
131
132    def pw_console_init(self, app: 'ConsoleApp') -> None:
133        """Set the Pigweed Console application instance.
134
135        This function is called after the Pigweed Console starts up and allows
136        access to the user preferences. Prefs is required for creating new
137        user-remappable keybinds."""
138        self.application = app
139        self.set_custom_keybinds()
140
141    def set_custom_keybinds(self) -> None:
142        # Fetch ConsoleApp preferences to load user keybindings
143        prefs = self.application.prefs
144        # Register a named keybind function that is user re-mappable
145        prefs.register_named_key_function(
146            'calc-pane.copy-selected-text',
147            # default bindings
148            ['c-c'],
149        )
150
151        # For setting additional keybindings to the output_field.
152        key_bindings = KeyBindings()
153
154        # Map the 'calc-pane.copy-selected-text' function keybind to the
155        # _copy_all_output function below. This will set
156        @prefs.register_keybinding('calc-pane.copy-selected-text', key_bindings)
157        def _copy_all_output(_event: KeyPressEvent) -> None:
158            """Copy selected text from the output buffer."""
159            self.copy_selected_output()
160
161        # Set the output_field controls key_bindings to the new bindings.
162        self.output_field.control.key_bindings = key_bindings
163
164    def run_calculation(self):
165        """Trigger the input_field's accept_handler.
166
167        This has the same effect as pressing enter in the input_field.
168        """
169        self.input_field.buffer.validate_and_handle()
170
171    def accept_input(self, _buffer):
172        """Function run when the user presses enter in the input_field.
173
174        Takes a buffer argument that contains the user's input text.
175        """
176        # Evaluate the user's calculator expression as Python and format the
177        # output result.
178        try:
179            output = "\n\nIn:  {}\nOut: {}".format(
180                self.input_field.text,
181                # NOTE: Don't use 'eval' in real code (this is just an example)
182                eval(self.input_field.text),  # pylint: disable=eval-used
183            )
184        except BaseException as exception:  # pylint: disable=broad-except
185            output = "\n\n{}".format(exception)
186
187        # Append the new output result to the existing output_field contents.
188        new_text = self.output_field.text + output
189
190        # Update the output_field with the new contents and move the
191        # cursor_position to the end.
192        self.output_field.buffer.document = Document(
193            text=new_text, cursor_position=len(new_text)
194        )
195
196    def copy_selected_output(self):
197        """Copy highlighted text in the output_field to the system clipboard."""
198        clipboard_data = self.output_field.buffer.copy_selection()
199        self.application.application.clipboard.set_data(clipboard_data)
200
201    def copy_all_output(self):
202        """Copy all text in the output_field to the system clipboard."""
203        self.application.application.clipboard.set_text(
204            self.output_field.buffer.text
205        )

Code Listing: clock_pane.py#

  1# Copyright 2021 The Pigweed Authors
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
  4# use this file except in compliance with the License. You may obtain a copy of
  5# the License at
  6#
  7#     https://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 12# License for the specific language governing permissions and limitations under
 13# the License.
 14"""Example Plugin that displays some dynamic content (a clock) and examples of
 15text formatting."""
 16
 17from datetime import datetime
 18
 19from prompt_toolkit.filters import Condition, has_focus
 20from prompt_toolkit.formatted_text import (
 21    FormattedText,
 22    HTML,
 23    merge_formatted_text,
 24)
 25from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
 26from prompt_toolkit.layout import FormattedTextControl, Window, WindowAlign
 27from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 28
 29from pw_console.plugin_mixin import PluginMixin
 30from pw_console.widgets import ToolbarButton, WindowPane, WindowPaneToolbar
 31from pw_console.get_pw_console_app import get_pw_console_app
 32
 33# Helper class used by the ClockPane plugin for displaying dynamic text,
 34# handling key bindings and mouse input. See the ClockPane class below for the
 35# beginning of the plugin implementation.
 36
 37
 38class ClockControl(FormattedTextControl):
 39    """Example prompt_toolkit UIControl for displaying formatted text.
 40
 41    This is the prompt_toolkit class that is responsible for drawing the clock,
 42    handling keybindings if in focus, and mouse input.
 43    """
 44
 45    def __init__(self, clock_pane: 'ClockPane', *args, **kwargs) -> None:
 46        self.clock_pane = clock_pane
 47
 48        # Set some custom key bindings to toggle the view mode and wrap lines.
 49        key_bindings = KeyBindings()
 50
 51        # If you press the v key this _toggle_view_mode function will be run.
 52        @key_bindings.add('v')
 53        def _toggle_view_mode(_event: KeyPressEvent) -> None:
 54            """Toggle view mode."""
 55            self.clock_pane.toggle_view_mode()
 56
 57        # If you press the w key this _toggle_wrap_lines function will be run.
 58        @key_bindings.add('w')
 59        def _toggle_wrap_lines(_event: KeyPressEvent) -> None:
 60            """Toggle line wrapping."""
 61            self.clock_pane.toggle_wrap_lines()
 62
 63        # Include the key_bindings keyword arg when passing to the parent class
 64        # __init__ function.
 65        kwargs['key_bindings'] = key_bindings
 66        # Call the parent FormattedTextControl.__init__
 67        super().__init__(*args, **kwargs)
 68
 69    def mouse_handler(self, mouse_event: MouseEvent):
 70        """Mouse handler for this control."""
 71        # If the user clicks anywhere this function is run.
 72
 73        # Mouse positions relative to this control. x is the column starting
 74        # from the left size as zero. y is the row starting with the top as
 75        # zero.
 76        _click_x = mouse_event.position.x
 77        _click_y = mouse_event.position.y
 78
 79        # Mouse click behavior usually depends on if this window pane is in
 80        # focus. If not in focus, then focus on it when left clicking. If
 81        # already in focus then perform the action specific to this window.
 82
 83        # If not in focus, change focus to this clock pane and do nothing else.
 84        if not has_focus(self.clock_pane)():
 85            if mouse_event.event_type == MouseEventType.MOUSE_UP:
 86                get_pw_console_app().focus_on_container(self.clock_pane)
 87                # Mouse event handled, return None.
 88                return None
 89
 90        # If code reaches this point, this window is already in focus.
 91        # On left click
 92        if mouse_event.event_type == MouseEventType.MOUSE_UP:
 93            # Toggle the view mode.
 94            self.clock_pane.toggle_view_mode()
 95            # Mouse event handled, return None.
 96            return None
 97
 98        # Mouse event not handled, return NotImplemented.
 99        return NotImplemented
100
101
102class ClockPane(WindowPane, PluginMixin):
103    """Example Pigweed Console plugin window that displays a clock.
104
105    The ClockPane is a WindowPane based plugin that displays a clock and some
106    formatted text examples. It inherits from both WindowPane and
107    PluginMixin. It can be added on console startup by calling: ::
108
109        my_console.add_window_plugin(ClockPane())
110
111    For an example see:
112    https://pigweed.dev/pw_console/embedding.html#adding-plugins
113    """
114
115    def __init__(self, *args, **kwargs):
116        super().__init__(*args, pane_title='Clock', **kwargs)
117        # Some toggle settings to change view and wrap lines.
118        self.view_mode_clock: bool = True
119        self.wrap_lines: bool = False
120        # Counter variable to track how many times the background task runs.
121        self.background_task_update_count: int = 0
122
123        # ClockControl is responsible for rendering the dynamic content provided
124        # by self._get_formatted_text() and handle keyboard and mouse input.
125        # Using a control is always necessary for displaying any content that
126        # will change.
127        self.clock_control = ClockControl(
128            self,  # This ClockPane class
129            self._get_formatted_text,  # Callable to get text for display
130            # These are FormattedTextControl options.
131            # See the prompt_toolkit docs for all possible options
132            # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.FormattedTextControl
133            show_cursor=False,
134            focusable=True,
135        )
136
137        # Every FormattedTextControl object (ClockControl) needs to live inside
138        # a prompt_toolkit Window() instance. Here is where you specify
139        # alignment, style, and dimensions. See the prompt_toolkit docs for all
140        # opitons:
141        # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window
142        self.clock_control_window = Window(
143            # Set the content to the clock_control defined above.
144            content=self.clock_control,
145            # Make content left aligned
146            align=WindowAlign.LEFT,
147            # These two set to false make this window fill all available space.
148            dont_extend_width=False,
149            dont_extend_height=False,
150            # Content inside this window will have its lines wrapped if
151            # self.wrap_lines is True.
152            wrap_lines=Condition(lambda: self.wrap_lines),
153        )
154
155        # Create a toolbar for display at the bottom of this clock window. It
156        # will show the window title and buttons.
157        self.bottom_toolbar = WindowPaneToolbar(self)
158
159        # Add a button to toggle the view mode.
160        self.bottom_toolbar.add_button(
161            ToolbarButton(
162                key='v',  # Key binding for this function
163                description='View Mode',  # Button name
164                # Function to run when clicked.
165                mouse_handler=self.toggle_view_mode,
166            )
167        )
168
169        # Add a checkbox button to display if wrap_lines is enabled.
170        self.bottom_toolbar.add_button(
171            ToolbarButton(
172                key='w',  # Key binding for this function
173                description='Wrap',  # Button name
174                # Function to run when clicked.
175                mouse_handler=self.toggle_wrap_lines,
176                # Display a checkbox in this button.
177                is_checkbox=True,
178                # lambda that returns the state of the checkbox
179                checked=lambda: self.wrap_lines,
180            )
181        )
182
183        # self.container is the root container that contains objects to be
184        # rendered in the UI, one on top of the other.
185        self.container = self._create_pane_container(
186            # Display the clock window on top...
187            self.clock_control_window,
188            # and the bottom_toolbar below.
189            self.bottom_toolbar,
190        )
191
192        # This plugin needs to run a task in the background periodically and
193        # uses self.plugin_init() to set which function to run, and how often.
194        # This is provided by PluginMixin. See the docs for more info:
195        # https://pigweed.dev/pw_console/plugins.html#background-tasks
196        self.plugin_init(
197            plugin_callback=self._background_task,
198            # Run self._background_task once per second.
199            plugin_callback_frequency=1.0,
200            plugin_logger_name='pw_console_example_clock_plugin',
201        )
202
203    def _background_task(self) -> bool:
204        """Function run in the background for the ClockPane plugin."""
205        self.background_task_update_count += 1
206        # Make a log message for debugging purposes. For more info see:
207        # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior
208        self.plugin_logger.debug(
209            'background_task_update_count: %s',
210            self.background_task_update_count,
211        )
212
213        # Returning True in the background task will force the user interface to
214        # re-draw.
215        # Returning False means no updates required.
216        return True
217
218    def toggle_view_mode(self):
219        """Toggle the view mode between the clock and formatted text example."""
220        self.view_mode_clock = not self.view_mode_clock
221        self.redraw_ui()
222
223    def toggle_wrap_lines(self):
224        """Enable or disable line wraping/truncation."""
225        self.wrap_lines = not self.wrap_lines
226        self.redraw_ui()
227
228    def _get_formatted_text(self):
229        """This function returns the content that will be displayed in the user
230        interface depending on which view mode is active."""
231        if self.view_mode_clock:
232            return self._get_clock_text()
233        return self._get_example_text()
234
235    def _get_clock_text(self):
236        """Create the time with some color formatting."""
237        # pylint: disable=no-self-use
238
239        # Get the date and time
240        date, time = (
241            datetime.now().isoformat(sep='_', timespec='seconds').split('_')
242        )
243
244        # Formatted text is represented as (style, text) tuples.
245        # For more examples see:
246        # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/printing_text.html
247
248        # These styles are selected using class names and start with the
249        # 'class:' prefix. For all classes defined by Pigweed Console see:
250        # https://cs.pigweed.dev/pigweed/+/main:pw_console/py/pw_console/style.py;l=189
251
252        # Date in cyan matching the current Pigweed Console theme.
253        date_with_color = ('class:theme-fg-cyan', date)
254        # Time in magenta
255        time_with_color = ('class:theme-fg-magenta', time)
256
257        # No color styles for line breaks and spaces.
258        line_break = ('', '\n')
259        space = ('', ' ')
260
261        # Concatenate the (style, text) tuples.
262        return FormattedText(
263            [
264                line_break,
265                space,
266                space,
267                date_with_color,
268                space,
269                time_with_color,
270            ]
271        )
272
273    def _get_example_text(self):
274        """Examples of how to create formatted text."""
275        # pylint: disable=no-self-use
276        # Make a list to hold all the formatted text to display.
277        fragments = []
278
279        # Some spacing vars
280        wide_space = ('', '       ')
281        space = ('', ' ')
282        newline = ('', '\n')
283
284        # HTML() is a shorthand way to style text. See:
285        # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/printing_text.html#html
286        # This formats 'Foreground Colors' as underlined:
287        fragments.append(HTML('<u>Foreground Colors</u>\n'))
288
289        # Standard ANSI colors examples
290        fragments.append(
291            FormattedText(
292                [
293                    # These tuples follow this format:
294                    #   (style_string, text_to_display)
295                    ('ansiblack', 'ansiblack'),
296                    wide_space,
297                    ('ansired', 'ansired'),
298                    wide_space,
299                    ('ansigreen', 'ansigreen'),
300                    wide_space,
301                    ('ansiyellow', 'ansiyellow'),
302                    wide_space,
303                    ('ansiblue', 'ansiblue'),
304                    wide_space,
305                    ('ansimagenta', 'ansimagenta'),
306                    wide_space,
307                    ('ansicyan', 'ansicyan'),
308                    wide_space,
309                    ('ansigray', 'ansigray'),
310                    wide_space,
311                    newline,
312                    ('ansibrightblack', 'ansibrightblack'),
313                    space,
314                    ('ansibrightred', 'ansibrightred'),
315                    space,
316                    ('ansibrightgreen', 'ansibrightgreen'),
317                    space,
318                    ('ansibrightyellow', 'ansibrightyellow'),
319                    space,
320                    ('ansibrightblue', 'ansibrightblue'),
321                    space,
322                    ('ansibrightmagenta', 'ansibrightmagenta'),
323                    space,
324                    ('ansibrightcyan', 'ansibrightcyan'),
325                    space,
326                    ('ansiwhite', 'ansiwhite'),
327                    space,
328                ]
329            )
330        )
331
332        fragments.append(HTML('\n<u>Background Colors</u>\n'))
333        fragments.append(
334            FormattedText(
335                [
336                    # Here's an example of a style that specifies both
337                    # background and foreground colors. The background color is
338                    # prefixed with 'bg:'. The foreground color follows that
339                    # with no prefix.
340                    ('bg:ansiblack ansiwhite', 'ansiblack'),
341                    wide_space,
342                    ('bg:ansired', 'ansired'),
343                    wide_space,
344                    ('bg:ansigreen', 'ansigreen'),
345                    wide_space,
346                    ('bg:ansiyellow', 'ansiyellow'),
347                    wide_space,
348                    ('bg:ansiblue ansiwhite', 'ansiblue'),
349                    wide_space,
350                    ('bg:ansimagenta', 'ansimagenta'),
351                    wide_space,
352                    ('bg:ansicyan', 'ansicyan'),
353                    wide_space,
354                    ('bg:ansigray', 'ansigray'),
355                    wide_space,
356                    ('', '\n'),
357                    ('bg:ansibrightblack', 'ansibrightblack'),
358                    space,
359                    ('bg:ansibrightred', 'ansibrightred'),
360                    space,
361                    ('bg:ansibrightgreen', 'ansibrightgreen'),
362                    space,
363                    ('bg:ansibrightyellow', 'ansibrightyellow'),
364                    space,
365                    ('bg:ansibrightblue', 'ansibrightblue'),
366                    space,
367                    ('bg:ansibrightmagenta', 'ansibrightmagenta'),
368                    space,
369                    ('bg:ansibrightcyan', 'ansibrightcyan'),
370                    space,
371                    ('bg:ansiwhite', 'ansiwhite'),
372                    space,
373                ]
374            )
375        )
376
377        # pylint: disable=line-too-long
378        # These themes use Pigweed Console style classes. See full list in:
379        # https://cs.pigweed.dev/pigweed/+/main:pw_console/py/pw_console/style.py;l=189
380        # pylint: enable=line-too-long
381        fragments.append(HTML('\n\n<u>Current Theme Foreground Colors</u>\n'))
382        fragments.append(
383            [
384                ('class:theme-fg-red', 'class:theme-fg-red'),
385                newline,
386                ('class:theme-fg-orange', 'class:theme-fg-orange'),
387                newline,
388                ('class:theme-fg-yellow', 'class:theme-fg-yellow'),
389                newline,
390                ('class:theme-fg-green', 'class:theme-fg-green'),
391                newline,
392                ('class:theme-fg-cyan', 'class:theme-fg-cyan'),
393                newline,
394                ('class:theme-fg-blue', 'class:theme-fg-blue'),
395                newline,
396                ('class:theme-fg-purple', 'class:theme-fg-purple'),
397                newline,
398                ('class:theme-fg-magenta', 'class:theme-fg-magenta'),
399                newline,
400            ]
401        )
402
403        fragments.append(HTML('\n<u>Current Theme Background Colors</u>\n'))
404        fragments.append(
405            [
406                ('class:theme-bg-red', 'class:theme-bg-red'),
407                newline,
408                ('class:theme-bg-orange', 'class:theme-bg-orange'),
409                newline,
410                ('class:theme-bg-yellow', 'class:theme-bg-yellow'),
411                newline,
412                ('class:theme-bg-green', 'class:theme-bg-green'),
413                newline,
414                ('class:theme-bg-cyan', 'class:theme-bg-cyan'),
415                newline,
416                ('class:theme-bg-blue', 'class:theme-bg-blue'),
417                newline,
418                ('class:theme-bg-purple', 'class:theme-bg-purple'),
419                newline,
420                ('class:theme-bg-magenta', 'class:theme-bg-magenta'),
421                newline,
422            ]
423        )
424
425        fragments.append(HTML('\n<u>Theme UI Colors</u>\n'))
426        fragments.append(
427            [
428                ('class:theme-fg-default', 'class:theme-fg-default'),
429                space,
430                ('class:theme-bg-default', 'class:theme-bg-default'),
431                space,
432                ('class:theme-bg-active', 'class:theme-bg-active'),
433                space,
434                ('class:theme-fg-active', 'class:theme-fg-active'),
435                space,
436                ('class:theme-bg-inactive', 'class:theme-bg-inactive'),
437                space,
438                ('class:theme-fg-inactive', 'class:theme-fg-inactive'),
439                newline,
440                ('class:theme-fg-dim', 'class:theme-fg-dim'),
441                space,
442                ('class:theme-bg-dim', 'class:theme-bg-dim'),
443                space,
444                ('class:theme-bg-dialog', 'class:theme-bg-dialog'),
445                space,
446                (
447                    'class:theme-bg-line-highlight',
448                    'class:theme-bg-line-highlight',
449                ),
450                space,
451                (
452                    'class:theme-bg-button-active',
453                    'class:theme-bg-button-active',
454                ),
455                space,
456                (
457                    'class:theme-bg-button-inactive',
458                    'class:theme-bg-button-inactive',
459                ),
460                space,
461            ]
462        )
463
464        # Return all formatted text lists merged together.
465        return merge_formatted_text(fragments)

Code Listing: twenty48_pane.py#

  1# Copyright 2022 The Pigweed Authors
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
  4# use this file except in compliance with the License. You may obtain a copy of
  5# the License at
  6#
  7#     https://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 12# License for the specific language governing permissions and limitations under
 13# the License.
 14"""Example Plugin that displays some dynamic content: a game of 2048."""
 15
 16from random import choice
 17from typing import Iterable, List, Tuple, TYPE_CHECKING
 18import time
 19
 20from prompt_toolkit.filters import has_focus
 21from prompt_toolkit.formatted_text import StyleAndTextTuples
 22from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
 23from prompt_toolkit.layout import (
 24    AnyContainer,
 25    Dimension,
 26    FormattedTextControl,
 27    HSplit,
 28    Window,
 29    WindowAlign,
 30    VSplit,
 31)
 32from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 33from prompt_toolkit.widgets import MenuItem
 34
 35from pw_console.widgets import (
 36    create_border,
 37    FloatingWindowPane,
 38    ToolbarButton,
 39    WindowPaneToolbar,
 40)
 41from pw_console.plugin_mixin import PluginMixin
 42from pw_console.get_pw_console_app import get_pw_console_app
 43
 44if TYPE_CHECKING:
 45    from pw_console.console_app import ConsoleApp
 46
 47Twenty48Cell = Tuple[int, int, int]
 48
 49
 50class Twenty48Game:
 51    """2048 Game."""
 52
 53    def __init__(self) -> None:
 54        self.colors = {
 55            2: 'bg:#dd6',
 56            4: 'bg:#da6',
 57            8: 'bg:#d86',
 58            16: 'bg:#d66',
 59            32: 'bg:#d6a',
 60            64: 'bg:#a6d',
 61            128: 'bg:#66d',
 62            256: 'bg:#68a',
 63            512: 'bg:#6a8',
 64            1024: 'bg:#6d6',
 65            2048: 'bg:#0f8',
 66            4096: 'bg:#0ff',
 67        }
 68        self.board: List[List[int]]
 69        self.last_board: List[Twenty48Cell]
 70        self.move_count: int
 71        self.width: int = 4
 72        self.height: int = 4
 73        self.max_value: int = 0
 74        self.start_time: float
 75        self.reset_game()
 76
 77    def reset_game(self) -> None:
 78        self.start_time = time.time()
 79        self.max_value = 2
 80        self.move_count = 0
 81        self.board = []
 82        for _i in range(self.height):
 83            self.board.append([0] * self.width)
 84        self.last_board = list(self.all_cells())
 85        self.add_random_tiles(2)
 86
 87    def stats(self) -> StyleAndTextTuples:
 88        """Returns stats on the game in progress."""
 89        elapsed_time = int(time.time() - self.start_time)
 90        minutes = int(elapsed_time / 60.0)
 91        seconds = elapsed_time % 60
 92        fragments: StyleAndTextTuples = []
 93        fragments.append(('', '\n'))
 94        fragments.append(('', f'Moves: {self.move_count}'))
 95        fragments.append(('', '\n'))
 96        fragments.append(('', 'Time:  {:0>2}:{:0>2}'.format(minutes, seconds)))
 97        fragments.append(('', '\n'))
 98        fragments.append(('', f'Max: {self.max_value}'))
 99        fragments.append(('', '\n\n'))
100        fragments.append(('', 'Press R to restart\n'))
101        fragments.append(('', '\n'))
102        fragments.append(('', 'Arrow keys to move'))
103        return fragments
104
105    def __pt_formatted_text__(self) -> StyleAndTextTuples:
106        """Returns the game board formatted in a grid with colors."""
107        fragments: StyleAndTextTuples = []
108
109        def print_row(row: List[int], include_number: bool = False) -> None:
110            fragments.append(('', '  '))
111            for col in row:
112                style = 'class:theme-fg-default '
113                if col > 0:
114                    style = '#000 '
115                style += self.colors.get(col, '')
116                text = ' ' * 6
117                if include_number:
118                    text = '{:^6}'.format(col)
119                fragments.append((style, text))
120            fragments.append(('', '\n'))
121
122        fragments.append(('', '\n'))
123        for row in self.board:
124            print_row(row)
125            print_row(row, include_number=True)
126            print_row(row)
127
128        return fragments
129
130    def __repr__(self) -> str:
131        board = ''
132        for row_cells in self.board:
133            for column in row_cells:
134                board += '{:^6}'.format(column)
135            board += '\n'
136        return board
137
138    def all_cells(self) -> Iterable[Twenty48Cell]:
139        for row, row_cells in enumerate(self.board):
140            for col, cell_value in enumerate(row_cells):
141                yield (row, col, cell_value)
142
143    def update_max_value(self) -> None:
144        for _row, _col, value in self.all_cells():
145            if value > self.max_value:
146                self.max_value = value
147
148    def empty_cells(self) -> Iterable[Twenty48Cell]:
149        for row, row_cells in enumerate(self.board):
150            for col, cell_value in enumerate(row_cells):
151                if cell_value != 0:
152                    continue
153                yield (row, col, cell_value)
154
155    def _board_changed(self) -> bool:
156        return self.last_board != list(self.all_cells())
157
158    def complete_move(self) -> None:
159        if not self._board_changed():
160            # Move did nothing, ignore.
161            return
162
163        self.update_max_value()
164        self.move_count += 1
165        self.add_random_tiles()
166        self.last_board = list(self.all_cells())
167
168    def add_random_tiles(self, count: int = 1) -> None:
169        for _i in range(count):
170            empty_cells = list(self.empty_cells())
171            if not empty_cells:
172                return
173            row, col, _value = choice(empty_cells)
174            self.board[row][col] = 2
175
176    def row(self, row_index: int) -> Iterable[Twenty48Cell]:
177        for col, cell_value in enumerate(self.board[row_index]):
178            yield (row_index, col, cell_value)
179
180    def col(self, col_index: int) -> Iterable[Twenty48Cell]:
181        for row, row_cells in enumerate(self.board):
182            for col, cell_value in enumerate(row_cells):
183                if col == col_index:
184                    yield (row, col, cell_value)
185
186    def non_zero_row_values(self, index: int) -> Tuple[List, List]:
187        non_zero_values = [
188            value for row, col, value in self.row(index) if value != 0
189        ]
190        padding = [0] * (self.width - len(non_zero_values))
191        return (non_zero_values, padding)
192
193    def move_right(self) -> None:
194        for i in range(self.height):
195            non_zero_values, padding = self.non_zero_row_values(i)
196            self.board[i] = padding + non_zero_values
197
198    def move_left(self) -> None:
199        for i in range(self.height):
200            non_zero_values, padding = self.non_zero_row_values(i)
201            self.board[i] = non_zero_values + padding
202
203    def add_horizontal(self, reverse=False) -> None:
204        for i in range(self.width):
205            this_row = list(self.row(i))
206            if reverse:
207                this_row = list(reversed(this_row))
208            for row, col, this_cell in this_row:
209                if this_cell == 0 or col >= self.width - 1:
210                    continue
211                next_cell = self.board[row][col + 1]
212                if this_cell == next_cell:
213                    self.board[row][col] = 0
214                    self.board[row][col + 1] = this_cell * 2
215                    break
216
217    def non_zero_col_values(self, index: int) -> Tuple[List, List]:
218        non_zero_values = [
219            value for row, col, value in self.col(index) if value != 0
220        ]
221        padding = [0] * (self.height - len(non_zero_values))
222        return (non_zero_values, padding)
223
224    def _set_column(self, col_index: int, values: List[int]) -> None:
225        for row, value in enumerate(values):
226            self.board[row][col_index] = value
227
228    def add_vertical(self, reverse=False) -> None:
229        for i in range(self.height):
230            this_column = list(self.col(i))
231            if reverse:
232                this_column = list(reversed(this_column))
233            for row, col, this_cell in this_column:
234                if this_cell == 0 or row >= self.height - 1:
235                    continue
236                next_cell = self.board[row + 1][col]
237                if this_cell == next_cell:
238                    self.board[row][col] = 0
239                    self.board[row + 1][col] = this_cell * 2
240                    break
241
242    def move_down(self) -> None:
243        for col_index in range(self.width):
244            non_zero_values, padding = self.non_zero_col_values(col_index)
245            self._set_column(col_index, padding + non_zero_values)
246
247    def move_up(self) -> None:
248        for col_index in range(self.width):
249            non_zero_values, padding = self.non_zero_col_values(col_index)
250            self._set_column(col_index, non_zero_values + padding)
251
252    def press_down(self) -> None:
253        self.move_down()
254        self.add_vertical(reverse=True)
255        self.move_down()
256        self.complete_move()
257
258    def press_up(self) -> None:
259        self.move_up()
260        self.add_vertical()
261        self.move_up()
262        self.complete_move()
263
264    def press_right(self) -> None:
265        self.move_right()
266        self.add_horizontal(reverse=True)
267        self.move_right()
268        self.complete_move()
269
270    def press_left(self) -> None:
271        self.move_left()
272        self.add_horizontal()
273        self.move_left()
274        self.complete_move()
275
276
277class Twenty48Control(FormattedTextControl):
278    """Example prompt_toolkit UIControl for displaying formatted text.
279
280    This is the prompt_toolkit class that is responsible for drawing the 2048,
281    handling keybindings if in focus, and mouse input.
282    """
283
284    def __init__(self, twenty48_pane: 'Twenty48Pane', *args, **kwargs) -> None:
285        self.twenty48_pane = twenty48_pane
286        self.game = self.twenty48_pane.game
287
288        # Set some custom key bindings to toggle the view mode and wrap lines.
289        key_bindings = KeyBindings()
290
291        @key_bindings.add('R')
292        def _restart(_event: KeyPressEvent) -> None:
293            """Restart the game."""
294            self.game.reset_game()
295
296        @key_bindings.add('q')
297        def _quit(_event: KeyPressEvent) -> None:
298            """Quit the game."""
299            self.twenty48_pane.close_dialog()
300
301        @key_bindings.add('j')
302        @key_bindings.add('down')
303        def _move_down(_event: KeyPressEvent) -> None:
304            """Move down"""
305            self.game.press_down()
306
307        @key_bindings.add('k')
308        @key_bindings.add('up')
309        def _move_up(_event: KeyPressEvent) -> None:
310            """Move up."""
311            self.game.press_up()
312
313        @key_bindings.add('h')
314        @key_bindings.add('left')
315        def _move_left(_event: KeyPressEvent) -> None:
316            """Move left."""
317            self.game.press_left()
318
319        @key_bindings.add('l')
320        @key_bindings.add('right')
321        def _move_right(_event: KeyPressEvent) -> None:
322            """Move right."""
323            self.game.press_right()
324
325        # Include the key_bindings keyword arg when passing to the parent class
326        # __init__ function.
327        kwargs['key_bindings'] = key_bindings
328        # Call the parent FormattedTextControl.__init__
329        super().__init__(*args, **kwargs)
330
331    def mouse_handler(self, mouse_event: MouseEvent):
332        """Mouse handler for this control."""
333        # If the user clicks anywhere this function is run.
334
335        # Mouse positions relative to this control. x is the column starting
336        # from the left size as zero. y is the row starting with the top as
337        # zero.
338        _click_x = mouse_event.position.x
339        _click_y = mouse_event.position.y
340
341        # Mouse click behavior usually depends on if this window pane is in
342        # focus. If not in focus, then focus on it when left clicking. If
343        # already in focus then perform the action specific to this window.
344
345        # If not in focus, change focus to this 2048 pane and do nothing else.
346        if not has_focus(self.twenty48_pane)():
347            if mouse_event.event_type == MouseEventType.MOUSE_UP:
348                get_pw_console_app().focus_on_container(self.twenty48_pane)
349                # Mouse event handled, return None.
350                return None
351
352        # If code reaches this point, this window is already in focus.
353        # if mouse_event.event_type == MouseEventType.MOUSE_UP:
354        #     # Toggle the view mode.
355        #     self.twenty48_pane.toggle_view_mode()
356        #     # Mouse event handled, return None.
357        #     return None
358
359        # Mouse event not handled, return NotImplemented.
360        return NotImplemented
361
362
363class Twenty48Pane(FloatingWindowPane, PluginMixin):
364    """Example Pigweed Console plugin to play 2048.
365
366    The Twenty48Pane is a WindowPane based plugin that displays an interactive
367    game of 2048. It inherits from both WindowPane and PluginMixin. It can be
368    added on console startup by calling: ::
369
370        my_console.add_window_plugin(Twenty48Pane())
371
372    For an example see:
373    https://pigweed.dev/pw_console/embedding.html#adding-plugins
374    """
375
376    def __init__(self, include_resize_handle: bool = True, **kwargs):
377
378        super().__init__(
379            pane_title='2048',
380            height=Dimension(preferred=17),
381            width=Dimension(preferred=50),
382            **kwargs,
383        )
384        self.game = Twenty48Game()
385
386        # Hide by default.
387        self.show_pane = False
388
389        # Create a toolbar for display at the bottom of the 2048 window. It
390        # will show the window title and buttons.
391        self.bottom_toolbar = WindowPaneToolbar(
392            self, include_resize_handle=include_resize_handle
393        )
394
395        # Add a button to restart the game.
396        self.bottom_toolbar.add_button(
397            ToolbarButton(
398                key='R',  # Key binding help text for this function
399                description='Restart',  # Button name
400                # Function to run when clicked.
401                mouse_handler=self.game.reset_game,
402            )
403        )
404        # Add a button to restart the game.
405        self.bottom_toolbar.add_button(
406            ToolbarButton(
407                key='q',  # Key binding help text for this function
408                description='Quit',  # Button name
409                # Function to run when clicked.
410                mouse_handler=self.close_dialog,
411            )
412        )
413
414        # Every FormattedTextControl object (Twenty48Control) needs to live
415        # inside a prompt_toolkit Window() instance. Here is where you specify
416        # alignment, style, and dimensions. See the prompt_toolkit docs for all
417        # opitons:
418        # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window
419        self.twenty48_game_window = Window(
420            # Set the content to a Twenty48Control instance.
421            content=Twenty48Control(
422                self,  # This Twenty48Pane class
423                self.game,  # Content from Twenty48Game.__pt_formatted_text__()
424                show_cursor=False,
425                focusable=True,
426            ),
427            # Make content left aligned
428            align=WindowAlign.LEFT,
429            # These two set to false make this window fill all available space.
430            dont_extend_width=True,
431            dont_extend_height=False,
432            wrap_lines=False,
433            width=Dimension(preferred=28),
434            height=Dimension(preferred=15),
435        )
436
437        self.twenty48_stats_window = Window(
438            content=Twenty48Control(
439                self,  # This Twenty48Pane class
440                self.game.stats,  # Content from Twenty48Game.stats()
441                show_cursor=False,
442                focusable=True,
443            ),
444            # Make content left aligned
445            align=WindowAlign.LEFT,
446            # These two set to false make this window fill all available space.
447            width=Dimension(preferred=20),
448            dont_extend_width=False,
449            dont_extend_height=False,
450            wrap_lines=False,
451        )
452
453        # self.container is the root container that contains objects to be
454        # rendered in the UI, one on top of the other.
455        self.container = self._create_pane_container(
456            create_border(
457                HSplit(
458                    [
459                        # Vertical split content
460                        VSplit(
461                            [
462                                # Left side will show the game board.
463                                self.twenty48_game_window,
464                                # Stats will be shown on the right.
465                                self.twenty48_stats_window,
466                            ]
467                        ),
468                        # The bottom_toolbar is shown below the VSplit.
469                        self.bottom_toolbar,
470                    ]
471                ),
472                title='2048',
473                border_style='class:command-runner-border',
474                # left_margin_columns=1,
475                # right_margin_columns=1,
476            )
477        )
478
479        self.dialog_content: List[AnyContainer] = [
480            # Vertical split content
481            VSplit(
482                [
483                    # Left side will show the game board.
484                    self.twenty48_game_window,
485                    # Stats will be shown on the right.
486                    self.twenty48_stats_window,
487                ]
488            ),
489            # The bottom_toolbar is shown below the VSplit.
490            self.bottom_toolbar,
491        ]
492        # Wrap the dialog content in a border
493        self.bordered_dialog_content = create_border(
494            HSplit(self.dialog_content),
495            title='2048',
496            border_style='class:command-runner-border',
497        )
498        # self.container is the root container that contains objects to be
499        # rendered in the UI, one on top of the other.
500        if include_resize_handle:
501            self.container = self._create_pane_container(*self.dialog_content)
502        else:
503            self.container = self._create_pane_container(
504                self.bordered_dialog_content
505            )
506
507        # This plugin needs to run a task in the background periodically and
508        # uses self.plugin_init() to set which function to run, and how often.
509        # This is provided by PluginMixin. See the docs for more info:
510        # https://pigweed.dev/pw_console/plugins.html#background-tasks
511        self.plugin_init(
512            plugin_callback=self._background_task,
513            # Run self._background_task once per second.
514            plugin_callback_frequency=1.0,
515            plugin_logger_name='pw_console_example_2048_plugin',
516        )
517
518    def get_top_level_menus(self) -> List[MenuItem]:
519        def _toggle_dialog() -> None:
520            self.toggle_dialog()
521
522        return [
523            MenuItem(
524                '[2048]',
525                children=[
526                    MenuItem(
527                        'Example Top Level Menu', handler=None, disabled=True
528                    ),
529                    # Menu separator
530                    MenuItem('-', None),
531                    MenuItem('Show/Hide 2048 Game', handler=_toggle_dialog),
532                    MenuItem('Restart', handler=self.game.reset_game),
533                ],
534            ),
535        ]
536
537    def pw_console_init(self, app: 'ConsoleApp') -> None:
538        """Set the Pigweed Console application instance.
539
540        This function is called after the Pigweed Console starts up and allows
541        access to the user preferences. Prefs is required for creating new
542        user-remappable keybinds."""
543        self.application = app
544
545    def _background_task(self) -> bool:
546        """Function run in the background for the ClockPane plugin."""
547        # Optional: make a log message for debugging purposes. For more info
548        # see:
549        # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior
550        # self.plugin_logger.debug('background_task_update_count: %s',
551        #                          self.background_task_update_count)
552
553        # Returning True in the background task will force the user interface to
554        # re-draw.
555        # Returning False means no updates required.
556
557        if self.show_pane:
558            # Return true so the game clock is updated.
559            return True
560
561        # Game window is hidden, don't redraw.
562        return False