Plugin Guide#

pw_console: Multi-purpose pluggable interactive console for dev & manufacturing

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: Callable[[...], bool] | None = None,
plugin_callback_frequency: float = 30.0,
plugin_logger_name: str | None = '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.

Bandwidth Toolbar#

Tracks and logs the data sent and received over a serial transport like a socket or PySerial device. To use in a custom transport interface instantiate the SerialBandwidthTracker and call track_read_data on incoming data bytes and track_write_data on outoing data bytes.

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

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