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:
Create a new Python class inheriting from either WindowPane or WindowPaneToolbar.
Optionally inherit from The
PluginMixin
class as well for running background tasks.
Enable the plugin before pw_console startup by calling
add_window_plugin
,add_floating_window_plugin
,add_top_toolbar
oradd_bottom_toolbar
. See the Adding Plugins section of the Embedding Guide for an example.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_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',
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.#
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.#
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.#
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_toolkitFormattedTextControl
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.#
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 self.max_value = max(self.max_value, value)
148
149 def empty_cells(self) -> Iterable[Twenty48Cell]:
150 for row, row_cells in enumerate(self.board):
151 for col, cell_value in enumerate(row_cells):
152 if cell_value != 0:
153 continue
154 yield (row, col, cell_value)
155
156 def _board_changed(self) -> bool:
157 return self.last_board != list(self.all_cells())
158
159 def complete_move(self) -> None:
160 if not self._board_changed():
161 # Move did nothing, ignore.
162 return
163
164 self.update_max_value()
165 self.move_count += 1
166 self.add_random_tiles()
167 self.last_board = list(self.all_cells())
168
169 def add_random_tiles(self, count: int = 1) -> None:
170 for _i in range(count):
171 empty_cells = list(self.empty_cells())
172 if not empty_cells:
173 return
174 row, col, _value = choice(empty_cells)
175 self.board[row][col] = 2
176
177 def row(self, row_index: int) -> Iterable[Twenty48Cell]:
178 for col, cell_value in enumerate(self.board[row_index]):
179 yield (row_index, col, cell_value)
180
181 def col(self, col_index: int) -> Iterable[Twenty48Cell]:
182 for row, row_cells in enumerate(self.board):
183 for col, cell_value in enumerate(row_cells):
184 if col == col_index:
185 yield (row, col, cell_value)
186
187 def non_zero_row_values(self, index: int) -> tuple[list, list]:
188 non_zero_values = [
189 value for row, col, value in self.row(index) if value != 0
190 ]
191 padding = [0] * (self.width - len(non_zero_values))
192 return (non_zero_values, padding)
193
194 def move_right(self) -> None:
195 for i in range(self.height):
196 non_zero_values, padding = self.non_zero_row_values(i)
197 self.board[i] = padding + non_zero_values
198
199 def move_left(self) -> None:
200 for i in range(self.height):
201 non_zero_values, padding = self.non_zero_row_values(i)
202 self.board[i] = non_zero_values + padding
203
204 def add_horizontal(self, reverse=False) -> None:
205 for i in range(self.width):
206 this_row = list(self.row(i))
207 if reverse:
208 this_row = list(reversed(this_row))
209 for row, col, this_cell in this_row:
210 if this_cell == 0 or col >= self.width - 1:
211 continue
212 next_cell = self.board[row][col + 1]
213 if this_cell == next_cell:
214 self.board[row][col] = 0
215 self.board[row][col + 1] = this_cell * 2
216 break
217
218 def non_zero_col_values(self, index: int) -> tuple[list, list]:
219 non_zero_values = [
220 value for row, col, value in self.col(index) if value != 0
221 ]
222 padding = [0] * (self.height - len(non_zero_values))
223 return (non_zero_values, padding)
224
225 def _set_column(self, col_index: int, values: list[int]) -> None:
226 for row, value in enumerate(values):
227 self.board[row][col_index] = value
228
229 def add_vertical(self, reverse=False) -> None:
230 for i in range(self.height):
231 this_column = list(self.col(i))
232 if reverse:
233 this_column = list(reversed(this_column))
234 for row, col, this_cell in this_column:
235 if this_cell == 0 or row >= self.height - 1:
236 continue
237 next_cell = self.board[row + 1][col]
238 if this_cell == next_cell:
239 self.board[row][col] = 0
240 self.board[row + 1][col] = this_cell * 2
241 break
242
243 def move_down(self) -> None:
244 for col_index in range(self.width):
245 non_zero_values, padding = self.non_zero_col_values(col_index)
246 self._set_column(col_index, padding + non_zero_values)
247
248 def move_up(self) -> None:
249 for col_index in range(self.width):
250 non_zero_values, padding = self.non_zero_col_values(col_index)
251 self._set_column(col_index, non_zero_values + padding)
252
253 def press_down(self) -> None:
254 self.move_down()
255 self.add_vertical(reverse=True)
256 self.move_down()
257 self.complete_move()
258
259 def press_up(self) -> None:
260 self.move_up()
261 self.add_vertical()
262 self.move_up()
263 self.complete_move()
264
265 def press_right(self) -> None:
266 self.move_right()
267 self.add_horizontal(reverse=True)
268 self.move_right()
269 self.complete_move()
270
271 def press_left(self) -> None:
272 self.move_left()
273 self.add_horizontal()
274 self.move_left()
275 self.complete_move()
276
277
278class Twenty48Control(FormattedTextControl):
279 """Example prompt_toolkit UIControl for displaying formatted text.
280
281 This is the prompt_toolkit class that is responsible for drawing the 2048,
282 handling keybindings if in focus, and mouse input.
283 """
284
285 def __init__(self, twenty48_pane: Twenty48Pane, *args, **kwargs) -> None:
286 self.twenty48_pane = twenty48_pane
287 self.game = self.twenty48_pane.game
288
289 # Set some custom key bindings to toggle the view mode and wrap lines.
290 key_bindings = KeyBindings()
291
292 @key_bindings.add('R')
293 def _restart(_event: KeyPressEvent) -> None:
294 """Restart the game."""
295 self.game.reset_game()
296
297 @key_bindings.add('q')
298 def _quit(_event: KeyPressEvent) -> None:
299 """Quit the game."""
300 self.twenty48_pane.close_dialog()
301
302 @key_bindings.add('j')
303 @key_bindings.add('down')
304 def _move_down(_event: KeyPressEvent) -> None:
305 """Move down"""
306 self.game.press_down()
307
308 @key_bindings.add('k')
309 @key_bindings.add('up')
310 def _move_up(_event: KeyPressEvent) -> None:
311 """Move up."""
312 self.game.press_up()
313
314 @key_bindings.add('h')
315 @key_bindings.add('left')
316 def _move_left(_event: KeyPressEvent) -> None:
317 """Move left."""
318 self.game.press_left()
319
320 @key_bindings.add('l')
321 @key_bindings.add('right')
322 def _move_right(_event: KeyPressEvent) -> None:
323 """Move right."""
324 self.game.press_right()
325
326 # Include the key_bindings keyword arg when passing to the parent class
327 # __init__ function.
328 kwargs['key_bindings'] = key_bindings
329 # Call the parent FormattedTextControl.__init__
330 super().__init__(*args, **kwargs)
331
332 def mouse_handler(self, mouse_event: MouseEvent):
333 """Mouse handler for this control."""
334 # If the user clicks anywhere this function is run.
335
336 # Mouse positions relative to this control. x is the column starting
337 # from the left size as zero. y is the row starting with the top as
338 # zero.
339 _click_x = mouse_event.position.x
340 _click_y = mouse_event.position.y
341
342 # Mouse click behavior usually depends on if this window pane is in
343 # focus. If not in focus, then focus on it when left clicking. If
344 # already in focus then perform the action specific to this window.
345
346 # If not in focus, change focus to this 2048 pane and do nothing else.
347 if not has_focus(self.twenty48_pane)():
348 if mouse_event.event_type == MouseEventType.MOUSE_UP:
349 get_pw_console_app().focus_on_container(self.twenty48_pane)
350 # Mouse event handled, return None.
351 return None
352
353 # If code reaches this point, this window is already in focus.
354 # if mouse_event.event_type == MouseEventType.MOUSE_UP:
355 # # Toggle the view mode.
356 # self.twenty48_pane.toggle_view_mode()
357 # # Mouse event handled, return None.
358 # return None
359
360 # Mouse event not handled, return NotImplemented.
361 return NotImplemented
362
363
364class Twenty48Pane(FloatingWindowPane, PluginMixin):
365 """Example Pigweed Console plugin to play 2048.
366
367 The Twenty48Pane is a WindowPane based plugin that displays an interactive
368 game of 2048. It inherits from both WindowPane and PluginMixin. It can be
369 added on console startup by calling: ::
370
371 my_console.add_window_plugin(Twenty48Pane())
372
373 For an example see:
374 https://pigweed.dev/pw_console/embedding.html#adding-plugins
375 """
376
377 def __init__(self, include_resize_handle: bool = True, **kwargs):
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