Plugin Guide¶
Pigweed Console supports extending the user interface with custom widgets. For example: Toolbars that display device information and provide buttons for interacting with the device.
Writing Plugins¶
Creating new plugins has a few high level steps:
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_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: Optional[Callable[[...], bool]] = None, plugin_callback_frequency: float = 30.0, plugin_logger_name: Optional[str] = 'pw_console_plugins') None ¶
Call this on __init__() to set plugin background task variables.
- Parameters
plugin_callback – Callable to run in a separate thread from the Pigweed Console UI. This function should return True if the UI should be redrawn after execution.
plugin_callback_frequency – Number of seconds to wait between executing plugin_callback.
plugin_logger_name – Unique name for this plugin’s Python logger. Useful for debugging code running in a separate thread.
- plugin_start()¶
Function used to start this plugin’s background thead and task.
Debugging Plugin Behavior¶
If your plugin uses background threads for updating it can be difficult to see
errors. Often, nothing will appear to be happening and exceptions may not be
visible. When using PluginMixin
you can specify a name for a Python logger
to use with the plugin_logger_name
keyword argument.
class AwesomeToolbar(WindowPaneToolbar, PluginMixin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.update_count = 0
self.plugin_init(
plugin_callback=self._background_task,
plugin_callback_frequency=1.0,
plugin_logger_name='my_awesome_plugin',
)
def _background_task(self) -> bool:
self.update_count += 1
self.plugin_logger.debug('background_task_update_count: %s',
self.update_count)
return True
This will let you open up a new log window while the console is running to see what the plugin is doing. Open up the logger name provided above by clicking in the main menu: File > Open Logger > my_awesome_plugin.
Sample Plugins¶
Pigweed Console will provide a few sample plugins to serve as templates for creating your own plugins. These are a work in progress at the moment and not available at this time.
Calculator¶
This plugin is similar to the full-screen calculator.py example provided in prompt_toolkit. It’s a full window that can be moved around the user interface like other Pigweed Console window panes. An input prompt is displayed on the bottom of the window where the user can type in some math equation. When the enter key is pressed the input is processed and the result shown in the top half of the window.
Both input and output fields are prompt_toolkit TextArea objects which can have their own options like syntax highlighting.
Screenshot of the CalcPane
plugin showing some math calculations.¶
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
WindowPane
and PluginMixin
. Game keybindings are set within the
Twenty48Control
class which is the FormattedTextControl
widget that is
in focus while playing.
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 typing import TYPE_CHECKING
17
18from prompt_toolkit.document import Document
19from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
20from prompt_toolkit.layout import Window
21from prompt_toolkit.widgets import SearchToolbar, TextArea
22
23from pw_console.widgets import ToolbarButton, WindowPane, WindowPaneToolbar
24
25if TYPE_CHECKING:
26 from pw_console.console_app import ConsoleApp
27
28
29class CalcPane(WindowPane):
30 """Example plugin that accepts text input and displays output.
31
32 This plugin is similar to the full-screen calculator example provided in
33 prompt_toolkit:
34 https://github.com/prompt-toolkit/python-prompt-toolkit/blob/3.0.23/examples/full-screen/calculator.py
35
36 It's a full window that can be moved around the user interface like other
37 Pigweed Console window panes. An input prompt is displayed on the bottom of
38 the window where the user can type in some math equation. When the enter key
39 is pressed the input is processed and the result shown in the top half of
40 the window.
41
42 Both input and output fields are prompt_toolkit TextArea objects which can
43 have their own options like syntax highlighting.
44 """
45 def __init__(self):
46 # Call WindowPane.__init__ and set the title to 'Calculator'
47 super().__init__(pane_title='Calculator')
48
49 # Create a TextArea for the output-field
50 # TextArea is a prompt_toolkit widget that can display editable text in
51 # a buffer. See the prompt_toolkit docs for all possible options:
52 # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.widgets.TextArea
53 self.output_field = TextArea(
54 # Optional Styles to apply to this TextArea
55 style='class:output-field',
56 # Initial text to put into the buffer.
57 text='Calculator Output',
58 # Allow this buffer to be in focus. This lets you drag select text
59 # contained inside, and edit the contents unless readonly.
60 focusable=True,
61 # Focus on mouse click.
62 focus_on_click=True,
63 )
64
65 # This is the search toolbar and only appears if the user presses ctrl-r
66 # to do reverse history search (similar to bash or zsh). Its used by the
67 # input_field below.
68 self.search_field = SearchToolbar()
69
70 # Create a TextArea for the user input.
71 self.input_field = TextArea(
72 # The height is set to 1 line
73 height=1,
74 # Prompt string that appears before the cursor.
75 prompt='>>> ',
76 # Optional Styles to apply to this TextArea
77 style='class:input-field',
78 # We only allow one line input for this example but multiline is
79 # supported by prompt_toolkit.
80 multiline=False,
81 wrap_lines=False,
82 # Allow reverse history search
83 search_field=self.search_field,
84 # Allow this input to be focused.
85 focusable=True,
86 # Focus on mouse click.
87 focus_on_click=True,
88 )
89
90 # The TextArea accept_handler function is called by prompt_toolkit (the
91 # UI) when the user presses enter. Here we override it to our own accept
92 # handler defined in this CalcPane class.
93 self.input_field.accept_handler = self.accept_input
94
95 # Create a toolbar for display at the bottom of this window. It will
96 # show the window title and toolbar buttons.
97 self.bottom_toolbar = WindowPaneToolbar(self)
98 self.bottom_toolbar.add_button(
99 ToolbarButton(
100 key='Enter', # Key binding for this function
101 description='Run Calculation', # Button name
102 # Function to run when clicked.
103 mouse_handler=self.run_calculation,
104 ))
105 self.bottom_toolbar.add_button(
106 ToolbarButton(
107 key='Ctrl-c', # Key binding for this function
108 description='Copy Output', # Button name
109 # Function to run when clicked.
110 mouse_handler=self.copy_all_output,
111 ))
112
113 # self.container is the root container that contains objects to be
114 # rendered in the UI, one on top of the other.
115 self.container = self._create_pane_container(
116 # Show the output_field on top
117 self.output_field,
118 # Draw a separator line with height=1
119 Window(height=1, char='─', style='class:line'),
120 # Show the input field just below that.
121 self.input_field,
122 # If ctrl-r reverse history is active, show the search box below the
123 # input_field.
124 self.search_field,
125 # Lastly, show the toolbar.
126 self.bottom_toolbar,
127 )
128
129 def pw_console_init(self, app: 'ConsoleApp') -> None:
130 """Set the Pigweed Console application instance.
131
132 This function is called after the Pigweed Console starts up and allows
133 access to the user preferences. Prefs is required for creating new
134 user-remappable keybinds."""
135 self.application = app
136 self.set_custom_keybinds()
137
138 def set_custom_keybinds(self) -> None:
139 # Fetch ConsoleApp preferences to load user keybindings
140 prefs = self.application.prefs
141 # Register a named keybind function that is user re-mappable
142 prefs.register_named_key_function(
143 'calc-pane.copy-selected-text',
144 # default bindings
145 ['c-c'])
146
147 # For setting additional keybindings to the output_field.
148 key_bindings = KeyBindings()
149
150 # Map the 'calc-pane.copy-selected-text' function keybind to the
151 # _copy_all_output function below. This will set
152 @prefs.register_keybinding('calc-pane.copy-selected-text',
153 key_bindings)
154 def _copy_all_output(_event: KeyPressEvent) -> None:
155 """Copy selected text from the output buffer."""
156 self.copy_selected_output()
157
158 # Set the output_field controls key_bindings to the new bindings.
159 self.output_field.control.key_bindings = key_bindings
160
161 def run_calculation(self):
162 """Trigger the input_field's accept_handler.
163
164 This has the same effect as pressing enter in the input_field.
165 """
166 self.input_field.buffer.validate_and_handle()
167
168 def accept_input(self, _buffer):
169 """Function run when the user presses enter in the input_field.
170
171 Takes a buffer argument that contains the user's input text.
172 """
173 # Evaluate the user's calculator expression as Python and format the
174 # output result.
175 try:
176 output = "\n\nIn: {}\nOut: {}".format(
177 self.input_field.text,
178 # NOTE: Don't use 'eval' in real code (this is just an example)
179 eval(self.input_field.text)) # pylint: disable=eval-used
180 except BaseException as exception: # pylint: disable=broad-except
181 output = "\n\n{}".format(exception)
182
183 # Append the new output result to the existing output_field contents.
184 new_text = self.output_field.text + output
185
186 # Update the output_field with the new contents and move the
187 # cursor_position to the end.
188 self.output_field.buffer.document = Document(
189 text=new_text, cursor_position=len(new_text))
190
191 def copy_selected_output(self):
192 """Copy highlighted text in the output_field to the system clipboard."""
193 clipboard_data = self.output_field.buffer.copy_selection()
194 self.application.application.clipboard.set_data(clipboard_data)
195
196 def copy_all_output(self):
197 """Copy all text in the output_field to the system clipboard."""
198 self.application.application.clipboard.set_text(
199 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 datetime import datetime
18
19from prompt_toolkit.filters import Condition, has_focus
20from prompt_toolkit.formatted_text import (
21 FormattedText,
22 HTML,
23 merge_formatted_text,
24)
25from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
26from prompt_toolkit.layout import FormattedTextControl, Window, WindowAlign
27from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
28
29from pw_console.plugin_mixin import PluginMixin
30from pw_console.widgets import ToolbarButton, WindowPane, WindowPaneToolbar
31from pw_console.get_pw_console_app import get_pw_console_app
32
33# Helper class used by the ClockPane plugin for displaying dynamic text,
34# handling key bindings and mouse input. See the ClockPane class below for the
35# beginning of the plugin implementation.
36
37
38class ClockControl(FormattedTextControl):
39 """Example prompt_toolkit UIControl for displaying formatted text.
40
41 This is the prompt_toolkit class that is responsible for drawing the clock,
42 handling keybindings if in focus, and mouse input.
43 """
44 def __init__(self, clock_pane: 'ClockPane', *args, **kwargs) -> None:
45 self.clock_pane = clock_pane
46
47 # Set some custom key bindings to toggle the view mode and wrap lines.
48 key_bindings = KeyBindings()
49
50 # If you press the v key this _toggle_view_mode function will be run.
51 @key_bindings.add('v')
52 def _toggle_view_mode(_event: KeyPressEvent) -> None:
53 """Toggle view mode."""
54 self.clock_pane.toggle_view_mode()
55
56 # If you press the w key this _toggle_wrap_lines function will be run.
57 @key_bindings.add('w')
58 def _toggle_wrap_lines(_event: KeyPressEvent) -> None:
59 """Toggle line wrapping."""
60 self.clock_pane.toggle_wrap_lines()
61
62 # Include the key_bindings keyword arg when passing to the parent class
63 # __init__ function.
64 kwargs['key_bindings'] = key_bindings
65 # Call the parent FormattedTextControl.__init__
66 super().__init__(*args, **kwargs)
67
68 def mouse_handler(self, mouse_event: MouseEvent):
69 """Mouse handler for this control."""
70 # If the user clicks anywhere this function is run.
71
72 # Mouse positions relative to this control. x is the column starting
73 # from the left size as zero. y is the row starting with the top as
74 # zero.
75 _click_x = mouse_event.position.x
76 _click_y = mouse_event.position.y
77
78 # Mouse click behavior usually depends on if this window pane is in
79 # focus. If not in focus, then focus on it when left clicking. If
80 # already in focus then perform the action specific to this window.
81
82 # If not in focus, change focus to this clock pane and do nothing else.
83 if not has_focus(self.clock_pane)():
84 if mouse_event.event_type == MouseEventType.MOUSE_UP:
85 get_pw_console_app().focus_on_container(self.clock_pane)
86 # Mouse event handled, return None.
87 return None
88
89 # If code reaches this point, this window is already in focus.
90 # On left click
91 if mouse_event.event_type == MouseEventType.MOUSE_UP:
92 # Toggle the view mode.
93 self.clock_pane.toggle_view_mode()
94 # Mouse event handled, return None.
95 return None
96
97 # Mouse event not handled, return NotImplemented.
98 return NotImplemented
99
100
101class ClockPane(WindowPane, PluginMixin):
102 """Example Pigweed Console plugin window that displays a clock.
103
104 The ClockPane is a WindowPane based plugin that displays a clock and some
105 formatted text examples. It inherits from both WindowPane and
106 PluginMixin. It can be added on console startup by calling: ::
107
108 my_console.add_window_plugin(ClockPane())
109
110 For an example see:
111 https://pigweed.dev/pw_console/embedding.html#adding-plugins
112 """
113 def __init__(self, *args, **kwargs):
114 super().__init__(*args, pane_title='Clock', **kwargs)
115 # Some toggle settings to change view and wrap lines.
116 self.view_mode_clock: bool = True
117 self.wrap_lines: bool = False
118 # Counter variable to track how many times the background task runs.
119 self.background_task_update_count: int = 0
120
121 # ClockControl is responsible for rendering the dynamic content provided
122 # by self._get_formatted_text() and handle keyboard and mouse input.
123 # Using a control is always necessary for displaying any content that
124 # will change.
125 self.clock_control = ClockControl(
126 self, # This ClockPane class
127 self._get_formatted_text, # Callable to get text for display
128 # These are FormattedTextControl options.
129 # See the prompt_toolkit docs for all possible options
130 # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.FormattedTextControl
131 show_cursor=False,
132 focusable=True,
133 )
134
135 # Every FormattedTextControl object (ClockControl) needs to live inside
136 # a prompt_toolkit Window() instance. Here is where you specify
137 # alignment, style, and dimensions. See the prompt_toolkit docs for all
138 # opitons:
139 # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window
140 self.clock_control_window = Window(
141 # Set the content to the clock_control defined above.
142 content=self.clock_control,
143 # Make content left aligned
144 align=WindowAlign.LEFT,
145 # These two set to false make this window fill all available space.
146 dont_extend_width=False,
147 dont_extend_height=False,
148 # Content inside this window will have its lines wrapped if
149 # self.wrap_lines is True.
150 wrap_lines=Condition(lambda: self.wrap_lines),
151 )
152
153 # Create a toolbar for display at the bottom of this clock window. It
154 # will show the window title and buttons.
155 self.bottom_toolbar = WindowPaneToolbar(self)
156
157 # Add a button to toggle the view mode.
158 self.bottom_toolbar.add_button(
159 ToolbarButton(
160 key='v', # Key binding for this function
161 description='View Mode', # Button name
162 # Function to run when clicked.
163 mouse_handler=self.toggle_view_mode,
164 ))
165
166 # Add a checkbox button to display if wrap_lines is enabled.
167 self.bottom_toolbar.add_button(
168 ToolbarButton(
169 key='w', # Key binding for this function
170 description='Wrap', # Button name
171 # Function to run when clicked.
172 mouse_handler=self.toggle_wrap_lines,
173 # Display a checkbox in this button.
174 is_checkbox=True,
175 # lambda that returns the state of the checkbox
176 checked=lambda: self.wrap_lines,
177 ))
178
179 # self.container is the root container that contains objects to be
180 # rendered in the UI, one on top of the other.
181 self.container = self._create_pane_container(
182 # Display the clock window on top...
183 self.clock_control_window,
184 # and the bottom_toolbar below.
185 self.bottom_toolbar,
186 )
187
188 # This plugin needs to run a task in the background periodically and
189 # uses self.plugin_init() to set which function to run, and how often.
190 # This is provided by PluginMixin. See the docs for more info:
191 # https://pigweed.dev/pw_console/plugins.html#background-tasks
192 self.plugin_init(
193 plugin_callback=self._background_task,
194 # Run self._background_task once per second.
195 plugin_callback_frequency=1.0,
196 plugin_logger_name='pw_console_example_clock_plugin',
197 )
198
199 def _background_task(self) -> bool:
200 """Function run in the background for the ClockPane plugin."""
201 self.background_task_update_count += 1
202 # Make a log message for debugging purposes. For more info see:
203 # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior
204 self.plugin_logger.debug('background_task_update_count: %s',
205 self.background_task_update_count)
206
207 # Returning True in the background task will force the user interface to
208 # re-draw.
209 # Returning False means no updates required.
210 return True
211
212 def toggle_view_mode(self):
213 """Toggle the view mode between the clock and formatted text example."""
214 self.view_mode_clock = not self.view_mode_clock
215 self.redraw_ui()
216
217 def toggle_wrap_lines(self):
218 """Enable or disable line wraping/truncation."""
219 self.wrap_lines = not self.wrap_lines
220 self.redraw_ui()
221
222 def _get_formatted_text(self):
223 """This function returns the content that will be displayed in the user
224 interface depending on which view mode is active."""
225 if self.view_mode_clock:
226 return self._get_clock_text()
227 return self._get_example_text()
228
229 def _get_clock_text(self):
230 """Create the time with some color formatting."""
231 # pylint: disable=no-self-use
232
233 # Get the date and time
234 date, time = datetime.now().isoformat(sep='_',
235 timespec='seconds').split('_')
236
237 # Formatted text is represented as (style, text) tuples.
238 # For more examples see:
239 # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/printing_text.html
240
241 # These styles are selected using class names and start with the
242 # 'class:' prefix. For all classes defined by Pigweed Console see:
243 # https://cs.opensource.google/pigweed/pigweed/+/main:pw_console/py/pw_console/style.py;l=189
244
245 # Date in cyan matching the current Pigweed Console theme.
246 date_with_color = ('class:theme-fg-cyan', date)
247 # Time in magenta
248 time_with_color = ('class:theme-fg-magenta', time)
249
250 # No color styles for line breaks and spaces.
251 line_break = ('', '\n')
252 space = ('', ' ')
253
254 # Concatenate the (style, text) tuples.
255 return FormattedText([
256 line_break,
257 space,
258 space,
259 date_with_color,
260 space,
261 time_with_color,
262 ])
263
264 def _get_example_text(self):
265 """Examples of how to create formatted text."""
266 # pylint: disable=no-self-use
267 # Make a list to hold all the formatted text to display.
268 fragments = []
269
270 # Some spacing vars
271 wide_space = ('', ' ')
272 space = ('', ' ')
273 newline = ('', '\n')
274
275 # HTML() is a shorthand way to style text. See:
276 # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/printing_text.html#html
277 # This formats 'Foreground Colors' as underlined:
278 fragments.append(HTML('<u>Foreground Colors</u>\n'))
279
280 # Standard ANSI colors examples
281 fragments.append(
282 FormattedText([
283 # These tuples follow this format:
284 # (style_string, text_to_display)
285 ('ansiblack', 'ansiblack'),
286 wide_space,
287 ('ansired', 'ansired'),
288 wide_space,
289 ('ansigreen', 'ansigreen'),
290 wide_space,
291 ('ansiyellow', 'ansiyellow'),
292 wide_space,
293 ('ansiblue', 'ansiblue'),
294 wide_space,
295 ('ansimagenta', 'ansimagenta'),
296 wide_space,
297 ('ansicyan', 'ansicyan'),
298 wide_space,
299 ('ansigray', 'ansigray'),
300 wide_space,
301 newline,
302 ('ansibrightblack', 'ansibrightblack'),
303 space,
304 ('ansibrightred', 'ansibrightred'),
305 space,
306 ('ansibrightgreen', 'ansibrightgreen'),
307 space,
308 ('ansibrightyellow', 'ansibrightyellow'),
309 space,
310 ('ansibrightblue', 'ansibrightblue'),
311 space,
312 ('ansibrightmagenta', 'ansibrightmagenta'),
313 space,
314 ('ansibrightcyan', 'ansibrightcyan'),
315 space,
316 ('ansiwhite', 'ansiwhite'),
317 space,
318 ]))
319
320 fragments.append(HTML('\n<u>Background Colors</u>\n'))
321 fragments.append(
322 FormattedText([
323 # Here's an example of a style that specifies both background
324 # and foreground colors. The background color is prefixed with
325 # 'bg:'. The foreground color follows that with no prefix.
326 ('bg:ansiblack ansiwhite', 'ansiblack'),
327 wide_space,
328 ('bg:ansired', 'ansired'),
329 wide_space,
330 ('bg:ansigreen', 'ansigreen'),
331 wide_space,
332 ('bg:ansiyellow', 'ansiyellow'),
333 wide_space,
334 ('bg:ansiblue ansiwhite', 'ansiblue'),
335 wide_space,
336 ('bg:ansimagenta', 'ansimagenta'),
337 wide_space,
338 ('bg:ansicyan', 'ansicyan'),
339 wide_space,
340 ('bg:ansigray', 'ansigray'),
341 wide_space,
342 ('', '\n'),
343 ('bg:ansibrightblack', 'ansibrightblack'),
344 space,
345 ('bg:ansibrightred', 'ansibrightred'),
346 space,
347 ('bg:ansibrightgreen', 'ansibrightgreen'),
348 space,
349 ('bg:ansibrightyellow', 'ansibrightyellow'),
350 space,
351 ('bg:ansibrightblue', 'ansibrightblue'),
352 space,
353 ('bg:ansibrightmagenta', 'ansibrightmagenta'),
354 space,
355 ('bg:ansibrightcyan', 'ansibrightcyan'),
356 space,
357 ('bg:ansiwhite', 'ansiwhite'),
358 space,
359 ]))
360
361 # These themes use Pigweed Console style classes. See full list in:
362 # https://cs.opensource.google/pigweed/pigweed/+/main:pw_console/py/pw_console/style.py;l=189
363 fragments.append(HTML('\n\n<u>Current Theme Foreground Colors</u>\n'))
364 fragments.append([
365 ('class:theme-fg-red', 'class:theme-fg-red'),
366 newline,
367 ('class:theme-fg-orange', 'class:theme-fg-orange'),
368 newline,
369 ('class:theme-fg-yellow', 'class:theme-fg-yellow'),
370 newline,
371 ('class:theme-fg-green', 'class:theme-fg-green'),
372 newline,
373 ('class:theme-fg-cyan', 'class:theme-fg-cyan'),
374 newline,
375 ('class:theme-fg-blue', 'class:theme-fg-blue'),
376 newline,
377 ('class:theme-fg-purple', 'class:theme-fg-purple'),
378 newline,
379 ('class:theme-fg-magenta', 'class:theme-fg-magenta'),
380 newline,
381 ])
382
383 fragments.append(HTML('\n<u>Current Theme Background Colors</u>\n'))
384 fragments.append([
385 ('class:theme-bg-red', 'class:theme-bg-red'),
386 newline,
387 ('class:theme-bg-orange', 'class:theme-bg-orange'),
388 newline,
389 ('class:theme-bg-yellow', 'class:theme-bg-yellow'),
390 newline,
391 ('class:theme-bg-green', 'class:theme-bg-green'),
392 newline,
393 ('class:theme-bg-cyan', 'class:theme-bg-cyan'),
394 newline,
395 ('class:theme-bg-blue', 'class:theme-bg-blue'),
396 newline,
397 ('class:theme-bg-purple', 'class:theme-bg-purple'),
398 newline,
399 ('class:theme-bg-magenta', 'class:theme-bg-magenta'),
400 newline,
401 ])
402
403 fragments.append(HTML('\n<u>Theme UI Colors</u>\n'))
404 fragments.append([
405 ('class:theme-fg-default', 'class:theme-fg-default'),
406 space,
407 ('class:theme-bg-default', 'class:theme-bg-default'),
408 space,
409 ('class:theme-bg-active', 'class:theme-bg-active'),
410 space,
411 ('class:theme-fg-active', 'class:theme-fg-active'),
412 space,
413 ('class:theme-bg-inactive', 'class:theme-bg-inactive'),
414 space,
415 ('class:theme-fg-inactive', 'class:theme-fg-inactive'),
416 newline,
417 ('class:theme-fg-dim', 'class:theme-fg-dim'),
418 space,
419 ('class:theme-bg-dim', 'class:theme-bg-dim'),
420 space,
421 ('class:theme-bg-dialog', 'class:theme-bg-dialog'),
422 space,
423 ('class:theme-bg-line-highlight', 'class:theme-bg-line-highlight'),
424 space,
425 ('class:theme-bg-button-active', 'class:theme-bg-button-active'),
426 space,
427 ('class:theme-bg-button-inactive',
428 'class:theme-bg-button-inactive'),
429 space,
430 ])
431
432 # Return all formatted text lists merged together.
433 return merge_formatted_text(fragments)
Code Listing: twenty48_pane.py
¶
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7# https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Example Plugin that displays some dynamic content: a game of 2048."""
15
16from random import choice
17from typing import Any, Iterable, List, Tuple
18import time
19
20from prompt_toolkit.filters import has_focus
21from prompt_toolkit.formatted_text import StyleAndTextTuples
22from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
23from prompt_toolkit.layout import (
24 Dimension,
25 FormattedTextControl,
26 Window,
27 WindowAlign,
28 VSplit,
29)
30from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
31
32from pw_console.widgets import ToolbarButton, WindowPane, WindowPaneToolbar
33from pw_console.plugin_mixin import PluginMixin
34from pw_console.get_pw_console_app import get_pw_console_app
35
36Twenty48Cell = Tuple[int, int, int]
37
38
39class Twenty48Game:
40 """2048 Game."""
41 def __init__(self) -> None:
42 self.colors = {
43 2: 'bg:#dd6',
44 4: 'bg:#da6',
45 8: 'bg:#d86',
46 16: 'bg:#d66',
47 32: 'bg:#d6a',
48 64: 'bg:#a6d',
49 128: 'bg:#66d',
50 256: 'bg:#68a',
51 512: 'bg:#6a8',
52 1024: 'bg:#6d6',
53 2048: 'bg:#0f8',
54 4096: 'bg:#0ff',
55 }
56 self.board: List[List[int]]
57 self.last_board: List[Twenty48Cell]
58 self.move_count: int
59 self.width: int = 4
60 self.height: int = 4
61 self.max_value: int = 0
62 self.start_time: float
63 self.reset_game()
64
65 def reset_game(self) -> None:
66 self.start_time = time.time()
67 self.max_value = 2
68 self.move_count = 0
69 self.board = []
70 for _i in range(self.height):
71 self.board.append([0] * self.width)
72 self.last_board = list(self.all_cells())
73 self.add_random_tiles(2)
74
75 def stats(self) -> StyleAndTextTuples:
76 """Returns stats on the game in progress."""
77 elapsed_time = int(time.time() - self.start_time)
78 minutes = int(elapsed_time / 60.0)
79 seconds = elapsed_time % 60
80 fragments: StyleAndTextTuples = []
81 fragments.append(('', '\n'))
82 fragments.append(('', f'Moves: {self.move_count}'))
83 fragments.append(('', '\n'))
84 fragments.append(('', 'Time: {:0>2}:{:0>2}'.format(minutes, seconds)))
85 fragments.append(('', '\n'))
86 fragments.append(('', f'Max: {self.max_value}'))
87 fragments.append(('', '\n\n'))
88 fragments.append(('', 'Press R to restart\n'))
89 fragments.append(('', '\n'))
90 fragments.append(('', 'Arrow keys to move'))
91 return fragments
92
93 def __pt_formatted_text__(self) -> StyleAndTextTuples:
94 """Returns the game board formatted in a grid with colors."""
95 fragments: StyleAndTextTuples = []
96
97 def print_row(row: List[int], include_number: bool = False) -> None:
98 fragments.append(('', ' '))
99 for col in row:
100 style = 'class:theme-fg-default '
101 if col > 0:
102 style = '#000 '
103 style += self.colors.get(col, '')
104 text = ' ' * 6
105 if include_number:
106 text = '{:^6}'.format(col)
107 fragments.append((style, text))
108 fragments.append(('', '\n'))
109
110 fragments.append(('', '\n'))
111 for row in self.board:
112 print_row(row)
113 print_row(row, include_number=True)
114 print_row(row)
115
116 return fragments
117
118 def __repr__(self) -> str:
119 board = ''
120 for row_cells in self.board:
121 for column in row_cells:
122 board += '{:^6}'.format(column)
123 board += '\n'
124 return board
125
126 def all_cells(self) -> Iterable[Twenty48Cell]:
127 for row, row_cells in enumerate(self.board):
128 for col, cell_value in enumerate(row_cells):
129 yield (row, col, cell_value)
130
131 def update_max_value(self) -> None:
132 for _row, _col, value in self.all_cells():
133 if value > self.max_value:
134 self.max_value = value
135
136 def empty_cells(self) -> Iterable[Twenty48Cell]:
137 for row, row_cells in enumerate(self.board):
138 for col, cell_value in enumerate(row_cells):
139 if cell_value != 0:
140 continue
141 yield (row, col, cell_value)
142
143 def _board_changed(self) -> bool:
144 return self.last_board != list(self.all_cells())
145
146 def complete_move(self) -> None:
147 if not self._board_changed():
148 # Move did nothing, ignore.
149 return
150
151 self.update_max_value()
152 self.move_count += 1
153 self.add_random_tiles()
154 self.last_board = list(self.all_cells())
155
156 def add_random_tiles(self, count: int = 1) -> None:
157 for _i in range(count):
158 empty_cells = list(self.empty_cells())
159 if not empty_cells:
160 return
161 row, col, _value = choice(empty_cells)
162 self.board[row][col] = 2
163
164 def row(self, row_index: int) -> Iterable[Twenty48Cell]:
165 for col, cell_value in enumerate(self.board[row_index]):
166 yield (row_index, col, cell_value)
167
168 def col(self, col_index: int) -> Iterable[Twenty48Cell]:
169 for row, row_cells in enumerate(self.board):
170 for col, cell_value in enumerate(row_cells):
171 if col == col_index:
172 yield (row, col, cell_value)
173
174 def non_zero_row_values(self, index: int) -> Tuple[List, List]:
175 non_zero_values = [
176 value for row, col, value in self.row(index) if value != 0
177 ]
178 padding = [0] * (self.width - len(non_zero_values))
179 return (non_zero_values, padding)
180
181 def move_right(self) -> None:
182 for i in range(self.height):
183 non_zero_values, padding = self.non_zero_row_values(i)
184 self.board[i] = padding + non_zero_values
185
186 def move_left(self) -> None:
187 for i in range(self.height):
188 non_zero_values, padding = self.non_zero_row_values(i)
189 self.board[i] = non_zero_values + padding
190
191 def add_horizontal(self, reverse=False) -> None:
192 for i in range(self.width):
193 this_row = list(self.row(i))
194 if reverse:
195 this_row = list(reversed(this_row))
196 for row, col, this_cell in this_row:
197 if this_cell == 0 or col >= self.width - 1:
198 continue
199 next_cell = self.board[row][col + 1]
200 if this_cell == next_cell:
201 self.board[row][col] = 0
202 self.board[row][col + 1] = this_cell * 2
203 break
204
205 def non_zero_col_values(self, index: int) -> Tuple[List, List]:
206 non_zero_values = [
207 value for row, col, value in self.col(index) if value != 0
208 ]
209 padding = [0] * (self.height - len(non_zero_values))
210 return (non_zero_values, padding)
211
212 def _set_column(self, col_index: int, values: List[int]) -> None:
213 for row, value in enumerate(values):
214 self.board[row][col_index] = value
215
216 def add_vertical(self, reverse=False) -> None:
217 for i in range(self.height):
218 this_column = list(self.col(i))
219 if reverse:
220 this_column = list(reversed(this_column))
221 for row, col, this_cell in this_column:
222 if this_cell == 0 or row >= self.height - 1:
223 continue
224 next_cell = self.board[row + 1][col]
225 if this_cell == next_cell:
226 self.board[row][col] = 0
227 self.board[row + 1][col] = this_cell * 2
228 break
229
230 def move_down(self) -> None:
231 for col_index in range(self.width):
232 non_zero_values, padding = self.non_zero_col_values(col_index)
233 self._set_column(col_index, padding + non_zero_values)
234
235 def move_up(self) -> None:
236 for col_index in range(self.width):
237 non_zero_values, padding = self.non_zero_col_values(col_index)
238 self._set_column(col_index, non_zero_values + padding)
239
240 def press_down(self) -> None:
241 self.move_down()
242 self.add_vertical(reverse=True)
243 self.move_down()
244 self.complete_move()
245
246 def press_up(self) -> None:
247 self.move_up()
248 self.add_vertical()
249 self.move_up()
250 self.complete_move()
251
252 def press_right(self) -> None:
253 self.move_right()
254 self.add_horizontal(reverse=True)
255 self.move_right()
256 self.complete_move()
257
258 def press_left(self) -> None:
259 self.move_left()
260 self.add_horizontal()
261 self.move_left()
262 self.complete_move()
263
264
265class Twenty48Control(FormattedTextControl):
266 """Example prompt_toolkit UIControl for displaying formatted text.
267
268 This is the prompt_toolkit class that is responsible for drawing the 2048,
269 handling keybindings if in focus, and mouse input.
270 """
271 def __init__(self, twenty48_pane: 'Twenty48Pane', *args, **kwargs) -> None:
272 self.twenty48_pane = twenty48_pane
273 self.game = self.twenty48_pane.game
274
275 # Set some custom key bindings to toggle the view mode and wrap lines.
276 key_bindings = KeyBindings()
277
278 @key_bindings.add('R')
279 def _restart(_event: KeyPressEvent) -> None:
280 """Restart the game."""
281 self.game.reset_game()
282
283 @key_bindings.add('q')
284 def _quit(_event: KeyPressEvent) -> None:
285 """Quit the game."""
286 self.twenty48_pane.close_dialog()
287
288 @key_bindings.add('j')
289 @key_bindings.add('down')
290 def _move_down(_event: KeyPressEvent) -> None:
291 """Move down"""
292 self.game.press_down()
293
294 @key_bindings.add('k')
295 @key_bindings.add('up')
296 def _move_up(_event: KeyPressEvent) -> None:
297 """Move up."""
298 self.game.press_up()
299
300 @key_bindings.add('h')
301 @key_bindings.add('left')
302 def _move_left(_event: KeyPressEvent) -> None:
303 """Move left."""
304 self.game.press_left()
305
306 @key_bindings.add('l')
307 @key_bindings.add('right')
308 def _move_right(_event: KeyPressEvent) -> None:
309 """Move right."""
310 self.game.press_right()
311
312 # Include the key_bindings keyword arg when passing to the parent class
313 # __init__ function.
314 kwargs['key_bindings'] = key_bindings
315 # Call the parent FormattedTextControl.__init__
316 super().__init__(*args, **kwargs)
317
318 def mouse_handler(self, mouse_event: MouseEvent):
319 """Mouse handler for this control."""
320 # If the user clicks anywhere this function is run.
321
322 # Mouse positions relative to this control. x is the column starting
323 # from the left size as zero. y is the row starting with the top as
324 # zero.
325 _click_x = mouse_event.position.x
326 _click_y = mouse_event.position.y
327
328 # Mouse click behavior usually depends on if this window pane is in
329 # focus. If not in focus, then focus on it when left clicking. If
330 # already in focus then perform the action specific to this window.
331
332 # If not in focus, change focus to this 2048 pane and do nothing else.
333 if not has_focus(self.twenty48_pane)():
334 if mouse_event.event_type == MouseEventType.MOUSE_UP:
335 get_pw_console_app().focus_on_container(self.twenty48_pane)
336 # Mouse event handled, return None.
337 return None
338
339 # If code reaches this point, this window is already in focus.
340 # if mouse_event.event_type == MouseEventType.MOUSE_UP:
341 # # Toggle the view mode.
342 # self.twenty48_pane.toggle_view_mode()
343 # # Mouse event handled, return None.
344 # return None
345
346 # Mouse event not handled, return NotImplemented.
347 return NotImplemented
348
349
350class Twenty48Pane(WindowPane, PluginMixin):
351 """Example Pigweed Console plugin to play 2048.
352
353 The Twenty48Pane is a WindowPane based plugin that displays an interactive
354 game of 2048. It inherits from both WindowPane and PluginMixin. It can be
355 added on console startup by calling: ::
356
357 my_console.add_window_plugin(Twenty48Pane())
358
359 For an example see:
360 https://pigweed.dev/pw_console/embedding.html#adding-plugins
361 """
362 def __init__(self,
363 application: Any,
364 include_resize_handle: bool = True,
365 **kwargs):
366
367 super().__init__(pane_title='2048',
368 height=Dimension(preferred=15),
369 width=Dimension(preferred=48),
370 **kwargs)
371 # Reference to the parent pw_console app.
372 self.application = application
373 self.game = Twenty48Game()
374
375 # Tracks the last focused container, to enable restoring focus after
376 # closing the dialog.
377 self.last_focused_pane = None
378
379 # Create a toolbar for display at the bottom of the 2048 window. It
380 # will show the window title and buttons.
381 self.bottom_toolbar = WindowPaneToolbar(
382 self, include_resize_handle=include_resize_handle)
383
384 # Add a button to restart the game.
385 self.bottom_toolbar.add_button(
386 ToolbarButton(
387 key='R', # Key binding help text for this function
388 description='Restart', # Button name
389 # Function to run when clicked.
390 mouse_handler=self.game.reset_game,
391 ))
392 # Add a button to restart the game.
393 self.bottom_toolbar.add_button(
394 ToolbarButton(
395 key='q', # Key binding help text for this function
396 description='Quit', # Button name
397 # Function to run when clicked.
398 mouse_handler=self.close_dialog,
399 ))
400
401 # Every FormattedTextControl object (Twenty48Control) needs to live
402 # inside a prompt_toolkit Window() instance. Here is where you specify
403 # alignment, style, and dimensions. See the prompt_toolkit docs for all
404 # opitons:
405 # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window
406 self.twenty48_game_window = Window(
407 # Set the content to a Twenty48Control instance.
408 content=Twenty48Control(
409 self, # This Twenty48Pane class
410 self.game, # Content from Twenty48Game.__pt_formatted_text__()
411 show_cursor=False,
412 focusable=True,
413 ),
414 # Make content left aligned
415 align=WindowAlign.LEFT,
416 # These two set to false make this window fill all available space.
417 dont_extend_width=True,
418 dont_extend_height=False,
419 wrap_lines=False,
420 width=Dimension(preferred=28),
421 height=Dimension(preferred=15),
422 )
423
424 self.twenty48_stats_window = Window(
425 content=Twenty48Control(
426 self, # This Twenty48Pane class
427 self.game.stats, # Content from Twenty48Game.stats()
428 show_cursor=False,
429 focusable=True,
430 ),
431 # Make content left aligned
432 align=WindowAlign.LEFT,
433 # These two set to false make this window fill all available space.
434 width=Dimension(preferred=20),
435 dont_extend_width=False,
436 dont_extend_height=False,
437 wrap_lines=False,
438 )
439
440 # self.container is the root container that contains objects to be
441 # rendered in the UI, one on top of the other.
442 self.container = self._create_pane_container(
443 # Vertical split content
444 VSplit([
445 # Left side will show the game board.
446 self.twenty48_game_window,
447 # Stats will be shown on the right.
448 self.twenty48_stats_window,
449 ]),
450 # The bottom_toolbar is shown below the VSplit.
451 self.bottom_toolbar,
452 )
453
454 # This plugin needs to run a task in the background periodically and
455 # uses self.plugin_init() to set which function to run, and how often.
456 # This is provided by PluginMixin. See the docs for more info:
457 # https://pigweed.dev/pw_console/plugins.html#background-tasks
458 self.plugin_init(
459 plugin_callback=self._background_task,
460 # Run self._background_task once per second.
461 plugin_callback_frequency=1.0,
462 plugin_logger_name='pw_console_example_2048_plugin',
463 )
464
465 def focus_self(self) -> None:
466 self.application.focus_on_container(self)
467
468 def close_dialog(self) -> None:
469 """Close runner dialog box."""
470 self.show_pane = False
471
472 # Restore original focus if possible.
473 if self.last_focused_pane:
474 self.application.focus_on_container(self.last_focused_pane)
475 else:
476 # Fallback to focusing on the main menu.
477 self.application.focus_main_menu()
478
479 def open_dialog(self) -> None:
480 self.show_pane = True
481 self.last_focused_pane = self.application.focused_window()
482 self.focus_self()
483 self.application.redraw_ui()
484
485 def _background_task(self) -> bool:
486 """Function run in the background for the ClockPane plugin."""
487 # Optional: make a log message for debugging purposes. For more info
488 # see:
489 # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior
490 # self.plugin_logger.debug('background_task_update_count: %s',
491 # self.background_task_update_count)
492
493 # Returning True in the background task will force the user interface to
494 # re-draw.
495 # Returning False means no updates required.
496
497 if self.show_pane:
498 # Return true so the game clock is updated.
499 return True
500
501 # Game window is hidden, don't redraw.
502 return False