xref: /aosp_15_r20/external/pigweed/pw_console/py/pw_console/plugins/twenty48_pane.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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
564