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.
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.
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:
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.
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