diff options
Diffstat (limited to 'pw_console/py')
70 files changed, 6095 insertions, 2803 deletions
diff --git a/pw_console/py/BUILD.bazel b/pw_console/py/BUILD.bazel new file mode 100644 index 000000000..a10a1c437 --- /dev/null +++ b/pw_console/py/BUILD.bazel @@ -0,0 +1,236 @@ +# Copyright 2022 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") + +package(default_visibility = ["//visibility:public"]) + +py_library( + name = "event_count_history", + srcs = ["pw_console/widgets/event_count_history.py"], +) + +py_library( + name = "pyserial_wrapper", + srcs = ["pw_console/pyserial_wrapper.py"], + deps = [ + ":event_count_history", + ], +) + +py_library( + name = "pw_console", + srcs = [ + "pw_console/__init__.py", + "pw_console/__main__.py", + "pw_console/command_runner.py", + "pw_console/console_app.py", + "pw_console/console_log_server.py", + "pw_console/console_prefs.py", + "pw_console/docs/__init__.py", + "pw_console/embed.py", + "pw_console/filter_toolbar.py", + "pw_console/get_pw_console_app.py", + "pw_console/help_window.py", + "pw_console/html/__init__.py", + "pw_console/key_bindings.py", + "pw_console/log_filter.py", + "pw_console/log_line.py", + "pw_console/log_pane.py", + "pw_console/log_pane_saveas_dialog.py", + "pw_console/log_pane_selection_dialog.py", + "pw_console/log_pane_toolbars.py", + "pw_console/log_screen.py", + "pw_console/log_store.py", + "pw_console/log_view.py", + "pw_console/mouse.py", + "pw_console/pigweed_code_style.py", + "pw_console/plugin_mixin.py", + "pw_console/plugins/__init__.py", + "pw_console/plugins/bandwidth_toolbar.py", + "pw_console/plugins/calc_pane.py", + "pw_console/plugins/clock_pane.py", + "pw_console/plugins/twenty48_pane.py", + "pw_console/progress_bar/__init__.py", + "pw_console/progress_bar/progress_bar_impl.py", + "pw_console/progress_bar/progress_bar_state.py", + "pw_console/progress_bar/progress_bar_task_counter.py", + "pw_console/pw_ptpython_repl.py", + "pw_console/python_logging.py", + "pw_console/quit_dialog.py", + "pw_console/repl_pane.py", + "pw_console/search_toolbar.py", + "pw_console/style.py", + "pw_console/templates/__init__.py", + "pw_console/test_mode.py", + "pw_console/text_formatting.py", + "pw_console/widgets/__init__.py", + "pw_console/widgets/border.py", + "pw_console/widgets/checkbox.py", + "pw_console/widgets/mouse_handlers.py", + "pw_console/widgets/table.py", + "pw_console/widgets/window_pane.py", + "pw_console/widgets/window_pane_toolbar.py", + "pw_console/window_list.py", + "pw_console/window_manager.py", + ], + data = [ + "pw_console/docs/user_guide.rst", + "pw_console/html/index.html", + "pw_console/html/main.js", + "pw_console/html/style.css", + "pw_console/py.typed", + "pw_console/templates/keybind_list.jinja", + "pw_console/templates/repl_output.jinja", + ], + imports = ["."], + deps = [ + ":event_count_history", + ":pyserial_wrapper", + "//pw_cli/py:pw_cli", + "//pw_log_tokenized/py:pw_log_tokenized", + ], +) + +py_binary( + name = "pw_console_test_mode", + srcs = [ + "pw_console/__main__.py", + ], + main = "pw_console/__main__.py", + deps = [ + ":pw_console", + ], +) + +py_test( + name = "command_runner_test", + size = "small", + srcs = [ + "command_runner_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "console_app_test", + size = "small", + srcs = [ + "console_app_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "console_prefs_test", + size = "small", + srcs = [ + "console_prefs_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "help_window_test", + size = "small", + srcs = [ + "help_window_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "log_filter_test", + size = "small", + srcs = [ + "log_filter_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "log_store_test", + size = "small", + srcs = [ + "log_store_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "log_view_test", + size = "small", + srcs = [ + "log_view_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "repl_pane_test", + size = "small", + srcs = [ + "repl_pane_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "table_test", + size = "small", + srcs = [ + "table_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "text_formatting_test", + size = "small", + srcs = [ + "text_formatting_test.py", + ], + deps = [ + ":pw_console", + ], +) + +py_test( + name = "window_manager_test", + size = "small", + srcs = [ + "window_manager_test.py", + ], + deps = [ + ":pw_console", + ], +) diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn index b7164c95b..9e053e2e7 100644 --- a/pw_console/py/BUILD.gn +++ b/pw_console/py/BUILD.gn @@ -27,11 +27,14 @@ pw_python_package("py") { "pw_console/__main__.py", "pw_console/command_runner.py", "pw_console/console_app.py", + "pw_console/console_log_server.py", "pw_console/console_prefs.py", + "pw_console/docs/__init__.py", "pw_console/embed.py", "pw_console/filter_toolbar.py", "pw_console/get_pw_console_app.py", "pw_console/help_window.py", + "pw_console/html/__init__.py", "pw_console/key_bindings.py", "pw_console/log_filter.py", "pw_console/log_line.py", @@ -49,6 +52,7 @@ pw_python_package("py") { "pw_console/plugins/bandwidth_toolbar.py", "pw_console/plugins/calc_pane.py", "pw_console/plugins/clock_pane.py", + "pw_console/plugins/twenty48_pane.py", "pw_console/progress_bar/__init__.py", "pw_console/progress_bar/progress_bar_impl.py", "pw_console/progress_bar/progress_bar_state.py", @@ -60,6 +64,8 @@ pw_python_package("py") { "pw_console/repl_pane.py", "pw_console/search_toolbar.py", "pw_console/style.py", + "pw_console/templates/__init__.py", + "pw_console/test_mode.py", "pw_console/text_formatting.py", "pw_console/widgets/__init__.py", "pw_console/widgets/border.py", @@ -71,7 +77,6 @@ pw_python_package("py") { "pw_console/widgets/window_pane_toolbar.py", "pw_console/window_list.py", "pw_console/window_manager.py", - "pw_console/yaml_config_loader_mixin.py", ] tests = [ "command_runner_test.py", @@ -89,12 +94,16 @@ pw_python_package("py") { python_deps = [ "$dir_pw_cli/py", "$dir_pw_log_tokenized/py", - "$dir_pw_tokenizer/py", ] inputs = [ + "pw_console/docs/user_guide.rst", "pw_console/templates/keybind_list.jinja", "pw_console/templates/repl_output.jinja", + "pw_console/html/index.html", + "pw_console/html/main.js", + "pw_console/html/style.css", ] pylintrc = "$dir_pigweed/.pylintrc" + mypy_ini = "$dir_pigweed/.mypy.ini" } diff --git a/pw_console/py/command_runner_test.py b/pw_console/py/command_runner_test.py index 45362fe90..822b46488 100644 --- a/pw_console/py/command_runner_test.py +++ b/pw_console/py/command_runner_test.py @@ -22,6 +22,7 @@ from unittest.mock import MagicMock from prompt_toolkit.application import create_app_session from prompt_toolkit.output import ColorDepth + # inclusive-language: ignore from prompt_toolkit.output import DummyOutput as FakeOutput @@ -31,14 +32,15 @@ from pw_console.text_formatting import ( flatten_formatted_text_tuples, join_adjacent_style_tuples, ) -from window_manager_test import target_list_and_pane, window_pane_titles def _create_console_app(log_pane_count=2): - console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, - prefs=ConsolePrefs(project_file=False, - project_user_file=False, - user_file=False)) + prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) + prefs.set_code_theme('default') + console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, prefs=prefs) + console_app.prefs.reset_config() # Setup log panes @@ -53,8 +55,33 @@ def _create_console_app(log_pane_count=2): return console_app +def window_pane_titles(window_manager): + return [ + [ + pane.pane_title() + ' - ' + pane.pane_subtitle() + for pane in window_list.active_panes + ] + for window_list in window_manager.window_lists + ] + + +def target_list_and_pane(window_manager, list_index, pane_index): + # pylint: disable=protected-access + # Bypass prompt_toolkit has_focus() + pane = window_manager.window_lists[list_index].active_panes[pane_index] + # If the pane is in focus it will be visible. + pane.show_pane = True + window_manager._get_active_window_list_and_pane = MagicMock( # type: ignore + return_value=( + window_manager.window_lists[list_index], + window_manager.window_lists[list_index].active_panes[pane_index], + ) + ) + + class TestCommandRunner(unittest.TestCase): """Tests for CommandRunner.""" + def setUp(self): self.maxDiff = None # pylint: disable=invalid-name @@ -62,21 +89,26 @@ class TestCommandRunner(unittest.TestCase): with create_app_session(output=FakeOutput()): console_app = _create_console_app(log_pane_count=2) flattened_menu_items = [ - text for text, handler in - console_app.command_runner.load_menu_items() + text + # pylint: disable=line-too-long + for text, handler in console_app.command_runner.load_menu_items() + # pylint: enable=line-too-long ] # Check some common menu items exist. self.assertIn('[File] > Open Logger', flattened_menu_items) - self.assertIn('[File] > Themes > UI Themes > High Contrast', - flattened_menu_items) + self.assertIn( + '[File] > Themes > UI Themes > High Contrast', + flattened_menu_items, + ) self.assertIn('[Help] > User Guide', flattened_menu_items) self.assertIn('[Help] > Keyboard Shortcuts', flattened_menu_items) # Check for log windows self.assertRegex( '\n'.join(flattened_menu_items), - re.compile(r'^\[Windows\] > .* LogPane-[0-9]+ > .*$', - re.MULTILINE), + re.compile( + r'^\[Windows\] > .* LogPane-[0-9]+ > .*$', re.MULTILINE + ), ) def test_filter_and_highlight_matches(self) -> None: @@ -86,7 +118,8 @@ class TestCommandRunner(unittest.TestCase): command_runner = console_app.command_runner command_runner.filter_completions = MagicMock( - wraps=command_runner.filter_completions) + wraps=command_runner.filter_completions + ) command_runner.width = 20 # Define custom completion items @@ -102,8 +135,10 @@ class TestCommandRunner(unittest.TestCase): ] command_runner.filter_completions.assert_not_called() - command_runner.set_completions(window_title='Test Completions', - load_completions=get_completions) + command_runner.set_completions( + window_title='Test Completions', + load_completions=get_completions, + ) command_runner.filter_completions.assert_called_once() command_runner.filter_completions.reset_mock() @@ -112,7 +147,9 @@ class TestCommandRunner(unittest.TestCase): # Flatten resulting formatted text result_items = join_adjacent_style_tuples( flatten_formatted_text_tuples( - command_runner.completion_fragments)) + command_runner.completion_fragments + ) + ) # index 0: the selected line # index 1: the rest of the completions with line breaks @@ -124,30 +161,40 @@ class TestCommandRunner(unittest.TestCase): self.assertEqual(len(first_item_text.splitlines()), 1) self.assertEqual(len(second_item_text.splitlines()), 3) # First line is highlighted as a selected item - self.assertEqual(first_item_style, - 'class:command-runner-selected-item') + self.assertEqual( + first_item_style, 'class:command-runner-selected-item' + ) self.assertIn('[File] > Open Logger', first_item_text) # Type: file open command_runner.input_field.buffer.text = 'file open' - self.assertEqual(command_runner.input_field.buffer.text, - 'file open') + self.assertEqual( + command_runner.input_field.buffer.text, 'file open' + ) # Run the filter command_runner.filter_completions() # Flatten resulting formatted text result_items = join_adjacent_style_tuples( flatten_formatted_text_tuples( - command_runner.completion_fragments)) + command_runner.completion_fragments + ) + ) # Check file and open are highlighted self.assertEqual( result_items[:4], [ ('class:command-runner-selected-item', '['), - ('class:command-runner-selected-item ' - 'class:command-runner-fuzzy-highlight-0 ', 'File'), + ( + 'class:command-runner-selected-item ' + 'class:command-runner-fuzzy-highlight-0 ', + 'File', + ), ('class:command-runner-selected-item', '] > '), - ('class:command-runner-selected-item ' - 'class:command-runner-fuzzy-highlight-1 ', 'Open'), + ( + 'class:command-runner-selected-item ' + 'class:command-runner-fuzzy-highlight-1 ', + 'Open', + ), ], ) @@ -157,18 +204,26 @@ class TestCommandRunner(unittest.TestCase): command_runner.filter_completions() result_items = join_adjacent_style_tuples( flatten_formatted_text_tuples( - command_runner.completion_fragments)) + command_runner.completion_fragments + ) + ) # Check file and open are highlighted, the fuzzy-highlight class # should be swapped. self.assertEqual( result_items[:4], [ ('class:command-runner-selected-item', '['), - ('class:command-runner-selected-item ' - 'class:command-runner-fuzzy-highlight-1 ', 'File'), + ( + 'class:command-runner-selected-item ' + 'class:command-runner-fuzzy-highlight-1 ', + 'File', + ), ('class:command-runner-selected-item', '] > '), - ('class:command-runner-selected-item ' - 'class:command-runner-fuzzy-highlight-0 ', 'Open'), + ( + 'class:command-runner-selected-item ' + 'class:command-runner-fuzzy-highlight-0 ', + 'Open', + ), ], ) @@ -177,7 +232,9 @@ class TestCommandRunner(unittest.TestCase): command_runner.filter_completions() result_items = join_adjacent_style_tuples( flatten_formatted_text_tuples( - command_runner.completion_fragments)) + command_runner.completion_fragments + ) + ) self.assertEqual(len(first_item_text.splitlines()), 1) self.assertEqual(len(second_item_text.splitlines()), 3) @@ -187,18 +244,29 @@ class TestCommandRunner(unittest.TestCase): command_runner.filter_completions() result_items = join_adjacent_style_tuples( flatten_formatted_text_tuples( - command_runner.completion_fragments)) + command_runner.completion_fragments + ) + ) self.assertEqual(len(result_items), 3) # First line - not selected self.assertEqual(result_items[0], ('', '[File] > Open Logger\n')) # Second line - is selected - self.assertEqual(result_items[1], - ('class:command-runner-selected-item', - '[Windows] > 1: Host Logs > Show/Hide\n')) + self.assertEqual( + result_items[1], + ( + 'class:command-runner-selected-item', + '[Windows] > 1: Host Logs > Show/Hide\n', + ), + ) # Third and fourth lines separated by \n - not selected - self.assertEqual(result_items[2], - ('', '[Windows] > 2: Device Logs > Show/Hide\n' - '[Help] > User Guide')) + self.assertEqual( + result_items[2], + ( + '', + '[Windows] > 2: Device Logs > Show/Hide\n' + '[Help] > User Guide', + ), + ) def test_run_action(self) -> None: """Check running an action works correctly.""" @@ -224,15 +292,17 @@ class TestCommandRunner(unittest.TestCase): # pylint: disable=protected-access command_runner._make_regexes = MagicMock( - wraps=command_runner._make_regexes) + wraps=command_runner._make_regexes + ) # pylint: enable=protected-access command_runner.filter_completions() # Filter should only be re-run if input text changed command_runner.filter_completions() command_runner._make_regexes.assert_called_once() # pylint: disable=protected-access - self.assertIn('[View] > Move Window Right', - command_runner.selected_item_text) + self.assertIn( + '[View] > Move Window Right', command_runner.selected_item_text + ) # Run the Move Window Right action command_runner._run_selected_item() # pylint: disable=protected-access # Dialog should be closed diff --git a/pw_console/py/console_app_test.py b/pw_console/py/console_app_test.py index 4dec65f79..8d0512264 100644 --- a/pw_console/py/console_app_test.py +++ b/pw_console/py/console_app_test.py @@ -18,6 +18,7 @@ import unittest from prompt_toolkit.application import create_app_session from prompt_toolkit.output import ColorDepth + # inclusive-language: ignore from prompt_toolkit.output import DummyOutput as FakeOutput @@ -27,25 +28,31 @@ from pw_console.console_prefs import ConsolePrefs class TestConsoleApp(unittest.TestCase): """Tests for ConsoleApp.""" + def test_instantiate(self) -> None: """Test init.""" with create_app_session(output=FakeOutput()): - console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, - prefs=ConsolePrefs( - project_file=False, - project_user_file=False, - user_file=False)) + prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) + prefs.set_code_theme('default') + console_app = ConsoleApp( + color_depth=ColorDepth.DEPTH_8_BIT, prefs=prefs + ) + self.assertIsNotNone(console_app) def test_multiple_loggers_in_one_pane(self) -> None: """Test window resizing.""" # pylint: disable=protected-access with create_app_session(output=FakeOutput()): - console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, - prefs=ConsolePrefs( - project_file=False, - project_user_file=False, - user_file=False)) + prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) + prefs.set_code_theme('default') + console_app = ConsoleApp( + color_depth=ColorDepth.DEPTH_8_BIT, prefs=prefs + ) loggers = { 'Logs': [ @@ -62,10 +69,14 @@ class TestConsoleApp(unittest.TestCase): self.assertEqual(len(window_list.active_panes), 2) self.assertEqual(window_list.active_panes[0].pane_title(), 'Logs') - self.assertEqual(window_list.active_panes[0]._pane_subtitle, - 'test_log1, test_log2, test_log3') - self.assertEqual(window_list.active_panes[0].pane_subtitle(), - 'test_log1 + 3 more') + self.assertEqual( + window_list.active_panes[0]._pane_subtitle, + 'test_log1, test_log2, test_log3', + ) + self.assertEqual( + window_list.active_panes[0].pane_subtitle(), + 'test_log1 + 3 more', + ) if __name__ == '__main__': diff --git a/pw_console/py/console_prefs_test.py b/pw_console/py/console_prefs_test.py index 958c00f2a..ece3da266 100644 --- a/pw_console/py/console_prefs_test.py +++ b/pw_console/py/console_prefs_test.py @@ -13,7 +13,6 @@ # the License. """Tests for pw_console.console_app""" -from datetime import datetime from pathlib import Path import tempfile import unittest @@ -28,38 +27,36 @@ from pw_console.console_prefs import ( def _create_tempfile(content: str) -> Path: - # Grab the current system timestamp as a string. - isotime = datetime.now().isoformat(sep='_', timespec='seconds') - isotime = isotime.replace(':', '') - - with tempfile.NamedTemporaryFile(prefix=f'{__package__}_{isotime}_', - delete=False) as output_file: - file_path = Path(output_file.name) + with tempfile.NamedTemporaryFile( + prefix=f'{__package__}', delete=False + ) as output_file: output_file.write(content.encode('UTF-8')) - return file_path + return Path(output_file.name) class TestConsolePrefs(unittest.TestCase): """Tests for ConsolePrefs.""" + def setUp(self): self.maxDiff = None # pylint: disable=invalid-name def test_load_no_existing_files(self) -> None: - prefs = ConsolePrefs(project_file=False, - project_user_file=False, - user_file=False) + prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) self.assertEqual(_DEFAULT_CONFIG, prefs._config) self.assertTrue(str(prefs.repl_history).endswith('pw_console_history')) - self.assertTrue( - str(prefs.search_history).endswith('pw_console_search')) + self.assertTrue(str(prefs.search_history).endswith('pw_console_search')) def test_load_empty_file(self) -> None: # Create an empty file project_config_file = _create_tempfile('') try: - prefs = ConsolePrefs(project_file=project_config_file, - project_user_file=False, - user_file=False) + prefs = ConsolePrefs( + project_file=project_config_file, + project_user_file=False, + user_file=False, + ) result_settings = { k: v for k, v in prefs._config.items() @@ -86,9 +83,11 @@ class TestConsolePrefs(unittest.TestCase): } project_config_file = _create_tempfile(yaml.dump(project_config)) try: - prefs = ConsolePrefs(project_file=project_config_file, - project_user_file=False, - user_file=False) + prefs = ConsolePrefs( + project_file=project_config_file, + project_user_file=False, + user_file=False, + ) result_settings = { k: v for k, v in prefs._config.items() @@ -125,7 +124,8 @@ class TestConsolePrefs(unittest.TestCase): }, } project_user_config_file = _create_tempfile( - yaml.dump(project_user_config)) + yaml.dump(project_user_config) + ) user_config = { 'pw_console': { @@ -135,32 +135,39 @@ class TestConsolePrefs(unittest.TestCase): } user_config_file = _create_tempfile(yaml.dump(user_config)) try: - prefs = ConsolePrefs(project_file=project_config_file, - project_user_file=project_user_config_file, - user_file=user_config_file) + prefs = ConsolePrefs( + project_file=project_config_file, + project_user_file=project_user_config_file, + user_file=user_config_file, + ) # Set by the project - self.assertEqual(project_config['pw_console']['code_theme'], - prefs.code_theme) + self.assertEqual( + project_config['pw_console']['code_theme'], prefs.code_theme + ) self.assertEqual( project_config['pw_console']['swap_light_and_dark'], - prefs.swap_light_and_dark) + prefs.swap_light_and_dark, + ) # Project user setting, result should not be project only setting. project_history = project_config['pw_console']['repl_history'] assert isinstance(project_history, str) self.assertNotEqual( - Path(project_history).expanduser(), prefs.repl_history) + Path(project_history).expanduser(), prefs.repl_history + ) history = project_user_config['pw_console']['repl_history'] assert isinstance(history, str) self.assertEqual(Path(history).expanduser(), prefs.repl_history) # User config overrides project and project_user - self.assertEqual(user_config['pw_console']['ui_theme'], - prefs.ui_theme) + self.assertEqual( + user_config['pw_console']['ui_theme'], prefs.ui_theme + ) self.assertEqual( Path(user_config['pw_console']['search_history']).expanduser(), - prefs.search_history) + prefs.search_history, + ) # ui_theme should not be the project_user file setting project_user_theme = project_user_config['pw_console']['ui_theme'] self.assertNotEqual(project_user_theme, prefs.ui_theme) diff --git a/pw_console/py/help_window_test.py b/pw_console/py/help_window_test.py index 2f25cac31..1a1f11072 100644 --- a/pw_console/py/help_window_test.py +++ b/pw_console/py/help_window_test.py @@ -23,8 +23,11 @@ from prompt_toolkit.key_binding import KeyBindings from pw_console.help_window import HelpWindow +_PW_CONSOLE_MODULE = 'pw_console' + + _jinja_env = Environment( - loader=PackageLoader('pw_console'), + loader=PackageLoader(_PW_CONSOLE_MODULE), undefined=make_logging_undefined(logger=logging.getLogger('pw_console')), trim_blocks=True, lstrip_blocks=True, @@ -40,6 +43,7 @@ def _create_app_mock(): class TestHelpWindow(unittest.TestCase): """Tests for HelpWindow text and keybind lists.""" + def setUp(self): self.maxDiff = None # pylint: disable=invalid-name @@ -76,7 +80,7 @@ class TestHelpWindow(unittest.TestCase): }, ) - def test_generate_help_text(self) -> None: + def test_generate_keybind_help_text(self) -> None: """Test keybind list template generation.""" global_bindings = KeyBindings() @@ -107,50 +111,62 @@ class TestHelpWindow(unittest.TestCase): help_window = HelpWindow( app, preamble='Pigweed CLI v0.1', - additional_help_text=inspect.cleandoc(""" + additional_help_text=inspect.cleandoc( + """ Welcome to the Pigweed Console! Please enjoy this extra help text. - """), + """ + ), ) help_window.add_keybind_help_text('Global', global_bindings) help_window.add_keybind_help_text('Focus', focus_bindings) - help_window.generate_help_text() + help_window.generate_keybind_help_text() self.assertIn( - inspect.cleandoc(""" + inspect.cleandoc( + """ Welcome to the Pigweed Console! Please enjoy this extra help text. - """), + """ + ), help_window.help_text, ) self.assertIn( - inspect.cleandoc(""" + inspect.cleandoc( + """ ==== Global Keys ==== - """), + """ + ), help_window.help_text, ) self.assertIn( - inspect.cleandoc(""" + inspect.cleandoc( + """ Toggle help window. ----------------- F1 Quit the application. --------------- Ctrl-Q Ctrl-W - """), + """ + ), help_window.help_text, ) self.assertIn( - inspect.cleandoc(""" + inspect.cleandoc( + """ ==== Focus Keys ==== - """), + """ + ), help_window.help_text, ) self.assertIn( - inspect.cleandoc(""" + inspect.cleandoc( + """ Move focus to the next widget. ------ Ctrl-Down Ctrl-Right Shift-Tab Move focus to the previous widget. -- Ctrl-Left Ctrl-Up - """), + """ + ), help_window.help_text, ) diff --git a/pw_console/py/log_filter_test.py b/pw_console/py/log_filter_test.py index d885560c0..7309c7abd 100644 --- a/pw_console/py/log_filter_test.py +++ b/pw_console/py/log_filter_test.py @@ -32,55 +32,58 @@ from pw_console.log_filter import ( class TestLogFilter(unittest.TestCase): """Tests for LogFilter.""" + def setUp(self): self.maxDiff = None # pylint: disable=invalid-name # pylint: disable=anomalous-backslash-in-string - @parameterized.expand([ - ( - 'raw string', - SearchMatcher.STRING, - 'f(x)', - 'f\(x\)', - re.IGNORECASE, - ), - ( - 'simple regex', - SearchMatcher.REGEX, - 'f(x)', - 'f(x)', - re.IGNORECASE, - ), - ( - 'regex with case sensitivity', - SearchMatcher.REGEX, - 'f(X)', - 'f(X)', - re.RegexFlag(0), - ), - ( - 'regex with error', - SearchMatcher.REGEX, - 'f of (x', # Un-terminated open paren - 'f of (x', - re.IGNORECASE, - True, # fails_validation - ), - ( - 'simple fuzzy', - SearchMatcher.FUZZY, - 'f x y', - '(f)(.*?)(x)(.*?)(y)', - re.IGNORECASE, - ), - ( - 'fuzzy with case sensitivity', - SearchMatcher.FUZZY, - 'f X y', - '(f)(.*?)(X)(.*?)(y)', - re.RegexFlag(0), - ), - ]) # yapf: disable + @parameterized.expand( + [ + ( + 'raw string', + SearchMatcher.STRING, + 'f(x)', + 'f\(x\)', + re.IGNORECASE, + ), + ( + 'simple regex', + SearchMatcher.REGEX, + 'f(x)', + 'f(x)', + re.IGNORECASE, + ), + ( + 'regex with case sensitivity', + SearchMatcher.REGEX, + 'f(X)', + 'f(X)', + re.RegexFlag(0), + ), + ( + 'regex with error', + SearchMatcher.REGEX, + 'f of (x', # Un-terminated open paren + 'f of (x', + re.IGNORECASE, + True, # fails_validation + ), + ( + 'simple fuzzy', + SearchMatcher.FUZZY, + 'f x y', + '(f)(.*?)(x)(.*?)(y)', + re.IGNORECASE, + ), + ( + 'fuzzy with case sensitivity', + SearchMatcher.FUZZY, + 'f X y', + '(f)(.*?)(X)(.*?)(y)', + re.RegexFlag(0), + ), + ] + ) def test_preprocess_search_regex( self, _name, @@ -91,15 +94,17 @@ class TestLogFilter(unittest.TestCase): should_fail_validation=False, ) -> None: """Test preprocess_search_regex returns the expected regex settings.""" - result_text, re_flag = preprocess_search_regex(input_text, - input_matcher) + result_text, re_flag = preprocess_search_regex( + input_text, input_matcher + ) self.assertEqual(expected_regex, result_text) self.assertEqual(expected_re_flag, re_flag) if should_fail_validation: document = Document(text=input_text) - with self.assertRaisesRegex(ValidationError, - r'Regex Error.*at position [0-9]+'): + with self.assertRaisesRegex( + ValidationError, r'Regex Error.*at position [0-9]+' + ): RegexValidator().validate(document) def _create_logs(self, log_messages): @@ -110,76 +115,90 @@ class TestLogFilter(unittest.TestCase): return log_context - @parameterized.expand([ - ( - 'simple fuzzy', - SearchMatcher.FUZZY, - 'log item', - [ - ('Log some item', {'planet': 'Jupiter'}), - ('Log another item', {'planet': 'Earth'}), - ('Some exception', {'planet': 'Earth'}), - ], - [ - 'Log some item', - 'Log another item', - ], - None, # field - False, # invert - ), - ( - 'simple fuzzy inverted', - SearchMatcher.FUZZY, - 'log item', - [ - ('Log some item', dict()), - ('Log another item', dict()), - ('Some exception', dict()), - ], - [ - 'Some exception', - ], - None, # field - True, # invert - ), - ( - 'regex with field', - SearchMatcher.REGEX, - 'earth', - [ - ('Log some item', - dict(extra_metadata_fields={'planet': 'Jupiter'})), - ('Log another item', - dict(extra_metadata_fields={'planet': 'Earth'})), - ('Some exception', - dict(extra_metadata_fields={'planet': 'Earth'})), - ], - [ - 'Log another item', - 'Some exception', - ], - 'planet', # field - False, # invert - ), - ( - 'regex with field inverted', - SearchMatcher.REGEX, - 'earth', - [ - ('Log some item', - dict(extra_metadata_fields={'planet': 'Jupiter'})), - ('Log another item', - dict(extra_metadata_fields={'planet': 'Earth'})), - ('Some exception', - dict(extra_metadata_fields={'planet': 'Earth'})), - ], - [ - 'Log some item', - ], - 'planet', # field - True, # invert - ), - ]) # yapf: disable + @parameterized.expand( + [ + ( + 'simple fuzzy', + SearchMatcher.FUZZY, + 'log item', + [ + ('Log some item', {'planet': 'Jupiter'}), + ('Log another item', {'planet': 'Earth'}), + ('Some exception', {'planet': 'Earth'}), + ], + [ + 'Log some item', + 'Log another item', + ], + None, # field + False, # invert + ), + ( + 'simple fuzzy inverted', + SearchMatcher.FUZZY, + 'log item', + [ + ('Log some item', dict()), + ('Log another item', dict()), + ('Some exception', dict()), + ], + [ + 'Some exception', + ], + None, # field + True, # invert + ), + ( + 'regex with field', + SearchMatcher.REGEX, + 'earth', + [ + ( + 'Log some item', + dict(extra_metadata_fields={'planet': 'Jupiter'}), + ), + ( + 'Log another item', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ( + 'Some exception', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ], + [ + 'Log another item', + 'Some exception', + ], + 'planet', # field + False, # invert + ), + ( + 'regex with field inverted', + SearchMatcher.REGEX, + 'earth', + [ + ( + 'Log some item', + dict(extra_metadata_fields={'planet': 'Jupiter'}), + ), + ( + 'Log another item', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ( + 'Some exception', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ], + [ + 'Log some item', + ], + 'planet', # field + True, # invert + ), + ] + ) def test_log_filter_matches( self, _name, @@ -191,8 +210,9 @@ class TestLogFilter(unittest.TestCase): invert=False, ) -> None: """Test log filter matches expected lines.""" - result_text, re_flag = preprocess_search_regex(input_text, - input_matcher) + result_text, re_flag = preprocess_search_regex( + input_text, input_matcher + ) log_filter = LogFilter( regex=re.compile(result_text, re_flag), input_text=input_text, @@ -205,7 +225,8 @@ class TestLogFilter(unittest.TestCase): for record in logs.records: if log_filter.matches( - LogLine(record, record.message, record.message)): + LogLine(record, record.message, record.message) + ): matched_lines.append(record.message) self.assertEqual(expected_matched_lines, matched_lines) diff --git a/pw_console/py/log_store_test.py b/pw_console/py/log_store_test.py index 37b628733..8359cc5bf 100644 --- a/pw_console/py/log_store_test.py +++ b/pw_console/py/log_store_test.py @@ -22,8 +22,11 @@ from pw_console.console_prefs import ConsolePrefs def _create_log_store(): - log_store = LogStore(prefs=ConsolePrefs( - project_file=False, project_user_file=False, user_file=False)) + log_store = LogStore( + prefs=ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) + ) assert not log_store.table.prefs.show_python_file viewer = MagicMock() @@ -34,6 +37,7 @@ def _create_log_store(): class TestLogStore(unittest.TestCase): """Tests for LogStore.""" + def setUp(self): self.maxDiff = None # pylint: disable=invalid-name @@ -64,11 +68,13 @@ class TestLogStore(unittest.TestCase): log_store, _viewer = _create_log_store() # Log some messagse on 3 separate logger instances - for i, logger_name in enumerate([ + for i, logger_name in enumerate( + [ 'log_store.test', 'log_store.dev', 'log_store.production', - ]): + ] + ): test_log = logging.getLogger(logger_name) with self.assertLogs(test_log, level='DEBUG') as _log_context: test_log.addHandler(log_store) @@ -109,11 +115,15 @@ class TestLogStore(unittest.TestCase): # Log table with extra columns with self.assertLogs(test_log, level='DEBUG') as _log_context: test_log.addHandler(log_store) - test_log.debug('Test log %s', - extra=dict(extra_metadata_fields={ - 'planet': 'Jupiter', - 'galaxy': 'Milky Way' - })) + test_log.debug( + 'Test log %s', + extra=dict( + extra_metadata_fields={ + 'planet': 'Jupiter', + 'galaxy': 'Milky Way', + } + ), + ) self.assertEqual( [ diff --git a/pw_console/py/log_view_test.py b/pw_console/py/log_view_test.py index a21a5647d..a46409e19 100644 --- a/pw_console/py/log_view_test.py +++ b/pw_console/py/log_view_test.py @@ -43,9 +43,9 @@ def _create_log_view(): log_pane.current_log_pane_height = 10 application = MagicMock() - application.prefs = ConsolePrefs(project_file=False, - project_user_file=False, - user_file=False) + application.prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) application.prefs.reset_config() log_view = LogView(log_pane, application) return log_view, log_pane @@ -198,8 +198,8 @@ class TestLogView(unittest.TestCase): # Mock time to always return the same value. mock_time = MagicMock( # type: ignore - return_value=time.mktime( - datetime(2021, 7, 13, 0, 0, 0).timetuple())) + return_value=time.mktime(datetime(2021, 7, 13, 0, 0, 0).timetuple()) + ) with patch('time.time', new=mock_time): log_view, log_pane = self._create_log_view_with_logs(log_count=4) @@ -224,26 +224,28 @@ class TestLogView(unittest.TestCase): ('', ' '), ('class:log-level-10', 'DEBUG'), ('', ' Test log 0'), - ('class:log-time', '20210713 00:00:00'), ('', ' '), ('class:log-level-10', 'DEBUG'), ('', ' Test log 1'), - ('class:log-time', '20210713 00:00:00'), ('', ' '), ('class:log-level-10', 'DEBUG'), ('', ' Test log 2'), - ('class:selected-log-line class:log-time', '20210713 00:00:00'), ('class:selected-log-line ', ' '), ('class:selected-log-line class:log-level-10', 'DEBUG'), - ('class:selected-log-line ', - ' Test log 3 ') - ] # yapf: disable + ( + 'class:selected-log-line ', + ' Test log 3 ', + ), + ] + # pylint: disable=protected-access result_text = join_adjacent_style_tuples( - flatten_formatted_text_tuples(log_view._line_fragment_cache)) # pylint: disable=protected-access + flatten_formatted_text_tuples(log_view._line_fragment_cache) + ) + # pylint: enable=protected-access self.assertEqual(result_text, expected_formatted_text) @@ -253,7 +255,8 @@ class TestLogView(unittest.TestCase): # Create log_view with 4 logs starting_log_count = 4 log_view, _pane = self._create_log_view_with_logs( - log_count=starting_log_count) + log_count=starting_log_count + ) log_view.render_content() # Check setup is correct @@ -261,9 +264,11 @@ class TestLogView(unittest.TestCase): self.assertEqual(log_view.get_current_line(), 3) self.assertEqual(log_view.get_total_count(), 4) self.assertEqual( - list(log.record.message - for log in log_view._get_visible_log_lines()), - ['Test log 0', 'Test log 1', 'Test log 2', 'Test log 3']) + list( + log.record.message for log in log_view._get_visible_log_lines() + ), + ['Test log 0', 'Test log 1', 'Test log 2', 'Test log 3'], + ) # Clear scrollback log_view.clear_scrollback() @@ -277,8 +282,11 @@ class TestLogView(unittest.TestCase): self.assertEqual(log_view.get_total_count(), 4) # No lines returned self.assertEqual( - list(log.record.message - for log in log_view._get_visible_log_lines()), []) + list( + log.record.message for log in log_view._get_visible_log_lines() + ), + [], + ) # Add Log 4 more lines test_log = logging.getLogger('log_view.test') @@ -295,9 +303,11 @@ class TestLogView(unittest.TestCase): self.assertEqual(log_view.get_total_count(), 8) # Only the last 4 logs should appear self.assertEqual( - list(log.record.message - for log in log_view._get_visible_log_lines()), - ['Test log 4', 'Test log 5', 'Test log 6', 'Test log 7']) + list( + log.record.message for log in log_view._get_visible_log_lines() + ), + ['Test log 4', 'Test log 5', 'Test log 6', 'Test log 7'], + ) log_view.scroll_to_bottom() log_view.render_content() @@ -311,11 +321,20 @@ class TestLogView(unittest.TestCase): self.assertEqual(log_view.get_total_count(), 8) # All logs should appear self.assertEqual( - list(log.record.message - for log in log_view._get_visible_log_lines()), [ - 'Test log 0', 'Test log 1', 'Test log 2', 'Test log 3', - 'Test log 4', 'Test log 5', 'Test log 6', 'Test log 7' - ]) + list( + log.record.message for log in log_view._get_visible_log_lines() + ), + [ + 'Test log 0', + 'Test log 1', + 'Test log 2', + 'Test log 3', + 'Test log 4', + 'Test log 5', + 'Test log 6', + 'Test log 7', + ], + ) log_view.scroll_to_bottom() log_view.render_content() @@ -334,7 +353,8 @@ class TestLogView(unittest.TestCase): # Create log_view with 4 logs starting_log_count = 4 log_view, _pane = self._create_log_view_with_logs( - log_count=starting_log_count) + log_count=starting_log_count + ) log_view.render_content() # Check setup is correct @@ -342,9 +362,11 @@ class TestLogView(unittest.TestCase): self.assertEqual(log_view.get_current_line(), 3) self.assertEqual(log_view.get_total_count(), 4) self.assertEqual( - list(log.record.message - for log in log_view._get_visible_log_lines()), - ['Test log 0', 'Test log 1', 'Test log 2', 'Test log 3']) + list( + log.record.message for log in log_view._get_visible_log_lines() + ), + ['Test log 0', 'Test log 1', 'Test log 2', 'Test log 3'], + ) self.assertEqual(log_view.log_screen.cursor_position, 9) # Force the cursor_position to be larger than the log_screen @@ -376,14 +398,17 @@ class TestLogView(unittest.TestCase): log_pane.current_log_pane_height = 10 log_view.log_screen.reset_logs = MagicMock( - wraps=log_view.log_screen.reset_logs) + wraps=log_view.log_screen.reset_logs + ) log_view.log_screen.get_lines = MagicMock( - wraps=log_view.log_screen.get_lines) + wraps=log_view.log_screen.get_lines + ) log_view.render_content() log_view.log_screen.reset_logs.assert_called_once() log_view.log_screen.get_lines.assert_called_once_with( - marked_logs_start=None, marked_logs_end=None) + marked_logs_start=None, marked_logs_end=None + ) log_view.log_screen.get_lines.reset_mock() log_view.log_screen.reset_logs.reset_mock() @@ -391,12 +416,14 @@ class TestLogView(unittest.TestCase): self.assertIsNone(log_view.marked_logs_end) log_view.visual_select_line(Point(0, 9)) self.assertEqual( - (99, 99), (log_view.marked_logs_start, log_view.marked_logs_end)) + (99, 99), (log_view.marked_logs_start, log_view.marked_logs_end) + ) log_view.visual_select_line(Point(0, 8)) log_view.visual_select_line(Point(0, 7)) self.assertEqual( - (97, 99), (log_view.marked_logs_start, log_view.marked_logs_end)) + (97, 99), (log_view.marked_logs_start, log_view.marked_logs_end) + ) log_view.clear_visual_selection() self.assertIsNone(log_view.marked_logs_start) @@ -407,7 +434,8 @@ class TestLogView(unittest.TestCase): log_view.visual_select_line(Point(0, 3)) log_view.visual_select_line(Point(0, 4)) self.assertEqual( - (91, 94), (log_view.marked_logs_start, log_view.marked_logs_end)) + (91, 94), (log_view.marked_logs_start, log_view.marked_logs_end) + ) # Make sure the log screen was not re-generated. log_view.log_screen.reset_logs.assert_not_called() @@ -418,7 +446,8 @@ class TestLogView(unittest.TestCase): log_view.log_screen.reset_logs.assert_called_once() # Check the visual selection was specified log_view.log_screen.get_lines.assert_called_once_with( - marked_logs_start=91, marked_logs_end=94) + marked_logs_start=91, marked_logs_end=94 + ) log_view.log_screen.get_lines.reset_mock() log_view.log_screen.reset_logs.reset_mock() @@ -426,7 +455,9 @@ class TestLogView(unittest.TestCase): if _PYTHON_3_8: from unittest import IsolatedAsyncioTestCase # type: ignore # pylint: disable=no-name-in-module - class TestLogViewFiltering(IsolatedAsyncioTestCase): # pylint: disable=undefined-variable + class TestLogViewFiltering( + IsolatedAsyncioTestCase + ): # pylint: disable=undefined-variable """Test LogView log filtering capabilities.""" # pylint: disable=invalid-name @@ -446,90 +477,99 @@ if _PYTHON_3_8: return log_view, log_pane - @parameterized.expand([ - ( - # Test name - 'regex filter', - # Search input_text - 'log.*item', - # input_logs - [ - ('Log some item', dict()), - ('Log another item', dict()), - ('Some exception', dict()), - ], - # expected_matched_lines - [ - 'Log some item', - 'Log another item', - ], - # expected_match_line_numbers - {0: 0, 1: 1}, - # expected_export_text + @parameterized.expand( + [ ( - ' DEBUG Log some item\n' - ' DEBUG Log another item\n' + # Test name + 'regex filter', + # Search input_text + 'log.*item', + # input_logs + [ + ('Log some item', dict()), + ('Log another item', dict()), + ('Some exception', dict()), + ], + # expected_matched_lines + [ + 'Log some item', + 'Log another item', + ], + # expected_match_line_numbers + {0: 0, 1: 1}, + # expected_export_text + (' DEBUG Log some item\n DEBUG Log another item\n'), + None, # field + False, # invert ), - None, # field - False, # invert - ), - ( - # Test name - 'regex filter with field', - # Search input_text - 'earth', - # input_logs - [ - ('Log some item', - dict(extra_metadata_fields={'planet': 'Jupiter'})), - ('Log another item', - dict(extra_metadata_fields={'planet': 'Earth'})), - ('Some exception', - dict(extra_metadata_fields={'planet': 'Earth'})), - ], - # expected_matched_lines - [ - 'Log another item', - 'Some exception', - ], - # expected_match_line_numbers - {1: 0, 2: 1}, - # expected_export_text ( - ' DEBUG Earth Log another item\n' - ' DEBUG Earth Some exception\n' + # Test name + 'regex filter with field', + # Search input_text + 'earth', + # input_logs + [ + ( + 'Log some item', + dict(extra_metadata_fields={'planet': 'Jupiter'}), + ), + ( + 'Log another item', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ( + 'Some exception', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ], + # expected_matched_lines + [ + 'Log another item', + 'Some exception', + ], + # expected_match_line_numbers + {1: 0, 2: 1}, + # expected_export_text + ( + ' DEBUG Earth Log another item\n' + ' DEBUG Earth Some exception\n' + ), + 'planet', # field + False, # invert ), - 'planet', # field - False, # invert - ), - ( - # Test name - 'regex filter with field inverted', - # Search input_text - 'earth', - # input_logs - [ - ('Log some item', - dict(extra_metadata_fields={'planet': 'Jupiter'})), - ('Log another item', - dict(extra_metadata_fields={'planet': 'Earth'})), - ('Some exception', - dict(extra_metadata_fields={'planet': 'Earth'})), - ], - # expected_matched_lines - [ - 'Log some item', - ], - # expected_match_line_numbers - {0: 0}, - # expected_export_text ( - ' DEBUG Jupiter Log some item\n' + # Test name + 'regex filter with field inverted', + # Search input_text + 'earth', + # input_logs + [ + ( + 'Log some item', + dict(extra_metadata_fields={'planet': 'Jupiter'}), + ), + ( + 'Log another item', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ( + 'Some exception', + dict(extra_metadata_fields={'planet': 'Earth'}), + ), + ], + # expected_matched_lines + [ + 'Log some item', + ], + # expected_match_line_numbers + {0: 0}, + # expected_export_text + (' DEBUG Jupiter Log some item\n'), + 'planet', # field + True, # invert ), - 'planet', # field - True, # invert - ), - ]) # yapf: disable + ] + ) async def test_log_filtering( self, _test_name, @@ -549,40 +589,49 @@ if _PYTHON_3_8: # Apply the search and wait for the match count background task log_view.new_search(input_text, invert=invert, field=field) await log_view.search_match_count_task - self.assertEqual(log_view.search_matched_lines, - expected_match_line_numbers) + self.assertEqual( + log_view.search_matched_lines, expected_match_line_numbers + ) # Apply the filter and wait for the filter background task log_view.apply_filter() await log_view.filter_existing_logs_task # Do the number of logs match the expected count? - self.assertEqual(log_view.get_total_count(), - len(expected_matched_lines)) + self.assertEqual( + log_view.get_total_count(), len(expected_matched_lines) + ) self.assertEqual( [log.record.message for log in log_view.filtered_logs], - expected_matched_lines) + expected_matched_lines, + ) # Check exported text respects filtering - log_text = log_view._logs_to_text( # pylint: disable=protected-access - use_table_formatting=True) + log_text = ( + log_view._logs_to_text( # pylint: disable=protected-access + use_table_formatting=True + ) + ) # Remove leading time from resulting logs log_text_no_datetime = '' for line in log_text.splitlines(): - log_text_no_datetime += (line[17:] + '\n') + log_text_no_datetime += line[17:] + '\n' self.assertEqual(log_text_no_datetime, expected_export_text) # Select the bottom log line log_view.render_content() log_view.visual_select_line(Point(0, 9)) # Window height is 10 # Export to text - log_text = log_view._logs_to_text( # pylint: disable=protected-access - selected_lines_only=True, - use_table_formatting=False) + log_text = ( + log_view._logs_to_text( # pylint: disable=protected-access + selected_lines_only=True, use_table_formatting=False + ) + ) self.assertEqual( # Remove date, time, and level log_text[24:].strip(), - expected_matched_lines[0].strip()) + expected_matched_lines[0].strip(), + ) # Clear filters and check the numbe of lines is back to normal. log_view.clear_filters() diff --git a/pw_console/py/pw_console/__main__.py b/pw_console/py/pw_console/__main__.py index 936bd32b5..2881b0a5c 100644 --- a/pw_console/py/pw_console/__main__.py +++ b/pw_console/py/pw_console/__main__.py @@ -18,46 +18,53 @@ import inspect import logging from pathlib import Path import sys +from typing import Optional, Dict -import pw_cli.log -import pw_cli.argument_types +from pw_cli import log as pw_cli_log +from pw_cli import argument_types -import pw_console -import pw_console.python_logging +from pw_console import PwConsoleEmbed +from pw_console.python_logging import create_temp_log_file from pw_console.log_store import LogStore from pw_console.plugins.calc_pane import CalcPane from pw_console.plugins.clock_pane import ClockPane +from pw_console.plugins.twenty48_pane import Twenty48Pane +from pw_console.test_mode import FAKE_DEVICE_LOGGER_NAME _LOG = logging.getLogger(__package__) _ROOT_LOG = logging.getLogger('') -# TODO(tonymd): Remove this when no downstream projects are using it. -def create_temp_log_file(): - return pw_console.python_logging.create_temp_log_file() - - def _build_argument_parser() -> argparse.ArgumentParser: """Setup argparse.""" - parser = argparse.ArgumentParser(description=__doc__) + parser = argparse.ArgumentParser( + prog="python -m pw_console", description=__doc__ + ) - parser.add_argument('-l', - '--loglevel', - type=pw_cli.argument_types.log_level, - default=logging.DEBUG, - help='Set the log level' - '(debug, info, warning, error, critical)') + parser.add_argument( + '-l', + '--loglevel', + type=argument_types.log_level, + default=logging.DEBUG, + help='Set the log level' '(debug, info, warning, error, critical)', + ) parser.add_argument('--logfile', help='Pigweed Console log file.') - parser.add_argument('--test-mode', - action='store_true', - help='Enable fake log messages for testing purposes.') - parser.add_argument('--config-file', - type=Path, - help='Path to a pw_console yaml config file.') - parser.add_argument('--console-debug-log-file', - help='Log file to send console debug messages to.') + parser.add_argument( + '--test-mode', + action='store_true', + help='Enable fake log messages for testing purposes.', + ) + parser.add_argument( + '--config-file', + type=Path, + help='Path to a pw_console yaml config file.', + ) + parser.add_argument( + '--console-debug-log-file', + help='Log file to send console debug messages to.', + ) return parser @@ -71,19 +78,23 @@ def main() -> int: if not args.logfile: # Create a temp logfile to prevent logs from appearing over stdout. This # would corrupt the prompt toolkit UI. - args.logfile = pw_console.python_logging.create_temp_log_file() + args.logfile = create_temp_log_file() - pw_cli.log.install(level=args.loglevel, - use_color=True, - hide_timestamp=False, - log_file=args.logfile) + pw_cli_log.install( + level=args.loglevel, + use_color=True, + hide_timestamp=False, + log_file=args.logfile, + ) if args.console_debug_log_file: - pw_cli.log.install(level=logging.DEBUG, - use_color=True, - hide_timestamp=False, - log_file=args.console_debug_log_file, - logger=logging.getLogger('pw_console')) + pw_cli_log.install( + level=logging.DEBUG, + use_color=True, + hide_timestamp=False, + log_file=args.console_debug_log_file, + logger=logging.getLogger('pw_console'), + ) global_vars = None default_loggers = {} @@ -92,12 +103,11 @@ def main() -> int: _ROOT_LOG.addHandler(root_log_store) _ROOT_LOG.debug('pw_console test-mode starting...') - fake_logger = logging.getLogger( - pw_console.console_app.FAKE_DEVICE_LOGGER_NAME) + fake_logger = logging.getLogger(FAKE_DEVICE_LOGGER_NAME) default_loggers = { # Don't include pw_console package logs (_LOG) in the log pane UI. # Add the fake logger for test_mode. - 'Fake Device Logs': [fake_logger], + 'Fake Device': [fake_logger], 'PwConsole Debug': [logging.getLogger('pw_console')], 'All Logs': root_log_store, } @@ -108,7 +118,8 @@ def main() -> int: app_title = None if args.test_mode: app_title = 'Console Test Mode' - help_text = inspect.cleandoc(""" + help_text = inspect.cleandoc( + """ Welcome to the Pigweed Console Test Mode! Example commands: @@ -116,9 +127,10 @@ def main() -> int: rpcs.pw.rpc.EchoService.Echo(msg='hello!') LOG.warning('Message appears console log window.') - """) + """ + ) - console = pw_console.PwConsoleEmbed( + console = PwConsoleEmbed( global_vars=global_vars, loggers=default_loggers, test_mode=args.test_mode, @@ -127,16 +139,48 @@ def main() -> int: config_file_path=args.config_file, ) - # Add example plugins used to validate behavior in the Pigweed Console - # manual test procedure: https://pigweed.dev/pw_console/testing.html + overriden_window_config: Optional[Dict] = None + # Add example plugins and log panes used to validate behavior in the Pigweed + # Console manual test procedure: https://pigweed.dev/pw_console/testing.html if args.test_mode: + fake_logger.propagate = False + console.setup_python_logging(loggers_with_no_propagation=[fake_logger]) + _ROOT_LOG.debug('pw_console.PwConsoleEmbed init complete') _ROOT_LOG.debug('Adding plugins...') console.add_window_plugin(ClockPane()) console.add_window_plugin(CalcPane()) + console.add_floating_window_plugin( + Twenty48Pane(include_resize_handle=False), left=4 + ) _ROOT_LOG.debug('Starting prompt_toolkit full-screen application...') - console.embed() + overriden_window_config = { + 'Split 1 stacked': { + 'Fake Device': None, + 'Fake Keys': { + 'duplicate_of': 'Fake Device', + 'filters': { + 'keys': {'regex': '[^ ]+'}, + }, + }, + 'Fake USB': { + 'duplicate_of': 'Fake Device', + 'filters': { + 'module': {'regex': 'USB'}, + }, + }, + }, + 'Split 2 tabbed': { + 'Python Repl': None, + 'All Logs': None, + 'PwConsole Debug': None, + 'Calculator': None, + 'Clock': None, + }, + } + + console.embed(override_window_config=overriden_window_config) if args.logfile: print(f'Logs saved to: {args.logfile}') diff --git a/pw_console/py/pw_console/command_runner.py b/pw_console/py/pw_console/command_runner.py index 88ff984b9..e2caf1212 100644 --- a/pw_console/py/pw_console/command_runner.py +++ b/pw_console/py/pw_console/command_runner.py @@ -51,9 +51,11 @@ from prompt_toolkit.layout import ( from prompt_toolkit.widgets import MenuItem from prompt_toolkit.widgets import TextArea -import pw_console.widgets.border -import pw_console.widgets.checkbox -import pw_console.widgets.mouse_handlers +from pw_console.widgets import ( + create_border, + mouse_handlers, + to_keybind_indicator, +) if TYPE_CHECKING: from pw_console.console_app import ConsoleApp @@ -61,8 +63,9 @@ if TYPE_CHECKING: _LOG = logging.getLogger(__package__) -def flatten_menu_items(items: List[MenuItem], - prefix: str = '') -> Iterator[Tuple[str, Callable]]: +def flatten_menu_items( + items: List[MenuItem], prefix: str = '' +) -> Iterator[Tuple[str, Callable]]: """Flatten nested prompt_toolkit MenuItems into text and callable tuples.""" for item in items: new_text = [] @@ -81,23 +84,22 @@ def flatten_menu_items(items: List[MenuItem], def highlight_matches( - regexes: Iterable[re.Pattern], - line_fragments: StyleAndTextTuples) -> StyleAndTextTuples: + regexes: Iterable[re.Pattern], line_fragments: StyleAndTextTuples +) -> StyleAndTextTuples: """Highlight regex matches in prompt_toolkit FormattedTextTuples.""" line_text = fragment_list_to_text(line_fragments) exploded_fragments = explode_text_fragments(line_fragments) - def apply_highlighting(fragments: StyleAndTextTuples, - index: int, - matching_regex_index: int = 0) -> None: + def apply_highlighting( + fragments: StyleAndTextTuples, index: int, matching_regex_index: int = 0 + ) -> None: # Expand all fragments and apply the highlighting style. old_style, _text, *_ = fragments[index] # There are 6 fuzzy-highlight styles defined in style.py. Get an index # from 0-5 to use one style after the other in turn. style_index = matching_regex_index % 6 fragments[index] = ( - old_style + - f' class:command-runner-fuzzy-highlight-{style_index} ', + old_style + f' class:command-runner-fuzzy-highlight-{style_index} ', fragments[index][1], ) @@ -116,14 +118,15 @@ class CommandRunner: # pylint: disable=too-many-instance-attributes def __init__( - self, - application: ConsoleApp, - window_title: str = None, - load_completions: Optional[Callable[[], - List[Tuple[str, - Callable]]]] = None, - width: int = 80, - height: int = 10): + self, + application: ConsoleApp, + window_title: Optional[str] = None, + load_completions: Optional[ + Callable[[], List[Tuple[str, Callable]]] + ] = None, + width: int = 80, + height: int = 10, + ): # Parent pw_console application self.application = application # Visibility toggle @@ -157,9 +160,14 @@ class CommandRunner: # Command runner text input field self.input_field = TextArea( prompt=[ - ('class:command-runner-setting', '> ', - functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self)) + ( + 'class:command-runner-setting', + '> ', + functools.partial( + mouse_handlers.on_click, + self.focus_self, + ), + ) ], focusable=True, focus_on_click=True, @@ -205,10 +213,12 @@ class CommandRunner: self.command_runner_content = HSplit( [ # Input field and buttons on the same line - VSplit([ - self.input_field, - input_field_buttons_container, - ]), + VSplit( + [ + self.input_field, + input_field_buttons_container, + ] + ), # Completion items below command_items_window, ], @@ -229,7 +239,7 @@ class CommandRunner: def _create_bordered_content(self) -> None: """Wrap self.command_runner_content in a border.""" # This should be called whenever the window_title changes. - self.bordered_content = pw_console.widgets.border.create_border( + self.bordered_content = create_border( self.command_runner_content, title=self.window_title, border_style='class:command-runner-border', @@ -270,7 +280,8 @@ class CommandRunner: def content_width(self) -> int: """Return the smaller value of self.width and the available width.""" window_manager_width = ( - self.application.window_manager.current_window_manager_width) + self.application.window_manager.current_window_manager_width + ) if not window_manager_width: window_manager_width = self.width return min(self.width, window_manager_width) @@ -298,17 +309,19 @@ class CommandRunner: def set_completions( self, - window_title: str = None, - load_completions: Optional[Callable[[], List[Tuple[str, - Callable]]]] = None, + window_title: Optional[str] = None, + load_completions: Optional[ + Callable[[], List[Tuple[str, Callable]]] + ] = None, ) -> None: """Set window title and callable to fetch possible completions. Call this function whenever new completion items need to be loaded. """ self.window_title = window_title if window_title else 'Menu Items' - self.load_completions = (load_completions - if load_completions else self.load_menu_items) + self.load_completions = ( + load_completions if load_completions else self.load_menu_items + ) self._reset_selected_item() self.completions = [] @@ -374,7 +387,8 @@ class CommandRunner: self.selected_item_handler = handler text = text.ljust(self.content_width()) fragments: StyleAndTextTuples = highlight_matches( - regexes, [(style, text + '\n')]) + regexes, [(style, text + '\n')] + ) self.completion_fragments.append(fragments) i += 1 @@ -397,7 +411,8 @@ class CommandRunner: # Don't move past the height of the window or the length of possible # items. min(self.height, len(self.completion_fragments)) - 1, - self.selected_item + 1) + self.selected_item + 1, + ) self.application.redraw_ui() def _previous_item(self) -> None: @@ -407,13 +422,11 @@ class CommandRunner: def _get_input_field_button_fragments(self) -> StyleAndTextTuples: # Mouse handlers - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self) - cancel = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.close_dialog) + focus = functools.partial(mouse_handlers.on_click, self.focus_self) + cancel = functools.partial(mouse_handlers.on_click, self.close_dialog) select_item = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self._run_selected_item) + mouse_handlers.on_click, self._run_selected_item + ) separator_text = ('', ' ', focus) @@ -424,18 +437,21 @@ class CommandRunner: # Cancel button fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='Ctrl-c', description='Cancel', mouse_handler=cancel, base_style=button_style, - )) + ) + ) fragments.append(separator_text) # Run button fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( - 'Enter', 'Run', select_item, base_style=button_style)) + to_keybind_indicator( + 'Enter', 'Run', select_item, base_style=button_style + ) + ) return fragments def render_completion_items(self) -> StyleAndTextTuples: @@ -473,7 +489,9 @@ class CommandRunner: # Actions that launch new command runners, close_dialog should not run. for command_text in [ - '[File] > Open Logger', + '[File] > Insert Repl Snippet', + '[File] > Insert Repl History', + '[File] > Open Logger', ]: if command_text in self.selected_item_text: close_dialog = False @@ -482,12 +500,14 @@ class CommandRunner: # Actions that change what is in focus should be run after closing the # command runner dialog. for command_text in [ - '[View] > Focus Next Window/Tab', - '[View] > Focus Prev Window/Tab', - # All help menu entries open popup windows. - '[Help] > ', - # This focuses on a save dialog bor. - 'Save/Export a copy', + '[File] > Games > ', + '[View] > Focus Next Window/Tab', + '[View] > Focus Prev Window/Tab', + # All help menu entries open popup windows. + '[Help] > ', + # This focuses on a save dialog bor. + 'Save/Export a copy', + '[Windows] > Floating ', ]: if command_text in self.selected_item_text: close_dialog_first = True diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py index 560d94672..38f0bfa38 100644 --- a/pw_console/py/pw_console/console_app.py +++ b/pw_console/py/pw_console/console_app.py @@ -16,14 +16,17 @@ import asyncio import builtins import functools +import socketserver +import importlib.resources import logging import os from pathlib import Path import sys +import time from threading import Thread -from typing import Any, Callable, Iterable, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union -from jinja2 import Environment, FileSystemLoader, make_logging_undefined +from jinja2 import Environment, DictLoader, make_logging_undefined from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.output import ColorDepth @@ -51,38 +54,48 @@ from prompt_toolkit.history import ( ) from ptpython.layout import CompletionVisualisation # type: ignore from ptpython.key_bindings import ( # type: ignore - load_python_bindings, load_sidebar_bindings, + load_python_bindings, + load_sidebar_bindings, ) +from pw_console.command_runner import CommandRunner +from pw_console.console_log_server import ( + ConsoleLogHTTPRequestHandler, + pw_console_http_server, +) from pw_console.console_prefs import ConsolePrefs from pw_console.help_window import HelpWindow -from pw_console.command_runner import CommandRunner -import pw_console.key_bindings +from pw_console.key_bindings import create_key_bindings from pw_console.log_pane import LogPane from pw_console.log_store import LogStore from pw_console.pw_ptpython_repl import PwPtPythonRepl from pw_console.python_logging import all_loggers from pw_console.quit_dialog import QuitDialog from pw_console.repl_pane import ReplPane -import pw_console.style -import pw_console.widgets.checkbox -import pw_console.widgets.mouse_handlers +from pw_console.style import generate_styles +from pw_console.test_mode import start_fake_logger +from pw_console.widgets import ( + FloatingWindowPane, + mouse_handlers, + to_checkbox_text, + to_keybind_indicator, +) from pw_console.window_manager import WindowManager _LOG = logging.getLogger(__package__) +_ROOT_LOG = logging.getLogger('') + +_SYSTEM_COMMAND_LOG = logging.getLogger('pw_console_system_command') -# Fake logger for --test-mode -FAKE_DEVICE_LOGGER_NAME = 'pw_console_fake_device' -_FAKE_DEVICE_LOG = logging.getLogger(FAKE_DEVICE_LOGGER_NAME) -# Don't send fake_device logs to the root Python logger. -_FAKE_DEVICE_LOG.propagate = False +_PW_CONSOLE_MODULE = 'pw_console' -MAX_FPS = 15 +MAX_FPS = 30 MIN_REDRAW_INTERVAL = (60.0 / MAX_FPS) / 60.0 class FloatingMessageBar(ConditionalContainer): """Floating message bar for showing status messages.""" + def __init__(self, application): super().__init__( FormattedTextToolbar( @@ -90,12 +103,16 @@ class FloatingMessageBar(ConditionalContainer): style='class:toolbar_inactive', ), filter=Condition( - lambda: application.message and application.message != '')) + lambda: application.message and application.message != '' + ), + ) -def _add_log_handler_to_pane(logger: Union[str, logging.Logger], - pane: 'LogPane', - level_name: Optional[str] = None) -> None: +def _add_log_handler_to_pane( + logger: Union[str, logging.Logger], + pane: 'LogPane', + level_name: Optional[str] = None, +) -> None: """A log pane handler for a given logger instance.""" if not pane: return @@ -103,7 +120,8 @@ def _add_log_handler_to_pane(logger: Union[str, logging.Logger], def get_default_colordepth( - color_depth: Optional[ColorDepth] = None) -> ColorDepth: + color_depth: Optional[ColorDepth] = None, +) -> ColorDepth: # Set prompt_toolkit color_depth to the highest possible. if color_depth is None: # Default to 24bit color @@ -135,10 +153,21 @@ class ConsoleApp: color_depth=None, extra_completers=None, prefs=None, + floating_window_plugins: Optional[ + List[Tuple[FloatingWindowPane, Dict]] + ] = None, ): self.prefs = prefs if prefs else ConsolePrefs() self.color_depth = get_default_colordepth(color_depth) + # Max frequency in seconds of prompt_toolkit UI redraws triggered by new + # log lines. + self.log_ui_update_frequency = 0.1 # 10 FPS + self._last_ui_update_time = time.time() + + self.http_server: Optional[socketserver.TCPServer] = None + self.html_files: Dict[str, str] = {} + # Create a default global and local symbol table. Values are the same # structure as what is returned by globals(): # https://docs.python.org/3/library/functions.html#globals @@ -152,13 +181,24 @@ class ConsoleApp: local_vars = local_vars or global_vars + jinja_templates = { + t: importlib.resources.read_text( + f'{_PW_CONSOLE_MODULE}.templates', t + ) + for t in importlib.resources.contents( + f'{_PW_CONSOLE_MODULE}.templates' + ) + if t.endswith('.jinja') + } + # Setup the Jinja environment self.jinja_env = Environment( # Load templates automatically from pw_console/templates - loader=FileSystemLoader(Path(__file__).parent / 'templates'), + loader=DictLoader(jinja_templates), # Raise errors if variables are undefined in templates undefined=make_logging_undefined( - logger=logging.getLogger(__package__), ), + logger=logging.getLogger(__package__), + ), # Trim whitespace in templates trim_blocks=True, lstrip_blocks=True, @@ -169,10 +209,12 @@ class ConsoleApp: # History instance for search toolbars. self.search_history: History = ThreadedHistory( - FileHistory(self.search_history_filename)) + FileHistory(self.search_history_filename) + ) # Event loop for executing user repl code. self.user_code_loop = asyncio.new_event_loop() + self.test_mode_log_loop = asyncio.new_event_loop() self.app_title = app_title if app_title else 'Pigweed Console' @@ -187,13 +229,16 @@ class ConsoleApp: self.message = [('class:logo', self.app_title), ('', ' ')] self.message.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( 'Ctrl-p', 'Search Menu', - functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.open_command_runner_main_menu), + functools.partial( + mouse_handlers.on_click, + self.open_command_runner_main_menu, + ), base_style='class:toolbar-button-inactive', - )) + ) + ) # One space separator self.message.append(('', ' ')) @@ -202,14 +247,23 @@ class ConsoleApp: # Downstream project specific help text self.app_help_text = help_text if help_text else None - self.app_help_window = HelpWindow(self, - additional_help_text=help_text, - title=(self.app_title + ' Help')) - self.app_help_window.generate_help_text() + self.app_help_window = HelpWindow( + self, + additional_help_text=help_text, + title=(self.app_title + ' Help'), + ) + self.app_help_window.generate_keybind_help_text() self.prefs_file_window = HelpWindow(self, title='.pw_console.yaml') self.prefs_file_window.load_yaml_text( - self.prefs.current_config_as_yaml()) + self.prefs.current_config_as_yaml() + ) + + self.floating_window_plugins: List[FloatingWindowPane] = [] + if floating_window_plugins: + self.floating_window_plugins = [ + plugin for plugin, _ in floating_window_plugins + ] # Used for tracking which pane was in focus before showing help window. self.last_focused_pane = None @@ -231,6 +285,8 @@ class ConsoleApp: ) self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme) + self.system_command_output_pane: Optional[LogPane] = None + if self.prefs.swap_light_and_dark: self.toggle_light_theme() @@ -244,7 +300,7 @@ class ConsoleApp: self.quit_dialog = QuitDialog(self) # Key bindings registry. - self.key_bindings = pw_console.key_bindings.create_key_bindings(self) + self.key_bindings = create_key_bindings(self) # Create help window text based global key_bindings and active panes. self._update_help_window() @@ -292,35 +348,52 @@ class ConsoleApp: # Callable to get width width=self.keybind_help_window.content_width, ), - # Completion menu that can overlap other panes since it lives in - # the top level Float container. - Float( - xcursor=True, - ycursor=True, - content=ConditionalContainer( - content=CompletionsMenu( - scroll_offset=(lambda: self.pw_ptpython_repl. - completion_menu_scroll_offset), - max_height=16, + ] + + if floating_window_plugins: + self.floats.extend( + [ + Float(content=plugin_container, **float_args) + for plugin_container, float_args in floating_window_plugins + ] + ) + + self.floats.extend( + [ + # Completion menu that can overlap other panes since it lives in + # the top level Float container. + # pylint: disable=line-too-long + Float( + xcursor=True, + ycursor=True, + content=ConditionalContainer( + content=CompletionsMenu( + scroll_offset=( + lambda: self.pw_ptpython_repl.completion_menu_scroll_offset + ), + max_height=16, + ), + # Only show our completion if ptpython's is disabled. + filter=Condition( + lambda: self.pw_ptpython_repl.completion_visualisation + == CompletionVisualisation.NONE + ), ), - # Only show our completion if ptpython's is disabled. - filter=Condition( - lambda: self.pw_ptpython_repl.completion_visualisation - == CompletionVisualisation.NONE), ), - ), - Float( - content=self.command_runner, - # Callable to get width - width=self.command_runner.content_width, - **self.prefs.command_runner_position, - ), - Float( - content=self.quit_dialog, - top=2, - left=2, - ), - ] + # pylint: enable=line-too-long + Float( + content=self.command_runner, + # Callable to get width + width=self.command_runner.content_width, + **self.prefs.command_runner_position, + ), + Float( + content=self.quit_dialog, + top=2, + left=2, + ), + ] + ) # prompt_toolkit root container. self.root_container = MenuContainer( @@ -353,18 +426,24 @@ class ConsoleApp: # Create the prompt_toolkit Application instance. self.application: Application = Application( layout=self.layout, - key_bindings=merge_key_bindings([ - # Pull key bindings from ptpython - load_python_bindings(self.pw_ptpython_repl), - load_sidebar_bindings(self.pw_ptpython_repl), - self.window_manager.key_bindings, - self.key_bindings, - ]), - style=DynamicStyle(lambda: merge_styles([ - self._current_theme, - # Include ptpython styles - self.pw_ptpython_repl._current_style, # pylint: disable=protected-access - ])), + key_bindings=merge_key_bindings( + [ + # Pull key bindings from ptpython + load_python_bindings(self.pw_ptpython_repl), + load_sidebar_bindings(self.pw_ptpython_repl), + self.window_manager.key_bindings, + self.key_bindings, + ] + ), + style=DynamicStyle( + lambda: merge_styles( + [ + self._current_theme, + # Include ptpython styles + self.pw_ptpython_repl._current_style, # pylint: disable=protected-access + ] + ) + ), style_transformation=self.pw_ptpython_repl.style_transformation, enable_page_navigation_bindings=True, full_screen=True, @@ -381,9 +460,9 @@ class ConsoleApp: # Run the function for a particular menu item. return_value = function_to_run() # It's return value dictates if the main menu should close or not. - # - True: The main menu stays open. This is the default prompt_toolkit + # - False: The main menu stays open. This is the default prompt_toolkit # menu behavior. - # - False: The main menu closes. + # - True: The main menu closes. # Update menu content. This will refresh checkboxes and add/remove # items. @@ -394,27 +473,35 @@ class ConsoleApp: self.focus_main_menu() def open_new_log_pane_for_logger( - self, - logger_name: str, - level_name='NOTSET', - window_title: Optional[str] = None) -> None: + self, + logger_name: str, + level_name='NOTSET', + window_title: Optional[str] = None, + ) -> None: pane_title = window_title if window_title else logger_name self.run_pane_menu_option( - functools.partial(self.add_log_handler, - pane_title, [logger_name], - log_level_name=level_name)) + functools.partial( + self.add_log_handler, + pane_title, + [logger_name], + log_level_name=level_name, + ) + ) def set_ui_theme(self, theme_name: str) -> Callable: call_function = functools.partial( self.run_pane_menu_option, - functools.partial(self.load_theme, theme_name)) + functools.partial(self.load_theme, theme_name), + ) return call_function def set_code_theme(self, theme_name: str) -> Callable: call_function = functools.partial( self.run_pane_menu_option, - functools.partial(self.pw_ptpython_repl.use_code_colorscheme, - theme_name)) + functools.partial( + self.pw_ptpython_repl.use_code_colorscheme, theme_name + ), + ) return call_function def update_menu_items(self): @@ -429,7 +516,8 @@ class ConsoleApp: def open_command_runner_loggers(self) -> None: self.command_runner.set_completions( window_title='Open Logger', - load_completions=self._create_logger_completions) + load_completions=self._create_logger_completions, + ) if not self.command_runner_is_open(): self.command_runner.open_dialog() @@ -437,20 +525,85 @@ class ConsoleApp: completions: List[Tuple[str, Callable]] = [ ( 'root', - functools.partial(self.open_new_log_pane_for_logger, - '', - window_title='root'), + functools.partial( + self.open_new_log_pane_for_logger, '', window_title='root' + ), ), ] all_logger_names = sorted([logger.name for logger in all_loggers()]) for logger_name in all_logger_names: - completions.append(( - logger_name, - functools.partial(self.open_new_log_pane_for_logger, - logger_name), - )) + completions.append( + ( + logger_name, + functools.partial( + self.open_new_log_pane_for_logger, logger_name + ), + ) + ) + return completions + + def open_command_runner_history(self) -> None: + self.command_runner.set_completions( + window_title='History', + load_completions=self._create_history_completions, + ) + if not self.command_runner_is_open(): + self.command_runner.open_dialog() + + def _create_history_completions(self) -> List[Tuple[str, Callable]]: + return [ + ( + description, + functools.partial( + self.repl_pane.insert_text_into_input_buffer, text + ), + ) + for description, text in self.repl_pane.history_completions() + ] + + def open_command_runner_snippets(self) -> None: + self.command_runner.set_completions( + window_title='Snippets', + load_completions=self._create_snippet_completions, + ) + if not self.command_runner_is_open(): + self.command_runner.open_dialog() + + def _http_server_entry(self) -> None: + handler = functools.partial( + ConsoleLogHTTPRequestHandler, self.html_files + ) + pw_console_http_server(3000, handler) + + def start_http_server(self): + if self.http_server is not None: + return + + html_package_path = f'{_PW_CONSOLE_MODULE}.html' + self.html_files = { + '/{}'.format(t): importlib.resources.read_text(html_package_path, t) + for t in importlib.resources.contents(html_package_path) + if Path(t).suffix in ['.css', '.html', '.js'] + } + + server_thread = Thread( + target=self._http_server_entry, args=(), daemon=True + ) + server_thread.start() + + def _create_snippet_completions(self) -> List[Tuple[str, Callable]]: + completions: List[Tuple[str, Callable]] = [ + ( + description, + functools.partial( + self.repl_pane.insert_text_into_input_buffer, text + ), + ) + for description, text in self.prefs.snippet_completions() + ] + return completions def _create_menu_items(self): @@ -461,8 +614,9 @@ class ConsoleApp: 'UI Themes', children=[ MenuItem('Default: Dark', self.set_ui_theme('dark')), - MenuItem('High Contrast', - self.set_ui_theme('high-contrast-dark')), + MenuItem( + 'High Contrast', self.set_ui_theme('high-contrast-dark') + ), MenuItem('Nord', self.set_ui_theme('nord')), MenuItem('Nord Light', self.set_ui_theme('nord-light')), MenuItem('Moonlight', self.set_ui_theme('moonlight')), @@ -471,25 +625,23 @@ class ConsoleApp: MenuItem( 'Code Themes', children=[ - MenuItem('Code: pigweed-code', - self.set_code_theme('pigweed-code')), - MenuItem('Code: pigweed-code-light', - self.set_code_theme('pigweed-code-light')), - MenuItem('Code: material', - self.set_code_theme('material')), - MenuItem('Code: gruvbox-light', - self.set_code_theme('gruvbox-light')), - MenuItem('Code: gruvbox-dark', - self.set_code_theme('gruvbox-dark')), - MenuItem('Code: tomorrow-night', - self.set_code_theme('tomorrow-night')), - MenuItem('Code: tomorrow-night-bright', - self.set_code_theme('tomorrow-night-bright')), - MenuItem('Code: tomorrow-night-blue', - self.set_code_theme('tomorrow-night-blue')), - MenuItem('Code: tomorrow-night-eighties', - self.set_code_theme('tomorrow-night-eighties')), - MenuItem('Code: dracula', self.set_code_theme('dracula')), + MenuItem( + 'Code: pigweed-code', + self.set_code_theme('pigweed-code'), + ), + MenuItem( + 'Code: pigweed-code-light', + self.set_code_theme('pigweed-code-light'), + ), + MenuItem('Code: material', self.set_code_theme('material')), + MenuItem( + 'Code: gruvbox-light', + self.set_code_theme('gruvbox-light'), + ), + MenuItem( + 'Code: gruvbox-dark', + self.set_code_theme('gruvbox-dark'), + ), MenuItem('Code: zenburn', self.set_code_theme('zenburn')), ], ), @@ -500,55 +652,81 @@ class ConsoleApp: MenuItem( '[File]', children=[ - MenuItem('Open Logger', - handler=self.open_command_runner_loggers), + MenuItem( + 'Insert Repl Snippet', + handler=self.open_command_runner_snippets, + ), + MenuItem( + 'Insert Repl History', + handler=self.open_command_runner_history, + ), + MenuItem( + 'Open Logger', handler=self.open_command_runner_loggers + ), MenuItem( 'Log Table View', children=[ + # pylint: disable=line-too-long MenuItem( '{check} Hide Date'.format( - check=pw_console.widgets.checkbox. - to_checkbox_text( + check=to_checkbox_text( self.prefs.hide_date_from_log_time, - end='')), + end='', + ) + ), handler=functools.partial( self.run_pane_menu_option, functools.partial( self.toggle_pref_option, - 'hide_date_from_log_time')), + 'hide_date_from_log_time', + ), + ), ), MenuItem( '{check} Show Source File'.format( - check=pw_console.widgets.checkbox. - to_checkbox_text( - self.prefs.show_source_file, end='')), + check=to_checkbox_text( + self.prefs.show_source_file, end='' + ) + ), handler=functools.partial( self.run_pane_menu_option, - functools.partial(self.toggle_pref_option, - 'show_source_file')), + functools.partial( + self.toggle_pref_option, + 'show_source_file', + ), + ), ), MenuItem( '{check} Show Python File'.format( - check=pw_console.widgets.checkbox. - to_checkbox_text( - self.prefs.show_python_file, end='')), + check=to_checkbox_text( + self.prefs.show_python_file, end='' + ) + ), handler=functools.partial( self.run_pane_menu_option, - functools.partial(self.toggle_pref_option, - 'show_python_file')), + functools.partial( + self.toggle_pref_option, + 'show_python_file', + ), + ), ), MenuItem( '{check} Show Python Logger'.format( - check=pw_console.widgets.checkbox. - to_checkbox_text( - self.prefs.show_python_logger, - end='')), + check=to_checkbox_text( + self.prefs.show_python_logger, end='' + ) + ), handler=functools.partial( self.run_pane_menu_option, - functools.partial(self.toggle_pref_option, - 'show_python_logger')), + functools.partial( + self.toggle_pref_option, + 'show_python_logger', + ), + ), ), - ]), + # pylint: enable=line-too-long + ], + ), MenuItem('-'), MenuItem( 'Themes', @@ -564,14 +742,29 @@ class ConsoleApp: MenuItem( '[Edit]', children=[ - MenuItem('Paste to Python Input', - handler=self.repl_pane. - paste_system_clipboard_to_input_buffer), + # pylint: disable=line-too-long + MenuItem( + 'Paste to Python Input', + handler=self.repl_pane.paste_system_clipboard_to_input_buffer, + ), + # pylint: enable=line-too-long MenuItem('-'), - MenuItem('Copy all Python Output', - handler=self.repl_pane.copy_all_output_text), - MenuItem('Copy all Python Input', - handler=self.repl_pane.copy_all_input_text), + MenuItem( + 'Copy all Python Output', + handler=self.repl_pane.copy_all_output_text, + ), + MenuItem( + 'Copy all Python Input', + handler=self.repl_pane.copy_all_input_text, + ), + MenuItem('-'), + MenuItem( + 'Clear Python Input', self.repl_pane.clear_input_buffer + ), + MenuItem( + 'Clear Python Output', + self.repl_pane.clear_output_buffer, + ), ], ), ] @@ -581,86 +774,167 @@ class ConsoleApp: '[View]', children=[ # [Menu Item ][Keybind ] - MenuItem('Focus Next Window/Tab Ctrl-Alt-n', - handler=self.window_manager.focus_next_pane), + MenuItem( + 'Focus Next Window/Tab Ctrl-Alt-n', + handler=self.window_manager.focus_next_pane, + ), # [Menu Item ][Keybind ] - MenuItem('Focus Prev Window/Tab Ctrl-Alt-p', - handler=self.window_manager.focus_previous_pane), + MenuItem( + 'Focus Prev Window/Tab Ctrl-Alt-p', + handler=self.window_manager.focus_previous_pane, + ), MenuItem('-'), - # [Menu Item ][Keybind ] - MenuItem('Move Window Up Ctrl-Alt-Up', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.move_pane_up)), + MenuItem( + 'Move Window Up Ctrl-Alt-Up', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.move_pane_up, + ), + ), # [Menu Item ][Keybind ] - MenuItem('Move Window Down Ctrl-Alt-Down', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.move_pane_down)), + MenuItem( + 'Move Window Down Ctrl-Alt-Down', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.move_pane_down, + ), + ), # [Menu Item ][Keybind ] - MenuItem('Move Window Left Ctrl-Alt-Left', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.move_pane_left)), + MenuItem( + 'Move Window Left Ctrl-Alt-Left', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.move_pane_left, + ), + ), # [Menu Item ][Keybind ] - MenuItem('Move Window Right Ctrl-Alt-Right', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.move_pane_right)), + MenuItem( + 'Move Window Right Ctrl-Alt-Right', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.move_pane_right, + ), + ), MenuItem('-'), - # [Menu Item ][Keybind ] - MenuItem('Shrink Height Alt-Minus', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.shrink_pane)), + MenuItem( + 'Shrink Height Alt-Minus', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.shrink_pane, + ), + ), # [Menu Item ][Keybind ] - MenuItem('Enlarge Height Alt-=', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.enlarge_pane)), + MenuItem( + 'Enlarge Height Alt-=', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.enlarge_pane, + ), + ), MenuItem('-'), - # [Menu Item ][Keybind ] - MenuItem('Shrink Column Alt-,', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.shrink_split)), + MenuItem( + 'Shrink Column Alt-,', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.shrink_split, + ), + ), # [Menu Item ][Keybind ] - MenuItem('Enlarge Column Alt-.', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.enlarge_split)), + MenuItem( + 'Enlarge Column Alt-.', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.enlarge_split, + ), + ), MenuItem('-'), - # [Menu Item ][Keybind ] - MenuItem('Balance Window Sizes Ctrl-u', - handler=functools.partial( - self.run_pane_menu_option, - self.window_manager.balance_window_sizes)), + MenuItem( + 'Balance Window Sizes Ctrl-u', + handler=functools.partial( + self.run_pane_menu_option, + self.window_manager.balance_window_sizes, + ), + ), ], ), ] - window_menu = self.window_manager.create_window_menu() + window_menu_items = self.window_manager.create_window_menu_items() + + floating_window_items = [] + if self.floating_window_plugins: + floating_window_items.append(MenuItem('-', None)) + floating_window_items.extend( + MenuItem( + 'Floating Window {index}: {title}'.format( + index=pane_index + 1, + title=pane.menu_title(), + ), + children=[ + MenuItem( + # pylint: disable=line-too-long + '{check} Show/Hide Window'.format( + check=to_checkbox_text(pane.show_pane, end='') + ), + # pylint: enable=line-too-long + handler=functools.partial( + self.run_pane_menu_option, pane.toggle_dialog + ), + ), + ] + + [ + MenuItem( + text, + handler=functools.partial( + self.run_pane_menu_option, handler + ), + ) + for text, handler in pane.get_window_menu_options() + ], + ) + for pane_index, pane in enumerate(self.floating_window_plugins) + ) + window_menu_items.extend(floating_window_items) + + window_menu = [MenuItem('[Windows]', children=window_menu_items)] + + top_level_plugin_menus = [] + for pane in self.window_manager.active_panes(): + top_level_plugin_menus.extend(pane.get_top_level_menus()) + if self.floating_window_plugins: + for pane in self.floating_window_plugins: + top_level_plugin_menus.extend(pane.get_top_level_menus()) help_menu_items = [ - MenuItem(self.user_guide_window.menu_title(), - handler=self.user_guide_window.toggle_display), - MenuItem(self.keybind_help_window.menu_title(), - handler=self.keybind_help_window.toggle_display), + MenuItem( + self.user_guide_window.menu_title(), + handler=self.user_guide_window.toggle_display, + ), + MenuItem( + self.keybind_help_window.menu_title(), + handler=self.keybind_help_window.toggle_display, + ), MenuItem('-'), - MenuItem('View Key Binding Config', - handler=self.prefs_file_window.toggle_display), + MenuItem( + 'View Key Binding Config', + handler=self.prefs_file_window.toggle_display, + ), ] if self.app_help_text: - help_menu_items.extend([ - MenuItem('-'), - MenuItem(self.app_help_window.menu_title(), - handler=self.app_help_window.toggle_display) - ]) + help_menu_items.extend( + [ + MenuItem('-'), + MenuItem( + self.app_help_window.menu_title(), + handler=self.app_help_window.toggle_display, + ), + ] + ) help_menu = [ # Info / Help @@ -670,7 +944,14 @@ class ConsoleApp: ), ] - return file_menu + edit_menu + view_menu + window_menu + help_menu + return ( + file_menu + + edit_menu + + view_menu + + top_level_plugin_menus + + window_menu + + help_menu + ) def focus_main_menu(self): """Set application focus to the main menu.""" @@ -690,7 +971,8 @@ class ConsoleApp: """Toggle light and dark theme colors.""" # Use ptpython's style_transformation to swap dark and light colors. self.pw_ptpython_repl.swap_light_and_dark = ( - not self.pw_ptpython_repl.swap_light_and_dark) + not self.pw_ptpython_repl.swap_light_and_dark + ) if self.application: self.focus_main_menu() @@ -699,17 +981,17 @@ class ConsoleApp: def load_theme(self, theme_name=None): """Regenerate styles for the current theme_name.""" - self._current_theme = pw_console.style.generate_styles(theme_name) + self._current_theme = generate_styles(theme_name) if theme_name: self.prefs.set_ui_theme(theme_name) - def _create_log_pane(self, - title: str = '', - log_store: Optional[LogStore] = None) -> 'LogPane': + def _create_log_pane( + self, title: str = '', log_store: Optional[LogStore] = None + ) -> 'LogPane': # Create one log pane. - log_pane = LogPane(application=self, - pane_title=title, - log_store=log_store) + log_pane = LogPane( + application=self, pane_title=title, log_store=log_store + ) self.window_manager.add_pane(log_pane) return log_pane @@ -726,11 +1008,12 @@ class ConsoleApp: self._update_help_window() def add_log_handler( - self, - window_title: str, - logger_instances: Union[Iterable[logging.Logger], LogStore], - separate_log_panes: bool = False, - log_level_name: Optional[str] = None) -> Optional[LogPane]: + self, + window_title: str, + logger_instances: Union[Iterable[logging.Logger], LogStore], + separate_log_panes: bool = False, + log_level_name: Optional[str] = None, + ) -> Optional[LogPane]: """Add the Log pane as a handler for this logger instance.""" existing_log_pane = None @@ -745,13 +1028,15 @@ class ConsoleApp: log_store = logger_instances if not existing_log_pane or separate_log_panes: - existing_log_pane = self._create_log_pane(title=window_title, - log_store=log_store) + existing_log_pane = self._create_log_pane( + title=window_title, log_store=log_store + ) if isinstance(logger_instances, list): for logger in logger_instances: - _add_log_handler_to_pane(logger, existing_log_pane, - log_level_name) + _add_log_handler_to_pane( + logger, existing_log_pane, log_level_name + ) self.refresh_layout() return existing_log_pane @@ -763,11 +1048,16 @@ class ConsoleApp: def start_user_code_thread(self): """Create a thread for running user code so the UI isn't blocked.""" - thread = Thread(target=self._user_code_thread_entry, - args=(), - daemon=True) + thread = Thread( + target=self._user_code_thread_entry, args=(), daemon=True + ) thread.start() + def _test_mode_log_thread_entry(self): + """Entry point for the user code thread.""" + asyncio.set_event_loop(self.test_mode_log_loop) + self.test_mode_log_loop.run_forever() + def _update_help_window(self): """Generate the help window text based on active pane keybindings.""" # Add global mouse bindings to the help text. @@ -777,14 +1067,17 @@ class ConsoleApp: } self.keybind_help_window.add_custom_keybinds_help_text( - 'Global Mouse', mouse_functions) + 'Global Mouse', mouse_functions + ) # Add global key bindings to the help text. - self.keybind_help_window.add_keybind_help_text('Global', - self.key_bindings) + self.keybind_help_window.add_keybind_help_text( + 'Global', self.key_bindings + ) self.keybind_help_window.add_keybind_help_text( - 'Window Management', self.window_manager.key_bindings) + 'Window Management', self.window_manager.key_bindings + ) # Add activated plugin key bindings to the help text. for pane in self.window_manager.active_panes(): @@ -792,12 +1085,14 @@ class ConsoleApp: help_section_title = pane.__class__.__name__ if isinstance(key_bindings, KeyBindings): self.keybind_help_window.add_keybind_help_text( - help_section_title, key_bindings) + help_section_title, key_bindings + ) elif isinstance(key_bindings, dict): self.keybind_help_window.add_custom_keybinds_help_text( - help_section_title, key_bindings) + help_section_title, key_bindings + ) - self.keybind_help_window.generate_help_text() + self.keybind_help_window.generate_keybind_help_text() def toggle_log_line_wrapping(self): """Menu item handler to toggle line wrapping of all log panes.""" @@ -817,23 +1112,39 @@ class ConsoleApp: def modal_window_is_open(self): """Return true if any modal window or dialog is open.""" + floating_window_is_open = ( + self.keybind_help_window.show_window + or self.prefs_file_window.show_window + or self.user_guide_window.show_window + or self.quit_dialog.show_dialog + or self.command_runner.show_dialog + ) + if self.app_help_text: - return (self.app_help_window.show_window - or self.keybind_help_window.show_window - or self.prefs_file_window.show_window - or self.user_guide_window.show_window - or self.quit_dialog.show_dialog - or self.command_runner.show_dialog) - return (self.keybind_help_window.show_window - or self.prefs_file_window.show_window - or self.user_guide_window.show_window - or self.quit_dialog.show_dialog - or self.command_runner.show_dialog) + floating_window_is_open = ( + self.app_help_window.show_window or floating_window_is_open + ) + + floating_plugin_is_open = any( + plugin.show_pane for plugin in self.floating_window_plugins + ) + + return floating_window_is_open or floating_plugin_is_open def exit_console(self): """Quit the console prompt_toolkit application UI.""" self.application.exit() + def logs_redraw(self): + emit_time = time.time() + # Has enough time passed since last UI redraw due to new logs? + if emit_time > self._last_ui_update_time + self.log_ui_update_frequency: + # Update last log time + self._last_ui_update_time = emit_time + + # Trigger Prompt Toolkit UI redraw. + self.redraw_ui() + def redraw_ui(self): """Redraw the prompt_toolkit UI.""" if hasattr(self, 'application'): @@ -841,10 +1152,37 @@ class ConsoleApp: # loop. self.application.invalidate() + def setup_command_runner_log_pane(self) -> None: + if not self.system_command_output_pane is None: + return + + self.system_command_output_pane = LogPane( + application=self, pane_title='Shell Output' + ) + self.system_command_output_pane.add_log_handler( + _SYSTEM_COMMAND_LOG, level_name='INFO' + ) + self.system_command_output_pane.log_view.log_store.formatter = ( + logging.Formatter('%(message)s') + ) + self.system_command_output_pane.table_view = False + self.system_command_output_pane.show_pane = True + # Enable line wrapping + self.system_command_output_pane.toggle_wrap_lines() + # Blank right side toolbar text + # pylint: disable=protected-access + self.system_command_output_pane._pane_subtitle = ' ' + # pylint: enable=protected-access + self.window_manager.add_pane(self.system_command_output_pane) + async def run(self, test_mode=False): """Start the prompt_toolkit UI.""" if test_mode: - background_log_task = asyncio.create_task(self.log_forever()) + background_log_task = start_fake_logger( + lines=self.user_guide_window.help_text_area.document.lines, + log_thread_entry=self._test_mode_log_thread_entry, + log_thread_loop=self.test_mode_log_loop, + ) # Repl pane has focus by default, if it's hidden switch focus to another # visible pane. @@ -853,49 +1191,12 @@ class ConsoleApp: try: unused_result = await self.application.run_async( - set_exception_handler=True) + set_exception_handler=True + ) finally: if test_mode: background_log_task.cancel() - async def log_forever(self): - """Test mode async log generator coroutine that runs forever.""" - message_count = 0 - # Sample log line format: - # Log message [= ] # 100 - - # Fake module column names. - module_names = ['APP', 'RADIO', 'BAT', 'USB', 'CPU'] - while True: - if message_count > 32 or message_count < 2: - await asyncio.sleep(1) - bar_size = 10 - position = message_count % bar_size - bar_content = " " * (bar_size - position - 1) + "=" - if position > 0: - bar_content = "=".rjust(position) + " " * (bar_size - position) - new_log_line = 'Log message [{}] # {}'.format( - bar_content, message_count) - if message_count % 10 == 0: - new_log_line += ( - ' Lorem ipsum \033[34m\033[1mdolor sit amet\033[0m' - ', consectetur ' - 'adipiscing elit.') * 8 - if message_count % 11 == 0: - new_log_line += ' ' - new_log_line += ( - '[PYTHON] START\n' - 'In []: import time;\n' - ' def t(s):\n' - ' time.sleep(s)\n' - ' return "t({}) seconds done".format(s)\n\n') - - module_name = module_names[message_count % len(module_names)] - _FAKE_DEVICE_LOG.info(new_log_line, - extra=dict(extra_metadata_fields=dict( - module=module_name, file='fake_app.cc'))) - message_count += 1 - # TODO(tonymd): Remove this alias when not used by downstream projects. def embed( @@ -904,6 +1205,10 @@ def embed( ) -> None: """PwConsoleEmbed().embed() alias.""" # Import here to avoid circular dependency - from pw_console.embed import PwConsoleEmbed # pylint: disable=import-outside-toplevel + # pylint: disable=import-outside-toplevel + from pw_console.embed import PwConsoleEmbed + + # pylint: enable=import-outside-toplevel + console = PwConsoleEmbed(*args, **kwargs) console.embed() diff --git a/pw_console/py/pw_console/console_log_server.py b/pw_console/py/pw_console/console_log_server.py new file mode 100644 index 000000000..c1d130fb0 --- /dev/null +++ b/pw_console/py/pw_console/console_log_server.py @@ -0,0 +1,78 @@ +# Copyright 2022 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Console HTTP Log Server functions.""" + +import logging +from pathlib import Path +import mimetypes +import http.server +from typing import Dict, Callable + +_LOG = logging.getLogger(__package__) + + +def _start_serving(port: int, handler: Callable) -> bool: + try: + with http.server.HTTPServer(('', port), handler) as httpd: + _LOG.debug('Serving on port %i', port) + httpd.serve_forever() + return True + except OSError: + _LOG.debug('Port %i failed.', port) + return False + + +def pw_console_http_server(starting_port: int, handler: Callable) -> None: + for i in range(100): + if _start_serving(starting_port + i, handler): + break + + +class ConsoleLogHTTPRequestHandler(http.server.BaseHTTPRequestHandler): + """Request handler that serves files from pw_console.html package data.""" + + def __init__(self, html_files: Dict[str, str], *args, **kwargs): + self.html_files = html_files + super().__init__(*args, **kwargs) + + def do_GET(self): # pylint: disable=invalid-name + _LOG.debug( + '%s: %s', + self.client_address[0], + self.raw_requestline.decode('utf-8').strip(), + ) + + path = self.path + if path == '/': + path = '/index.html' + + if path not in self.html_files: + self.send_error(http.server.HTTPStatus.NOT_FOUND, 'File not found') + return + + content: str = self.html_files[path].encode('utf-8') + content_type = 'application/octet-stream' + mime_guess, _ = mimetypes.guess_type(Path(path).name) + if mime_guess: + content_type = mime_guess + + self.send_response(http.server.HTTPStatus.OK) + self.send_header('Content-type', content_type) + self.send_header('Content-Length', str(len(content))) + self.send_header('Last-Modified', self.date_time_string()) + self.end_headers() + self.wfile.write(content) + + def log_message(self, *args, **kwargs): + pass diff --git a/pw_console/py/pw_console/console_prefs.py b/pw_console/py/pw_console/console_prefs.py index 1fda908f5..94219cf7b 100644 --- a/pw_console/py/pw_console/console_prefs.py +++ b/pw_console/py/pw_console/console_prefs.py @@ -15,14 +15,15 @@ import os from pathlib import Path -from typing import Dict, Callable, List, Union +from typing import Dict, Callable, List, Tuple, Union from prompt_toolkit.key_binding import KeyBindings import yaml -from pw_console.style import get_theme_colors +from pw_cli.yaml_config_loader_mixin import YamlConfigLoaderMixin + +from pw_console.style import get_theme_colors, generate_styles from pw_console.key_bindings import DEFAULT_KEY_BINDINGS -from pw_console.yaml_config_loader_mixin import YamlConfigLoaderMixin _DEFAULT_REPL_HISTORY: Path = Path.home() / '.pw_console_history' _DEFAULT_SEARCH_HISTORY: Path = Path.home() / '.pw_console_search' @@ -49,11 +50,11 @@ _DEFAULT_CONFIG = { 'command_runner': { 'width': 80, 'height': 10, - 'position': { - 'top': 3 - }, + 'position': {'top': 3}, }, 'key_bindings': DEFAULT_KEY_BINDINGS, + 'snippets': {}, + 'user_snippets': {}, } _DEFAULT_PROJECT_FILE = Path('$PW_PROJECT_ROOT/.pw_console.yaml') @@ -69,8 +70,9 @@ class EmptyWindowList(Exception): """Exception for window lists with no content.""" -def error_unknown_window(window_title: str, - existing_pane_titles: List[str]) -> None: +def error_unknown_window( + window_title: str, existing_pane_titles: List[str] +) -> None: """Raise an error when the window config has an unknown title. If a window title does not already exist on startup it must have a loggers: @@ -88,17 +90,21 @@ def error_unknown_window(window_title: str, f'add "duplicate_of: {existing_pane_title_example}" to your config.\n' 'If this is a brand new window, include a "loggers:" section.\n' 'See also: ' - 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config') + 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config' + ) -def error_empty_window_list(window_list_title: str, ) -> None: +def error_empty_window_list( + window_list_title: str, +) -> None: """Raise an error if a window list is empty.""" raise EmptyWindowList( f'\n\nError: The window layout heading "{window_list_title}" contains ' 'no windows.\n' 'See also: ' - 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config') + 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config' + ) class ConsolePrefs(YamlConfigLoaderMixin): @@ -121,6 +127,7 @@ class ConsolePrefs(YamlConfigLoaderMixin): environment_var='PW_CONSOLE_CONFIG_FILE', ) + self._snippet_completions: List[Tuple[str, str]] = [] self.registered_commands = DEFAULT_KEY_BINDINGS self.registered_commands.update(self.user_key_bindings) @@ -139,6 +146,9 @@ class ConsolePrefs(YamlConfigLoaderMixin): def code_theme(self) -> str: return self._config.get('code_theme', '') + def set_code_theme(self, theme_name: str): + self._config['code_theme'] = theme_name + @property def swap_light_and_dark(self) -> bool: return self._config.get('swap_light_and_dark', False) @@ -187,13 +197,12 @@ class ConsolePrefs(YamlConfigLoaderMixin): self._config[name] = not existing_setting @property - def column_order(self) -> list: + def column_order(self) -> List: return self._config.get('column_order', []) - def column_style(self, - column_name: str, - column_value: str, - default='') -> str: + def column_style( + self, column_name: str, column_value: str, default='' + ) -> str: column_colors = self._config.get('column_colors', {}) column_style = default @@ -205,25 +214,40 @@ class ConsolePrefs(YamlConfigLoaderMixin): column_style = column_colors[column_name].get('default', default) # Check for value specific color, otherwise use the default. column_style = column_colors[column_name].get( - column_value, column_style) + column_value, column_style + ) return column_style + def pw_console_color_config(self) -> Dict[str, Dict]: + column_colors = self._config.get('column_colors', {}) + theme_styles = generate_styles(self.ui_theme) + style_classes = dict(theme_styles.style_rules) + + color_config = {} + color_config['classes'] = style_classes + color_config['column_values'] = column_colors + return {'__pw_console_colors': color_config} + @property def window_column_split_method(self) -> str: return self._config.get('window_column_split_method', 'vertical') @property - def windows(self) -> dict: + def windows(self) -> Dict: return self._config.get('windows', {}) + def set_windows(self, new_config: Dict) -> None: + self._config['windows'] = new_config + @property - def window_column_modes(self) -> list: + def window_column_modes(self) -> List: return list(column_type for column_type in self.windows.keys()) @property def command_runner_position(self) -> Dict[str, int]: - position = self._config.get('command_runner', - {}).get('position', {'top': 3}) + position = self._config.get('command_runner', {}).get( + 'position', {'top': 3} + ) return { key: value for key, value in position.items() @@ -243,9 +267,9 @@ class ConsolePrefs(YamlConfigLoaderMixin): return self._config.get('key_bindings', {}) def current_config_as_yaml(self) -> str: - yaml_options = dict(sort_keys=True, - default_style='', - default_flow_style=False) + yaml_options = dict( + sort_keys=True, default_style='', default_flow_style=False + ) title = {'config_title': 'pw_console'} text = '\n' @@ -268,7 +292,8 @@ class ConsolePrefs(YamlConfigLoaderMixin): window_options = window_dict if window_dict else {} # Use 'duplicate_of: Title' if it exists, otherwise use the key. titles.append( - window_options.get('duplicate_of', window_key_title)) + window_options.get('duplicate_of', window_key_title) + ) return set(titles) def get_function_keys(self, name: str) -> List: @@ -278,13 +303,16 @@ class ConsolePrefs(YamlConfigLoaderMixin): except KeyError as error: raise KeyError('Unbound key function: {}'.format(name)) from error - def register_named_key_function(self, name: str, - default_bindings: List[str]) -> None: + def register_named_key_function( + self, name: str, default_bindings: List[str] + ) -> None: self.registered_commands[name] = default_bindings - def register_keybinding(self, name: str, key_bindings: KeyBindings, - **kwargs) -> Callable: + def register_keybinding( + self, name: str, key_bindings: KeyBindings, **kwargs + ) -> Callable: """Apply registered keys for the given named function.""" + def decorator(handler: Callable) -> Callable: "`handler` is a callable or Binding." for keys in self.get_function_keys(name): @@ -292,3 +320,41 @@ class ConsolePrefs(YamlConfigLoaderMixin): return handler return decorator + + @property + def snippets(self) -> Dict: + return self._config.get('snippets', {}) + + @property + def user_snippets(self) -> Dict: + return self._config.get('user_snippets', {}) + + def snippet_completions(self) -> List[Tuple[str, str]]: + if self._snippet_completions: + return self._snippet_completions + + all_descriptions: List[str] = [] + all_descriptions.extend(self.user_snippets.keys()) + all_descriptions.extend(self.snippets.keys()) + if not all_descriptions: + return [] + max_description_width = max( + len(description) for description in all_descriptions + ) + + all_snippets: List[Tuple[str, str]] = [] + all_snippets.extend(self.user_snippets.items()) + all_snippets.extend(self.snippets.items()) + + self._snippet_completions = [ + ( + description.ljust(max_description_width) + ' : ' + + # Flatten linebreaks in the text. + ' '.join([line.lstrip() for line in text.splitlines()]), + # Pass original text as the completion result. + text, + ) + for description, text in all_snippets + ] + + return self._snippet_completions diff --git a/pw_console/py/pw_console/docs/__init__.py b/pw_console/py/pw_console/docs/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/pw_console/py/pw_console/docs/__init__.py diff --git a/pw_console/py/pw_console/docs/user_guide.rst b/pw_console/py/pw_console/docs/user_guide.rst index 3f1edcb15..43da6d0d4 100644 --- a/pw_console/py/pw_console/docs/user_guide.rst +++ b/pw_console/py/pw_console/docs/user_guide.rst @@ -3,9 +3,10 @@ User Guide ========== -.. seealso:: +.. tip:: - This guide can be viewed online at: + This guide can be viewed while running pw_console under the ``[Help]`` menu + or online at: https://pigweed.dev/pw_console/py/pw_console/docs/user_guide.html @@ -16,10 +17,14 @@ in a single-window terminal based interface. Starting the Console -------------------- -:: +Launching the console may be different if you implement your own custom console +startup script. To launch pw_console in upstream Pigweed you can run in test +mode with ``pw-console --test-mode``. - pw rpc -s localhost:33000 --proto-globs pw_rpc/echo.proto +.. seealso:: + Running pw_console for the :ref:`target-stm32f429i-disc1-stm32cube` and + :ref:`target-host-device-simulator` targets. Exiting ~~~~~~~ @@ -578,6 +583,8 @@ loaded later in the startup sequence. .. code-block:: yaml + --- + config_title: pw_console ui_theme: nord code_theme: pigweed-code swap_light_and_dark: False @@ -778,6 +785,23 @@ Example Config log-pane.shift-line-to-center: - z z + # Python Repl Snippets (Project owned) + snippets: + Count Ten Times: | + for i in range(10): + print(i) + Local Variables: | + locals() + + # Python Repl Snippets (User owned) + user_snippets: + Pretty print format function: | + import pprint + _pretty_format = pprint.PrettyPrinter(indent=1, width=120).pformat + Global variables: | + globals() + + Changing Keyboard Shortcuts --------------------------- diff --git a/pw_console/py/pw_console/embed.py b/pw_console/py/pw_console/embed.py index 5ef83f065..47cd86d60 100644 --- a/pw_console/py/pw_console/embed.py +++ b/pw_console/py/pw_console/embed.py @@ -16,15 +16,21 @@ import asyncio import logging from pathlib import Path -from typing import Any, Dict, List, Iterable, Optional, Union +from typing import Any, Dict, List, Iterable, Optional, Tuple, Union from prompt_toolkit.completion import WordCompleter from pw_console.console_app import ConsoleApp from pw_console.get_pw_console_app import PW_CONSOLE_APP_CONTEXTVAR from pw_console.plugin_mixin import PluginMixin -import pw_console.python_logging -from pw_console.widgets import WindowPane, WindowPaneToolbar +from pw_console.python_logging import ( + setup_python_logging as pw_console_setup_python_logging, +) +from pw_console.widgets import ( + FloatingWindowPane, + WindowPane, + WindowPaneToolbar, +) def _set_console_app_instance(plugin: Any, console_app: ConsoleApp) -> None: @@ -38,16 +44,19 @@ class PwConsoleEmbed: """Embed class for customizing the console before startup.""" # pylint: disable=too-many-instance-attributes - def __init__(self, - global_vars=None, - local_vars=None, - loggers: Optional[Union[Dict[str, Iterable[logging.Logger]], - Iterable]] = None, - test_mode=False, - repl_startup_message: Optional[str] = None, - help_text: Optional[str] = None, - app_title: Optional[str] = None, - config_file_path: Optional[Union[str, Path]] = None) -> None: + def __init__( + self, + global_vars=None, + local_vars=None, + loggers: Optional[ + Union[Dict[str, Iterable[logging.Logger]], Iterable] + ] = None, + test_mode=False, + repl_startup_message: Optional[str] = None, + help_text: Optional[str] = None, + app_title: Optional[str] = None, + config_file_path: Optional[Union[str, Path]] = None, + ) -> None: """Call this to embed pw console at the call point within your program. Example usage: @@ -65,7 +74,7 @@ class PwConsoleEmbed: loggers={ 'Host Logs': [ logging.getLogger(__package__), - logging.getLogger(__file__), + logging.getLogger(__name__), ], 'Device Logs': [ logging.getLogger('usb_gadget'), @@ -115,8 +124,9 @@ class PwConsoleEmbed: self.repl_startup_message = repl_startup_message self.help_text = help_text self.app_title = app_title - self.config_file_path = Path( - config_file_path) if config_file_path else None + self.config_file_path = ( + Path(config_file_path) if config_file_path else None + ) self.console_app: Optional[ConsoleApp] = None self.extra_completers: List = [] @@ -124,6 +134,7 @@ class PwConsoleEmbed: self.setup_python_logging_called = False self.hidden_by_default_windows: List[str] = [] self.window_plugins: List[WindowPane] = [] + self.floating_window_plugins: List[Tuple[FloatingWindowPane, Dict]] = [] self.top_toolbar_plugins: List[WindowPaneToolbar] = [] self.bottom_toolbar_plugins: List[WindowPaneToolbar] = [] @@ -135,6 +146,42 @@ class PwConsoleEmbed: """ self.window_plugins.append(window_pane) + def add_floating_window_plugin( + self, window_pane: FloatingWindowPane, **float_args + ) -> None: + """Include a custom floating window pane plugin. + + This adds a FloatingWindowPane class to the pw_console UI. The first + argument should be the window to add and the remaining keyword arguments + are passed to the prompt_toolkit Float() class. This allows positioning + of the floating window. By default the floating window will be + centered. To anchor the window to a side or corner of the screen set the + ``left``, ``right``, ``top``, or ``bottom`` keyword args. + + For example: + + .. code-block:: python + + from pw_console import PwConsoleEmbed + + console = PwConsoleEmbed(...) + my_plugin = MyPlugin() + # Anchor this floating window 2 rows away from the top and 4 columns + # away from the left edge of the screen. + console.add_floating_window_plugin(my_plugin, top=2, left=4) + + See all possible keyword args in the prompt_toolkit documentation: + https://python-prompt-toolkit.readthedocs.io/en/stable/pages/reference.html#prompt_toolkit.layout.Float + + Args: + window_pane: Any instance of the FloatingWindowPane class. + left: Distance to the left edge of the screen + right: Distance to the right edge of the screen + top: Distance to the top edge of the screen + bottom: Distance to the bottom edge of the screen + """ + self.floating_window_plugins.append((window_pane, float_args)) + def add_top_toolbar(self, toolbar: WindowPaneToolbar) -> None: """Include a toolbar plugin to display on the top of the screen. @@ -157,9 +204,9 @@ class PwConsoleEmbed: """ self.bottom_toolbar_plugins.append(toolbar) - def add_sentence_completer(self, - word_meta_dict: Dict[str, str], - ignore_case=True) -> None: + def add_sentence_completer( + self, word_meta_dict: Dict[str, str], ignore_case=True + ) -> None: """Include a custom completer that matches on the entire repl input. Args: @@ -196,16 +243,20 @@ class PwConsoleEmbed: elif isinstance(self.loggers, dict): for window_title, logger_instances in self.loggers.items(): window_pane = self.console_app.add_log_handler( - window_title, logger_instances) - - if (window_pane and window_pane.pane_title() - in self.hidden_by_default_windows): + window_title, logger_instances + ) + + if ( + window_pane + and window_pane.pane_title() + in self.hidden_by_default_windows + ): window_pane.show_pane = False def setup_python_logging( self, last_resort_filename: Optional[str] = None, - loggers_with_no_propagation: Optional[Iterable[logging.Logger]] = None + loggers_with_no_propagation: Optional[Iterable[logging.Logger]] = None, ) -> None: """Setup friendly logging for full-screen prompt_toolkit applications. @@ -232,15 +283,16 @@ class PwConsoleEmbed: logger. """ self.setup_python_logging_called = True - pw_console.python_logging.setup_python_logging( - last_resort_filename, loggers_with_no_propagation) + pw_console_setup_python_logging( + last_resort_filename, loggers_with_no_propagation + ) def hide_windows(self, *window_titles) -> None: """Hide window panes specified by title on console startup.""" for window_title in window_titles: self.hidden_by_default_windows.append(window_title) - def embed(self) -> None: + def embed(self, override_window_config: Optional[Dict] = None) -> None: """Start the console.""" # Create the ConsoleApp instance. @@ -251,6 +303,7 @@ class PwConsoleEmbed: help_text=self.help_text, app_title=self.app_title, extra_completers=self.extra_completers, + floating_window_plugins=self.floating_window_plugins, ) PW_CONSOLE_APP_CONTEXTVAR.set(self.console_app) # type: ignore # Setup Python logging and log panes. @@ -274,6 +327,10 @@ class PwConsoleEmbed: _set_console_app_instance(toolbar, self.console_app) self.console_app.window_manager.add_bottom_toolbar(toolbar) + # Init floating window plugins. + for floating_window, _ in self.floating_window_plugins: + _set_console_app_instance(floating_window, self.console_app) + # Rebuild prompt_toolkit containers, menu items, and help content with # any new plugins added above. self.console_app.refresh_layout() @@ -282,6 +339,8 @@ class PwConsoleEmbed: if self.config_file_path: self.console_app.load_clean_config(self.config_file_path) + if override_window_config: + self.console_app.prefs.set_windows(override_window_config) self.console_app.apply_window_config() # Hide the repl pane if it's in the hidden windows list. @@ -303,5 +362,6 @@ class PwConsoleEmbed: toolbar.plugin_start() # Start the prompt_toolkit UI app. - asyncio.run(self.console_app.run(test_mode=self.test_mode), - debug=self.test_mode) + asyncio.run( + self.console_app.run(test_mode=self.test_mode), debug=self.test_mode + ) diff --git a/pw_console/py/pw_console/filter_toolbar.py b/pw_console/py/pw_console/filter_toolbar.py index df37669ec..9018180b0 100644 --- a/pw_console/py/pw_console/filter_toolbar.py +++ b/pw_console/py/pw_console/filter_toolbar.py @@ -28,9 +28,14 @@ from prompt_toolkit.layout import ( ) from prompt_toolkit.mouse_events import MouseEvent, MouseEventType -import pw_console.widgets.checkbox -import pw_console.widgets.mouse_handlers -import pw_console.style +from pw_console.style import ( + get_button_style, + get_toolbar_style, +) +from pw_console.widgets import ( + mouse_handlers, + to_keybind_indicator, +) if TYPE_CHECKING: from pw_console.log_pane import LogPane @@ -41,8 +46,7 @@ class FilterToolbar(ConditionalContainer): TOOLBAR_HEIGHT = 1 - def mouse_handler_delete_filter(self, filter_text, - mouse_event: MouseEvent): + def mouse_handler_delete_filter(self, filter_text, mouse_event: MouseEvent): """Delete the given log filter.""" if mouse_event.event_type == MouseEventType.MOUSE_UP: self.log_pane.log_view.delete_filter(filter_text) @@ -55,7 +59,7 @@ class FilterToolbar(ConditionalContainer): space = ('', ' ') fragments = [('class:filter-bar-title', ' Filters '), separator] - button_style = pw_console.style.get_button_style(self.log_pane) + button_style = get_button_style(self.log_pane) for filter_text, log_filter in self.log_pane.log_view.filters.items(): fragments.append(('class:filter-bar-delimiter', '<')) @@ -64,17 +68,21 @@ class FilterToolbar(ConditionalContainer): fragments.append(('class:filter-bar-setting', 'NOT ')) if log_filter.field: - fragments.append( - ('class:filter-bar-setting', log_filter.field)) + fragments.append(('class:filter-bar-setting', log_filter.field)) fragments.append(space) fragments.append(('', filter_text)) fragments.append(space) fragments.append( - (button_style + ' class:filter-bar-delete', ' (X) ', - functools.partial(self.mouse_handler_delete_filter, - filter_text))) # type: ignore + ( + button_style + ' class:filter-bar-delete', + ' (X) ', + functools.partial( + self.mouse_handler_delete_filter, filter_text + ), + ) + ) # type: ignore fragments.append(('class:filter-bar-delimiter', '>')) fragments.append(separator) @@ -83,36 +91,42 @@ class FilterToolbar(ConditionalContainer): def get_center_fragments(self): """Return formatted text tokens for display.""" clear_filters = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self.log_pane.log_view.clear_filters) + mouse_handlers.on_click, + self.log_pane.log_view.clear_filters, + ) - button_style = pw_console.style.get_button_style(self.log_pane) + button_style = get_button_style(self.log_pane) - return pw_console.widgets.checkbox.to_keybind_indicator( + return to_keybind_indicator( 'Ctrl-Alt-r', 'Clear Filters', clear_filters, - base_style=button_style) + base_style=button_style, + ) def __init__(self, log_pane: 'LogPane'): self.log_pane = log_pane left_bar_control = FormattedTextControl(self.get_left_fragments) - left_bar_window = Window(content=left_bar_control, - align=WindowAlign.LEFT, - dont_extend_width=True) + left_bar_window = Window( + content=left_bar_control, + align=WindowAlign.LEFT, + dont_extend_width=True, + ) center_bar_control = FormattedTextControl(self.get_center_fragments) - center_bar_window = Window(content=center_bar_control, - align=WindowAlign.LEFT, - dont_extend_width=False) + center_bar_window = Window( + content=center_bar_control, + align=WindowAlign.LEFT, + dont_extend_width=False, + ) super().__init__( VSplit( [ left_bar_window, center_bar_window, ], - style=functools.partial(pw_console.style.get_toolbar_style, - self.log_pane, - dim=True), + style=functools.partial( + get_toolbar_style, self.log_pane, dim=True + ), height=1, align=HorizontalAlign.LEFT, ), diff --git a/pw_console/py/pw_console/help_window.py b/pw_console/py/pw_console/help_window.py index d8ca7ad55..d90a234db 100644 --- a/pw_console/py/pw_console/help_window.py +++ b/pw_console/py/pw_console/help_window.py @@ -14,10 +14,10 @@ """Help window container class.""" import functools +import importlib.resources import inspect import logging -from pathlib import Path -from typing import Dict, TYPE_CHECKING +from typing import Dict, Optional, TYPE_CHECKING from prompt_toolkit.document import Document from prompt_toolkit.filters import Condition @@ -37,13 +37,22 @@ from prompt_toolkit.widgets import Box, TextArea from pygments.lexers.markup import RstLexer # type: ignore from pygments.lexers.data import YamlLexer # type: ignore -import pw_console.widgets.mouse_handlers + +from pw_console.style import ( + get_pane_indicator, +) +from pw_console.widgets import ( + mouse_handlers, + to_keybind_indicator, +) if TYPE_CHECKING: from pw_console.console_app import ConsoleApp _LOG = logging.getLogger(__package__) +_PW_CONSOLE_MODULE = 'pw_console' + def _longest_line_length(text): """Return the longest line in the given text.""" @@ -78,24 +87,30 @@ class HelpWindow(ConditionalContainer): """Close the current dialog window.""" self.toggle_display() - @register('help-window.copy-all', key_bindings) - def _copy_all(_event: KeyPressEvent) -> None: - """Close the current dialog window.""" - self.copy_all_text() + if not self.disable_ctrl_c: + + @register('help-window.copy-all', key_bindings) + def _copy_all(_event: KeyPressEvent) -> None: + """Close the current dialog window.""" + self.copy_all_text() help_text_area.control.key_bindings = key_bindings return help_text_area - def __init__(self, - application: 'ConsoleApp', - preamble: str = '', - additional_help_text: str = '', - title: str = '') -> None: + def __init__( + self, + application: 'ConsoleApp', + preamble: str = '', + additional_help_text: str = '', + title: str = '', + disable_ctrl_c: bool = False, + ) -> None: # Dict containing key = section title and value = list of key bindings. self.application: 'ConsoleApp' = application self.show_window: bool = False self.help_text_sections: Dict[str, Dict] = {} self._pane_title: str = title + self.disable_ctrl_c = disable_ctrl_c # Tracks the last focused container, to enable restoring focus after # closing the dialog. @@ -106,8 +121,11 @@ class HelpWindow(ConditionalContainer): self.additional_help_text: str = additional_help_text self.help_text: str = '' - self.max_additional_help_text_width: int = (_longest_line_length( - self.additional_help_text) if additional_help_text else 0) + self.max_additional_help_text_width: int = ( + _longest_line_length(self.additional_help_text) + if additional_help_text + else 0 + ) self.max_description_width: int = 0 self.max_key_list_width: int = 0 self.max_line_length: int = 0 @@ -115,35 +133,47 @@ class HelpWindow(ConditionalContainer): self.help_text_area: TextArea = self._create_help_text_area() close_mouse_handler = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self.toggle_display) + mouse_handlers.on_click, self.toggle_display + ) copy_mouse_handler = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self.copy_all_text) + mouse_handlers.on_click, self.copy_all_text + ) toolbar_padding = 1 toolbar_title = ' ' * toolbar_padding toolbar_title += self.pane_title() buttons = [] + if not self.disable_ctrl_c: + buttons.extend( + to_keybind_indicator( + 'Ctrl-c', + 'Copy All', + copy_mouse_handler, + base_style='class:toolbar-button-active', + ) + ) + buttons.append(('', ' ')) + buttons.extend( - pw_console.widgets.checkbox.to_keybind_indicator( - 'Ctrl-c', - 'Copy All', - copy_mouse_handler, - base_style='class:toolbar-button-active')) - buttons.append(('', ' ')) - buttons.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( 'q', 'Close', close_mouse_handler, - base_style='class:toolbar-button-active')) + base_style='class:toolbar-button-active', + ) + ) top_toolbar = VSplit( [ Window( content=FormattedTextControl( # [('', toolbar_title)] - functools.partial(pw_console.style.get_pane_indicator, - self, toolbar_title)), + functools.partial( + get_pane_indicator, + self, + toolbar_title, + ) + ), align=WindowAlign.LEFT, dont_extend_width=True, ), @@ -162,17 +192,19 @@ class HelpWindow(ConditionalContainer): style='class:toolbar_active', ) - self.container = HSplit([ - top_toolbar, - Box( - body=DynamicContainer(lambda: self.help_text_area), - padding=Dimension(preferred=1, max=1), - padding_bottom=0, - padding_top=0, - char=' ', - style='class:frame.border', # Same style used for Frame. - ), - ]) + self.container = HSplit( + [ + top_toolbar, + Box( + body=DynamicContainer(lambda: self.help_text_area), + padding=Dimension(preferred=1, max=1), + padding_bottom=0, + padding_top=0, + char=' ', + style='class:frame.border', # Same style used for Frame. + ), + ] + ) super().__init__( self.container, @@ -196,7 +228,8 @@ class HelpWindow(ConditionalContainer): def copy_all_text(self): """Copy all text in the Python input to the system clipboard.""" self.application.application.clipboard.set_text( - self.help_text_area.buffer.text) + self.help_text_area.buffer.text + ) def toggle_display(self): """Toggle visibility of this help window.""" @@ -227,26 +260,30 @@ class HelpWindow(ConditionalContainer): scrollbar_width = 1 desired_width = self.max_line_length + ( - left_side_frame_and_padding_width + - right_side_frame_and_padding_width + scrollbar_padding + - scrollbar_width) + left_side_frame_and_padding_width + + right_side_frame_and_padding_width + + scrollbar_padding + + scrollbar_width + ) desired_width = max(60, desired_width) window_manager_width = ( - self.application.window_manager.current_window_manager_width) + self.application.window_manager.current_window_manager_width + ) if not window_manager_width: window_manager_width = 80 return min(desired_width, window_manager_width) def load_user_guide(self): - rstdoc = Path(__file__).parent / 'docs/user_guide.rst' + rstdoc_text = importlib.resources.read_text( + f'{_PW_CONSOLE_MODULE}.docs', 'user_guide.rst' + ) max_line_length = 0 rst_text = '' - with rstdoc.open() as rstfile: - for line in rstfile.readlines(): - if 'https://' not in line and len(line) > max_line_length: - max_line_length = len(line) - rst_text += line + for line in rstdoc_text.splitlines(): + if 'https://' not in line and len(line) > max_line_length: + max_line_length = len(line) + rst_text += line + '\n' self.max_line_length = max_line_length self.help_text_area = self._create_help_text_area( @@ -266,12 +303,21 @@ class HelpWindow(ConditionalContainer): text=content, ) - def generate_help_text(self): + def set_help_text( + self, text: str, lexer: Optional[PygmentsLexer] = None + ) -> None: + self.help_text_area = self._create_help_text_area( + lexer=lexer, + text=text, + ) + self._update_help_text_area(text) + + def generate_keybind_help_text(self) -> str: """Generate help text based on added key bindings.""" template = self.application.get_template('keybind_list.jinja') - self.help_text = template.render( + text = template.render( sections=self.help_text_sections, max_additional_help_text_width=self.max_additional_help_text_width, max_description_width=self.max_description_width, @@ -280,14 +326,19 @@ class HelpWindow(ConditionalContainer): additional_help_text=self.additional_help_text, ) + self._update_help_text_area(text) + return text + + def _update_help_text_area(self, text: str) -> None: + self.help_text = text + # Find the longest line in the rendered template. self.max_line_length = _longest_line_length(self.help_text) # Replace the TextArea content. - self.help_text_area.buffer.document = Document(text=self.help_text, - cursor_position=0) - - return self.help_text + self.help_text_area.buffer.document = Document( + text=self.help_text, cursor_position=0 + ) def add_custom_keybinds_help_text(self, section_name, key_bindings: Dict): """Add hand written key_bindings.""" @@ -319,11 +370,13 @@ class HelpWindow(ConditionalContainer): # Get the existing list of keys for this function or make a new one. key_list = self.help_text_sections[section_name].get( - description, list()) + description, list() + ) # Save the name of the key e.g. F1, q, ControlQ, ControlUp key_name = ' '.join( - [getattr(key, 'name', str(key)) for key in binding.keys]) + [getattr(key, 'name', str(key)) for key in binding.keys] + ) key_name = key_name.replace('Control', 'Ctrl-') key_name = key_name.replace('Shift', 'Shift-') key_name = key_name.replace('Escape ', 'Alt-') diff --git a/pw_console/py/pw_console/html/__init__.py b/pw_console/py/pw_console/html/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/pw_console/py/pw_console/html/__init__.py diff --git a/pw_console/py/pw_console/html/index.html b/pw_console/py/pw_console/html/index.html new file mode 100644 index 000000000..5d7a21b09 --- /dev/null +++ b/pw_console/py/pw_console/html/index.html @@ -0,0 +1,37 @@ +<!-- +Copyright 2022 The Pigweed Authors + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy of +the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. +--> +<head> + <link href="./style.css" rel="stylesheet" /> +</head> + +<body> + <div class="table-container"> + <div class="log-header"> + <div class="log-entry"> + <span class="timestamp">Time</span> + <span class="level">Level</span> + <span class="module">Module</span> + <span class="time">Timestamp</span> + <span class="keys">Keys</span> + <span class="msg">Message</span> + </div> + </div> + <div class="log-container"></div> + </div> + + <script src="https://unpkg.com/virtualized-list@2.2.0/umd/virtualized-list.min.js"></script> + <script src="./main.js"></script> +</body> diff --git a/pw_console/py/pw_console/html/main.js b/pw_console/py/pw_console/html/main.js new file mode 100644 index 000000000..d08d019ed --- /dev/null +++ b/pw_console/py/pw_console/html/main.js @@ -0,0 +1,261 @@ +// Copyright 2022 The Pigweed Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +var VirtualizedList = window.VirtualizedList.default; +const rowHeight = 30; + +function formatDate(dt) { + function pad2(n) { + return (n < 10 ? '0' : '') + n; + } + + return dt.getFullYear() + pad2(dt.getMonth() + 1) + pad2(dt.getDate()) + ' ' + + pad2(dt.getHours()) + ':' + pad2(dt.getMinutes()) + ':' + + pad2(dt.getSeconds()); +} + +let data = []; +function clearLogs() { + data = [{ + 'message': 'Logs started', + 'levelno': 20, + time: formatDate(new Date()), + 'levelname': '\u001b[35m\u001b[1mINF\u001b[0m', + 'args': [], + 'fields': {'module': '', 'file': '', 'timestamp': '', 'keys': ''} + }]; +} +clearLogs(); + +let nonAdditionalDataFields = + ['_hosttime', 'levelname', 'levelno', 'args', 'fields', 'message', 'time']; +let additionalHeaders = []; +function updateHeadersFromData(data) { + let dirty = false; + Object.keys(data).forEach((columnName) => { + if (nonAdditionalDataFields.indexOf(columnName) === -1 && + additionalHeaders.indexOf(columnName) === -1) { + additionalHeaders.push(columnName); + dirty = true; + } + }); + Object.keys(data.fields || {}).forEach((columnName) => { + if (nonAdditionalDataFields.indexOf(columnName) === -1 && + additionalHeaders.indexOf(columnName) === -1) { + additionalHeaders.push(columnName); + dirty = true; + } + }); + + const headerDOM = document.querySelector('.log-header'); + if (dirty) { + headerDOM.innerHTML = ` + <span class="_hosttime">Time</span> + <span class="level">Level</span> + ${ + additionalHeaders + .map((key) => ` + <span class="${key}">${key}</span> + `).join('\n')} + <span class="msg">Message</span>` + } + + // Also update column widths to match actual row. + const headerChildren = Array.from(headerDOM.children); + + const firstRow = document.querySelector('.log-container .log-entry'); + const firstRowChildren = Array.from(firstRow.children); + headerChildren.forEach((col, index) => { + if (firstRowChildren[index]) { + col.setAttribute( + 'style', + `width:${firstRowChildren[index].getBoundingClientRect().width}`); + col.setAttribute('title', col.innerText); + } + }) +} + +function getUrlHashParameter(param) { + var params = getUrlHashParameters(); + return params[param]; +} + +function getUrlHashParameters() { + var sPageURL = window.location.hash; + if (sPageURL) + sPageURL = sPageURL.split('#')[1]; + var pairs = sPageURL.split('&'); + var object = {}; + pairs.forEach(function(pair, i) { + pair = pair.split('='); + if (pair[0] !== '') + object[pair[0]] = pair[1]; + }); + return object; +} +let currentTheme = {}; +let defaultLogStyleRule = 'color: #ffffff;'; +let columnStyleRules = {}; +let defaultColumnStyles = []; +let logLevelStyles = {}; +const logLevelToString = { + 10: 'DBG', + 20: 'INF', + 21: 'OUT', + 30: 'WRN', + 40: 'ERR', + 50: 'CRT', + 70: 'FTL' +}; + +function setCurrentTheme(newTheme) { + currentTheme = newTheme; + defaultLogStyleRule = parseStyle(newTheme.default); + document.querySelector('body').setAttribute('style', defaultLogStyleRule); + // Apply default font styles to columns + let styles = []; + Object.keys(newTheme).forEach(key => { + if (key.startsWith('log-table-column-')) { + styles.push(newTheme[key]); + } + if (key.startsWith('log-level-')) { + logLevelStyles[parseInt(key.replace('log-level-', ''))] = + parseStyle(newTheme[key]); + } + }); + defaultColumnStyles = styles; +} + +function parseStyle(rule) { + const ruleList = rule.split(' '); + let outputStyle = ruleList.map(fragment => { + if (fragment.startsWith('bg:')) { + return `background-color: ${fragment.replace('bg:', '')}` + } else if (fragment === 'bold') { + return `font-weight: bold`; + } else if (fragment === 'underline') { + return `text-decoration: underline`; + } else if (fragment.startsWith('#')) { + return `color: ${fragment}`; + } + }); + return outputStyle.join(';') +} + +function applyStyling(data, applyColors = false) { + let colIndex = 0; + Object.keys(data).forEach(key => { + if (columnStyleRules[key] && typeof data[key] === 'string') { + Object.keys(columnStyleRules[key]).forEach(token => { + data[key] = data[key].replaceAll( + token, + `<span + style="${defaultLogStyleRule};${ + applyColors ? (defaultColumnStyles + [colIndex % defaultColumnStyles.length]) : + ''};${parseStyle(columnStyleRules[key][token])};"> + ${token} + </span>`); + }); + } else if (key === 'fields') { + data[key] = applyStyling(data.fields, true); + } + if (applyColors) { + data[key] = `<span + style="${ + parseStyle( + defaultColumnStyles[colIndex % defaultColumnStyles.length])}"> + ${data[key]} + </span>`; + } + colIndex++; + }); + return data; +} + +(function() { +const container = document.querySelector('.log-container'); +const height = window.innerHeight - 50 +let follow = true; +// Initialize our VirtualizedList +var virtualizedList = new VirtualizedList(container, { + height, + rowCount: data.length, + rowHeight: rowHeight, + estimatedRowHeight: rowHeight, + renderRow: (index) => { + const element = document.createElement('div'); + element.classList.add('log-entry'); + element.setAttribute('style', `height: ${rowHeight}px;`); + const logData = data[index]; + element.innerHTML = ` + <span class="time">${logData.time}</span> + <span class="level" style="${logLevelStyles[logData.levelno] || ''}">${ + logLevelToString[logData.levelno]}</span> + ${ + additionalHeaders + .map( + (key) => ` + <span class="${key}">${ + logData[key] || logData.fields[key] || ''}</span> + `).join('\n')} + <span class="msg">${logData.message}</span> + `; + return element; + }, + initialIndex: 0, + onScroll: (scrollTop, event) => { + const offset = + virtualizedList._sizeAndPositionManager.getUpdatedOffsetForIndex({ + containerSize: height, + targetIndex: data.length - 1, + }); + + if (scrollTop < offset) { + follow = false; + } else { + follow = true; + } + } +}); + +const port = getUrlHashParameter('ws') +const hostname = location.hostname || '127.0.0.1'; +var ws = new WebSocket(`ws://${hostname}:${port}/`); +ws.onmessage = function(event) { + let dataObj; + try { + dataObj = JSON.parse(event.data); + } catch (e) { + } + if (!dataObj) + return; + + if (dataObj.__pw_console_colors) { + const colors = dataObj.__pw_console_colors; + setCurrentTheme(colors.classes); + if (colors.column_values) { + columnStyleRules = {...colors.column_values}; + } + } else { + const currentData = {...dataObj, time: formatDate(new Date())}; + updateHeadersFromData(currentData); + data.push(applyStyling(currentData)); + virtualizedList.setRowCount(data.length); + if (follow) { + virtualizedList.scrollToIndex(data.length - 1); + } + } +}; +})(); diff --git a/pw_console/py/pw_console/html/style.css b/pw_console/py/pw_console/html/style.css new file mode 100644 index 000000000..2c92a48d0 --- /dev/null +++ b/pw_console/py/pw_console/html/style.css @@ -0,0 +1,74 @@ +/* + * Copyright 2022 The Pigweed Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +body { + background-color: rgb(46, 46, 46); + color: #ffffff; + overflow: hidden; + margin: 0; +} + +.table-container { + display: table; + width: 100%; + border-spacing: 30px 0px; +} + +.log-header { + font-size: 18px; + font-family: monospace; +} + +.log-container { + width: 100%; + height: calc(100vh - 50px); + overflow-y: auto; + border-top: 1px solid #DDD; + font-size: 18px; + font-family: monospace; +} + +.log-header { + width: 100%; + font-weight: bold; + display: table-row; +} + +.log-container .row>span { + display: table-cell; + padding: 20px 18px; + +} + +.log-header>span { + text-transform: capitalize; + overflow: hidden; + display: inline-block; + margin-left: 30px; +} + +.log-entry { + display: table-row; +} + +.log-entry>span { + display: table-cell; + overflow: hidden; + text-overflow: ellipsis; +} + +.log-entry .msg { + flex: 1; +} diff --git a/pw_console/py/pw_console/key_bindings.py b/pw_console/py/pw_console/key_bindings.py index c44d8663c..3db259217 100644 --- a/pw_console/py/pw_console/key_bindings.py +++ b/pw_console/py/pw_console/key_bindings.py @@ -24,9 +24,8 @@ from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous from prompt_toolkit.key_binding.key_bindings import Binding -import pw_console.pw_ptpython_repl -__all__ = ('create_key_bindings', ) +__all__ = ('create_key_bindings',) _LOG = logging.getLogger(__package__) @@ -46,6 +45,7 @@ DEFAULT_KEY_BINDINGS: Dict[str, List[str]] = { 'log-pane.remove-duplicated-log-pane': ['delete'], 'log-pane.clear-history': ['C'], 'log-pane.toggle-follow': ['f'], + 'log-pane.toggle-web-browser': ['O'], 'log-pane.move-cursor-up': ['up', 'k'], 'log-pane.move-cursor-down': ['down', 'j'], 'log-pane.visual-select-up': ['s-up'], @@ -73,8 +73,9 @@ DEFAULT_KEY_BINDINGS: Dict[str, List[str]] = { 'window-manager.move-pane-down': ['escape c-up'], # Alt-Ctrl- 'window-manager.move-pane-up': ['escape c-down'], # Alt-Ctrl- 'window-manager.enlarge-pane': ['escape ='], # Alt-= (mnemonic: Alt Plus) - 'window-manager.shrink-pane': - ['escape -'], # Alt-minus (mnemonic: Alt Minus) + 'window-manager.shrink-pane': [ + 'escape -' + ], # Alt-minus (mnemonic: Alt Minus) 'window-manager.shrink-split': ['escape ,'], # Alt-, (mnemonic: Alt <) 'window-manager.enlarge-split': ['escape .'], # Alt-. (mnemonic: Alt >) 'window-manager.focus-prev-pane': ['escape c-p'], # Ctrl-Alt-p @@ -84,6 +85,8 @@ DEFAULT_KEY_BINDINGS: Dict[str, List[str]] = { 'python-repl.copy-all-output': ['escape c-c'], 'python-repl.copy-clear-or-cancel': ['c-c'], 'python-repl.paste-to-input': ['c-v'], + 'python-repl.history-search': ['c-r'], + 'python-repl.snippet-search': ['c-t'], 'save-as-dialog.cancel': ['escape', 'c-c', 'c-d'], 'quit-dialog.no': ['escape', 'n', 'c-c'], 'quit-dialog.yes': ['y', 'c-d'], @@ -106,9 +109,11 @@ def create_key_bindings(console_app) -> KeyBindings: key_bindings = KeyBindings() register = console_app.prefs.register_keybinding - @register('global.open-user-guide', - key_bindings, - filter=Condition(lambda: not console_app.modal_window_is_open())) + @register( + 'global.open-user-guide', + key_bindings, + filter=Condition(lambda: not console_app.modal_window_is_open()), + ) def show_help(event): """Toggle user guide window.""" console_app.user_guide_window.toggle_display() @@ -116,9 +121,11 @@ def create_key_bindings(console_app) -> KeyBindings: # F2 is ptpython settings # F3 is ptpython history - @register('global.open-menu-search', - key_bindings, - filter=Condition(lambda: not console_app.modal_window_is_open())) + @register( + 'global.open-menu-search', + key_bindings, + filter=Condition(lambda: not console_app.modal_window_is_open()), + ) def show_command_runner(event): """Open command runner window.""" console_app.open_command_runner_main_menu() @@ -136,9 +143,11 @@ def create_key_bindings(console_app) -> KeyBindings: # Bindings for when the ReplPane input field is in focus. # These are hidden from help window global keyboard shortcuts since the # method names end with `_hidden`. - @register('python-repl.copy-clear-or-cancel', - key_bindings, - filter=has_focus(console_app.pw_ptpython_repl)) + @register( + 'python-repl.copy-clear-or-cancel', + key_bindings, + filter=has_focus(console_app.pw_ptpython_repl), + ) def handle_ctrl_c_hidden(event): """Reset the python repl on Ctrl-c""" console_app.repl_pane.ctrl_c() @@ -151,24 +160,47 @@ def create_key_bindings(console_app) -> KeyBindings: @register( 'global.exit-with-confirmation', key_bindings, - filter=console_app.pw_ptpython_repl.input_empty_if_in_focus_condition( - ) | has_focus(console_app.quit_dialog)) + filter=console_app.pw_ptpython_repl.input_empty_if_in_focus_condition() + | has_focus(console_app.quit_dialog), + ) def quit(event): """Quit with confirmation dialog.""" # If the python repl is in focus and has text input then Ctrl-d will # delete forward characters instead. console_app.quit_dialog.open_dialog() - @register('python-repl.paste-to-input', - key_bindings, - filter=has_focus(console_app.pw_ptpython_repl)) + @register( + 'python-repl.paste-to-input', + key_bindings, + filter=has_focus(console_app.pw_ptpython_repl), + ) def paste_into_repl(event): """Reset the python repl on Ctrl-c""" console_app.repl_pane.paste_system_clipboard_to_input_buffer() - @register('python-repl.copy-all-output', - key_bindings, - filter=console_app.repl_pane.input_or_output_has_focus()) + @register( + 'python-repl.history-search', + key_bindings, + filter=has_focus(console_app.pw_ptpython_repl), + ) + def history_search(event): + """Open the repl history search dialog.""" + console_app.open_command_runner_history() + + @register( + 'python-repl.snippet-search', + key_bindings, + filter=has_focus(console_app.pw_ptpython_repl), + ) + def insert_snippet(event): + """Open the repl snippet search dialog.""" + console_app.open_command_runner_snippets() + + @register( + 'python-repl.copy-all-output', + key_bindings, + filter=console_app.repl_pane.input_or_output_has_focus(), + ) def copy_repl_output_text(event): """Copy all Python output to the system clipboard.""" console_app.repl_pane.copy_all_output_text() diff --git a/pw_console/py/pw_console/log_filter.py b/pw_console/py/pw_console/log_filter.py index 9873a46de..60411efc1 100644 --- a/pw_console/py/pw_console/log_filter.py +++ b/pw_console/py/pw_console/log_filter.py @@ -34,6 +34,7 @@ _UPPERCASE_REGEX = re.compile(r'[A-Z]') class SearchMatcher(Enum): """Possible search match methods.""" + FUZZY = 'FUZZY' REGEX = 'REGEX' STRING = 'STRING' @@ -42,8 +43,9 @@ class SearchMatcher(Enum): DEFAULT_SEARCH_MATCHER = SearchMatcher.REGEX -def preprocess_search_regex(text, - matcher: SearchMatcher = DEFAULT_SEARCH_MATCHER): +def preprocess_search_regex( + text, matcher: SearchMatcher = DEFAULT_SEARCH_MATCHER +): # Ignorecase unless the text has capital letters in it. regex_flags = re.IGNORECASE if _UPPERCASE_REGEX.search(text): @@ -54,7 +56,8 @@ def preprocess_search_regex(text, text_tokens = text.split(' ') if len(text_tokens) > 1: text = '(.*?)'.join( - ['({})'.format(re.escape(text)) for text in text_tokens]) + ['({})'.format(re.escape(text)) for text in text_tokens] + ) elif matcher == SearchMatcher.STRING: # Escape any regex specific characters to match the string literal. text = re.escape(text) @@ -67,50 +70,55 @@ def preprocess_search_regex(text, class RegexValidator(Validator): """Validation of regex input.""" + def validate(self, document): """Check search input for regex syntax errors.""" regex_text, regex_flags = preprocess_search_regex(document.text) try: re.compile(regex_text, regex_flags) except re.error as error: - raise ValidationError(error.pos, - "Regex Error: %s" % error) from error + raise ValidationError( + error.pos, "Regex Error: %s" % error + ) from error @dataclass class LogFilter: """Log Filter Dataclass.""" + regex: re.Pattern input_text: Optional[str] = None invert: bool = False field: Optional[str] = None def pattern(self): - return self.regex.pattern + return self.regex.pattern # pylint: disable=no-member def matches(self, log: LogLine): field = log.ansi_stripped_log if self.field: if hasattr(log, 'metadata') and hasattr(log.metadata, 'fields'): - field = log.metadata.fields.get(self.field, - log.ansi_stripped_log) + field = log.metadata.fields.get( + self.field, log.ansi_stripped_log + ) if hasattr(log.record, 'extra_metadata_fields'): # type: ignore field = log.record.extra_metadata_fields.get( # type: ignore - self.field, log.ansi_stripped_log) + self.field, log.ansi_stripped_log + ) if self.field == 'lvl': field = log.record.levelname elif self.field == 'time': field = log.record.asctime - match = self.regex.search(field) + match = self.regex.search(field) # pylint: disable=no-member if self.invert: return not match return match - def highlight_search_matches(self, - line_fragments, - selected=False) -> StyleAndTextTuples: + def highlight_search_matches( + self, line_fragments, selected=False + ) -> StyleAndTextTuples: """Highlight search matches in the current line_fragment.""" line_text = fragment_list_to_text(line_fragments) exploded_fragments = explode_text_fragments(line_fragments) @@ -135,7 +143,9 @@ class LogFilter: apply_highlighting(exploded_fragments, i) else: # Highlight each non-overlapping search match. - for match in self.regex.finditer(line_text): + for match in self.regex.finditer( # pylint: disable=no-member + line_text + ): # pylint: disable=no-member for fragment_i in range(match.start(), match.end()): apply_highlighting(exploded_fragments, fragment_i) diff --git a/pw_console/py/pw_console/log_line.py b/pw_console/py/pw_console/log_line.py index 97d75f49d..0277166af 100644 --- a/pw_console/py/pw_console/log_line.py +++ b/pw_console/py/pw_console/log_line.py @@ -26,6 +26,7 @@ from pw_log_tokenized import FormatStringWithMetadata @dataclass class LogLine: """Class to hold a single log event.""" + record: logging.LogRecord formatted_log: str ansi_stripped_log: str @@ -42,9 +43,12 @@ class LogLine: """Parse log metadata fields from various sources.""" # 1. Parse any metadata from the message itself. - self.metadata = FormatStringWithMetadata(str(self.record.message)) + self.metadata = FormatStringWithMetadata( + str(self.record.message) # pylint: disable=no-member + ) # pylint: disable=no-member self.formatted_log = self.formatted_log.replace( - self.metadata.raw_string, self.metadata.message) + self.metadata.raw_string, self.metadata.message + ) # Remove any trailing line breaks. self.formatted_log = self.formatted_log.rstrip() @@ -63,8 +67,9 @@ class LogLine: # See: # https://docs.python.org/3/library/logging.html#logging.debug if hasattr(self.record, 'extra_metadata_fields') and ( - self.record.extra_metadata_fields): # type: ignore - fields = self.record.extra_metadata_fields # type: ignore + self.record.extra_metadata_fields # type: ignore # pylint: disable=no-member + ): + fields = self.record.extra_metadata_fields # type: ignore # pylint: disable=no-member for key, value in fields.items(): self.metadata.fields[key] = value @@ -89,8 +94,8 @@ class LogLine: # Create prompt_toolkit FormattedText tuples based on the log ANSI # escape sequences. if self.fragment_cache is None: - self.fragment_cache = ANSI(self.formatted_log + - '\n' # Add a trailing linebreak - ).__pt_formatted_text__() + self.fragment_cache = ANSI( + self.formatted_log + '\n' # Add a trailing linebreak + ).__pt_formatted_text__() return self.fragment_cache diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py index eb4fbfda4..c9bfa8f84 100644 --- a/pw_console/py/pw_console/log_pane.py +++ b/pw_console/py/pw_console/log_pane.py @@ -16,7 +16,16 @@ import functools import logging import re -from typing import Any, List, Optional, Union, TYPE_CHECKING +import time +from typing import ( + Any, + Callable, + List, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) from prompt_toolkit.application.current import get_app from prompt_toolkit.filters import ( @@ -33,15 +42,17 @@ from prompt_toolkit.layout import ( ConditionalContainer, Float, FloatContainer, + FormattedTextControl, + HSplit, UIContent, UIControl, VerticalAlign, + VSplit, Window, + WindowAlign, ) from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton -import pw_console.widgets.checkbox -import pw_console.style from pw_console.log_view import LogView from pw_console.log_pane_toolbars import ( LineInfoBar, @@ -52,13 +63,22 @@ from pw_console.log_pane_selection_dialog import LogPaneSelectionDialog from pw_console.log_store import LogStore from pw_console.search_toolbar import SearchToolbar from pw_console.filter_toolbar import FilterToolbar + +from pw_console.style import ( + get_pane_style, +) from pw_console.widgets import ( ToolbarButton, WindowPane, WindowPaneHSplit, WindowPaneToolbar, + create_border, + mouse_handlers, + to_checkbox_text, + to_keybind_indicator, ) + if TYPE_CHECKING: from pw_console.console_app import ConsoleApp @@ -68,6 +88,7 @@ _LOG = logging.getLogger(__package__) class LogContentControl(UIControl): """LogPane prompt_toolkit UIControl for displaying LogContainer lines.""" + def __init__(self, log_pane: 'LogPane') -> None: # pylint: disable=too-many-locals self.log_pane = log_pane @@ -114,7 +135,8 @@ class LogContentControl(UIControl): """Remove log pane.""" if self.log_pane.is_a_duplicate: self.log_pane.application.window_manager.remove_pane( - self.log_pane) + self.log_pane + ) @register('log-pane.clear-history', key_bindings) def _clear_history(_event: KeyPressEvent) -> None: @@ -136,6 +158,11 @@ class LogContentControl(UIControl): """Toggle log line following.""" self.log_pane.toggle_follow() + @register('log-pane.toggle-web-browser', key_bindings) + def _toggle_browser(_event: KeyPressEvent) -> None: + """View logs in browser.""" + self.log_pane.toggle_websocket_server() + @register('log-pane.move-cursor-up', key_bindings) def _up(_event: KeyPressEvent) -> None: """Move cursor up.""" @@ -240,9 +267,11 @@ class LogContentControl(UIControl): # Create a UIContent instance if none exists if self.uicontent is None: - self.uicontent = UIContent(get_line=lambda i: self.lines[i], - line_count=len(self.lines), - show_cursor=False) + self.uicontent = UIContent( + get_line=lambda i: self.lines[i], + line_count=len(self.lines), + show_cursor=False, + ) # Update line_count self.uicontent.line_count = len(self.lines) @@ -257,13 +286,16 @@ class LogContentControl(UIControl): # 1. check if a mouse drag just completed. # 2. If not in focus, switch focus to this log pane # If in focus, move the cursor to that position. - if (mouse_event.event_type == MouseEventType.MOUSE_UP - and mouse_event.button == MouseButton.LEFT): - + if ( + mouse_event.event_type == MouseEventType.MOUSE_UP + and mouse_event.button == MouseButton.LEFT + ): # If a drag was in progress and this is the first mouse release # press, set the stop flag. - if (self.visual_select_mode_drag_start - and not self.visual_select_mode_drag_stop): + if ( + self.visual_select_mode_drag_start + and not self.visual_select_mode_drag_stop + ): self.visual_select_mode_drag_stop = True if not has_focus(self)(): @@ -287,11 +319,15 @@ class LogContentControl(UIControl): # Mouse drag with left button should start selecting lines. # The log pane does not need to be in focus to start this. - if (mouse_event.event_type == MouseEventType.MOUSE_MOVE - and mouse_event.button == MouseButton.LEFT): + if ( + mouse_event.event_type == MouseEventType.MOUSE_MOVE + and mouse_event.button == MouseButton.LEFT + ): # If a previous mouse drag was completed, clear the selection. - if (self.visual_select_mode_drag_start - and self.visual_select_mode_drag_stop): + if ( + self.visual_select_mode_drag_start + and self.visual_select_mode_drag_stop + ): self.log_pane.log_view.clear_visual_selection() # Drag select in progress, set flags accordingly. self.visual_select_mode_drag_start = True @@ -317,6 +353,161 @@ class LogContentControl(UIControl): return NotImplemented +class LogPaneWebsocketDialog(ConditionalContainer): + """Dialog box for showing the websocket URL.""" + + # Height of the dialog box contens in lines of text. + DIALOG_HEIGHT = 2 + + def __init__(self, log_pane: 'LogPane'): + self.log_pane = log_pane + + self._last_action_message: str = '' + self._last_action_time: float = 0 + + info_bar_control = FormattedTextControl(self.get_info_fragments) + info_bar_window = Window( + content=info_bar_control, + height=1, + align=WindowAlign.LEFT, + dont_extend_width=False, + ) + + message_bar_control = FormattedTextControl(self.get_message_fragments) + message_bar_window = Window( + content=message_bar_control, + height=1, + align=WindowAlign.RIGHT, + dont_extend_width=False, + ) + + action_bar_control = FormattedTextControl(self.get_action_fragments) + action_bar_window = Window( + content=action_bar_control, + height=1, + align=WindowAlign.RIGHT, + dont_extend_width=True, + ) + + super().__init__( + create_border( + HSplit( + [ + info_bar_window, + VSplit([message_bar_window, action_bar_window]), + ], + height=LogPaneWebsocketDialog.DIALOG_HEIGHT, + style='class:saveas-dialog', + ), + content_height=LogPaneWebsocketDialog.DIALOG_HEIGHT, + title='Websocket Log Server', + border_style='class:saveas-dialog-border', + left_margin_columns=1, + ), + filter=Condition(lambda: self.log_pane.websocket_dialog_active), + ) + + def focus_self(self) -> None: + # Nothing in this dialog can be focused, focus on the parent log_pane + # instead. + self.log_pane.application.focus_on_container(self.log_pane) + + def close_dialog(self) -> None: + """Close this dialog.""" + self.log_pane.toggle_websocket_server() + self.log_pane.websocket_dialog_active = False + self.log_pane.application.focus_on_container(self.log_pane) + self.log_pane.redraw_ui() + + def _set_action_message(self, text: str) -> None: + self._last_action_time = time.time() + self._last_action_message = text + + def copy_url_to_clipboard(self) -> None: + self.log_pane.application.application.clipboard.set_text( + self.log_pane.log_view.get_web_socket_url() + ) + self._set_action_message('Copied!') + + def get_message_fragments(self): + """Return FormattedText with the last action message.""" + # Mouse handlers + focus = functools.partial(mouse_handlers.on_click, self.focus_self) + # Separator should have the focus mouse handler so clicking on any + # whitespace focuses the input field. + separator_text = ('', ' ', focus) + + if self._last_action_time + 10 > time.time(): + return [ + ('class:theme-fg-yellow', self._last_action_message, focus), + separator_text, + ] + return [separator_text] + + def get_info_fragments(self): + """Return FormattedText with current URL info.""" + # Mouse handlers + focus = functools.partial(mouse_handlers.on_click, self.focus_self) + # Separator should have the focus mouse handler so clicking on any + # whitespace focuses the input field. + separator_text = ('', ' ', focus) + + fragments = [ + ('class:saveas-dialog-setting', 'URL: ', focus), + ( + 'class:saveas-dialog-title', + self.log_pane.log_view.get_web_socket_url(), + focus, + ), + separator_text, + ] + return fragments + + def get_action_fragments(self): + """Return FormattedText with the action buttons.""" + # Mouse handlers + focus = functools.partial(mouse_handlers.on_click, self.focus_self) + cancel = functools.partial(mouse_handlers.on_click, self.close_dialog) + copy = functools.partial( + mouse_handlers.on_click, + self.copy_url_to_clipboard, + ) + + # Separator should have the focus mouse handler so clicking on any + # whitespace focuses the input field. + separator_text = ('', ' ', focus) + + # Default button style + button_style = 'class:toolbar-button-inactive' + + fragments = [] + + # Action buttons + fragments.extend( + to_keybind_indicator( + key=None, + description='Stop', + mouse_handler=cancel, + base_style=button_style, + ) + ) + + fragments.append(separator_text) + fragments.extend( + to_keybind_indicator( + key=None, + description='Copy to Clipboard', + mouse_handler=copy, + base_style=button_style, + ) + ) + + # One space separator + fragments.append(('', ' ', focus)) + + return fragments + + class LogPane(WindowPane): """LogPane class.""" @@ -336,9 +527,9 @@ class LogPane(WindowPane): self.is_a_duplicate = False # Create the log container which stores and handles incoming logs. - self.log_view: LogView = LogView(self, - self.application, - log_store=log_store) + self.log_view: LogView = LogView( + self, self.application, log_store=log_store + ) # Log pane size variables. These are updated just befor rendering the # pane by the LogLineHSplit class. @@ -356,35 +547,60 @@ class LogPane(WindowPane): self.saveas_dialog_active = False self.visual_selection_dialog = LogPaneSelectionDialog(self) + self.websocket_dialog = LogPaneWebsocketDialog(self) + self.websocket_dialog_active = False + # Table header bar, only shown if table view is active. self.table_header_toolbar = TableToolbar(self) # Create the bottom toolbar for the whole log pane. self.bottom_toolbar = WindowPaneToolbar(self) self.bottom_toolbar.add_button( - ToolbarButton('/', 'Search', self.start_search)) + ToolbarButton('/', 'Search', self.start_search) + ) self.bottom_toolbar.add_button( - ToolbarButton('Ctrl-o', 'Save', self.start_saveas)) + ToolbarButton('Ctrl-o', 'Save', self.start_saveas) + ) self.bottom_toolbar.add_button( - ToolbarButton('f', - 'Follow', - self.toggle_follow, - is_checkbox=True, - checked=lambda: self.log_view.follow)) + ToolbarButton( + 'f', + 'Follow', + self.toggle_follow, + is_checkbox=True, + checked=lambda: self.log_view.follow, + ) + ) self.bottom_toolbar.add_button( - ToolbarButton('t', - 'Table', - self.toggle_table_view, - is_checkbox=True, - checked=lambda: self.table_view)) + ToolbarButton( + 't', + 'Table', + self.toggle_table_view, + is_checkbox=True, + checked=lambda: self.table_view, + ) + ) self.bottom_toolbar.add_button( - ToolbarButton('w', - 'Wrap', - self.toggle_wrap_lines, - is_checkbox=True, - checked=lambda: self.wrap_lines)) + ToolbarButton( + 'w', + 'Wrap', + self.toggle_wrap_lines, + is_checkbox=True, + checked=lambda: self.wrap_lines, + ) + ) self.bottom_toolbar.add_button( - ToolbarButton('C', 'Clear', self.clear_history)) + ToolbarButton('C', 'Clear', self.clear_history) + ) + + self.bottom_toolbar.add_button( + ToolbarButton( + 'Shift-o', + 'Open in browser', + self.toggle_websocket_server, + is_checkbox=True, + checked=lambda: self.log_view.websocket_running, + ) + ) self.log_content_control = LogContentControl(self) @@ -406,7 +622,7 @@ class LogPane(WindowPane): dont_extend_width=False, # Needed for log lines ANSI sequences that don't specify foreground # or background colors. - style=functools.partial(pw_console.style.get_pane_style, self), + style=functools.partial(get_pane_style, self), ) # Root level container @@ -426,25 +642,39 @@ class LogPane(WindowPane): align=VerticalAlign.BOTTOM, height=lambda: self.height, width=lambda: self.width, - style=functools.partial(pw_console.style.get_pane_style, - self), + style=functools.partial(get_pane_style, self), ), floats=[ Float(top=0, right=0, height=1, content=LineInfoBar(self)), - Float(top=0, - right=0, - height=LogPaneSelectionDialog.DIALOG_HEIGHT, - content=self.visual_selection_dialog), - Float(top=3, - left=2, - right=2, - height=LogPaneSaveAsDialog.DIALOG_HEIGHT + 2, - content=self.saveas_dialog), - ]), - filter=Condition(lambda: self.show_pane)) + Float( + top=0, + right=0, + height=LogPaneSelectionDialog.DIALOG_HEIGHT, + content=self.visual_selection_dialog, + ), + Float( + top=3, + left=2, + right=2, + height=LogPaneSaveAsDialog.DIALOG_HEIGHT + 2, + content=self.saveas_dialog, + ), + Float( + top=1, + left=2, + right=2, + height=LogPaneWebsocketDialog.DIALOG_HEIGHT + 2, + content=self.websocket_dialog, + ), + ], + ), + filter=Condition(lambda: self.show_pane), + ) @property def table_view(self): + if self.log_view.websocket_running: + return False return self._table_view @table_view.setter @@ -458,10 +688,12 @@ class LogPane(WindowPane): # List active filters if self.log_view.filtering_on: title += ' (FILTERS: ' - title += ' '.join([ - log_filter.pattern() - for log_filter in self.log_view.filters.values() - ]) + title += ' '.join( + [ + log_filter.pattern() + for log_filter in self.log_view.filters.values() + ] + ) title += ')' return title @@ -483,6 +715,8 @@ class LogPane(WindowPane): def start_search(self): """Show the search bar to begin a search.""" + if self.log_view.websocket_running: + return # Show the search bar self.search_bar_active = True # Focus on the search bar @@ -500,8 +734,10 @@ class LogPane(WindowPane): def pane_resized(self) -> bool: """Return True if the current window size has changed.""" - return (self.last_log_pane_width != self.current_log_pane_width - or self.last_log_pane_height != self.current_log_pane_height) + return ( + self.last_log_pane_width != self.current_log_pane_width + or self.last_log_pane_height != self.current_log_pane_height + ) def update_pane_size(self, width, height): """Save width and height of the log pane for the current UI render @@ -543,12 +779,26 @@ class LogPane(WindowPane): self.log_view.clear_scrollback() self.redraw_ui() + def toggle_websocket_server(self): + """Start or stop websocket server to send logs.""" + if self.log_view.websocket_running: + self.log_view.stop_websocket_thread() + self.websocket_dialog_active = False + else: + self.search_toolbar.close_search_bar() + self.log_view.start_websocket_thread() + self.application.start_http_server() + self.saveas_dialog_active = False + self.websocket_dialog_active = True + def get_all_key_bindings(self) -> List: """Return all keybinds for this pane.""" # Return log content control keybindings return [self.log_content_control.get_key_bindings()] - def get_all_menu_options(self) -> List: + def get_window_menu_options( + self, + ) -> List[Tuple[str, Union[Callable, None]]]: """Return all menu options for the log pane.""" options = [ @@ -561,22 +811,30 @@ class LogPane(WindowPane): ('-', None), ( '{check} Line wrapping'.format( - check=pw_console.widgets.checkbox.to_checkbox_text( - self.wrap_lines, end='')), + check=to_checkbox_text(self.wrap_lines, end='') + ), self.toggle_wrap_lines, ), ( '{check} Table view'.format( - check=pw_console.widgets.checkbox.to_checkbox_text( - self._table_view, end='')), + check=to_checkbox_text(self._table_view, end='') + ), self.toggle_table_view, ), ( '{check} Follow'.format( - check=pw_console.widgets.checkbox.to_checkbox_text( - self.log_view.follow, end='')), + check=to_checkbox_text(self.log_view.follow, end='') + ), self.toggle_follow, ), + ( + '{check} Open in web browser'.format( + check=to_checkbox_text( + self.log_view.websocket_running, end='' + ) + ), + self.toggle_websocket_server, + ), # Menu separator ('-', None), ( @@ -589,11 +847,14 @@ class LogPane(WindowPane): ), ] if self.is_a_duplicate: - options += [( - 'Remove/Delete pane', - functools.partial(self.application.window_manager.remove_pane, - self), - )] + options += [ + ( + 'Remove/Delete pane', + functools.partial( + self.application.window_manager.remove_pane, self + ), + ) + ] # Search / Filter section options += [ @@ -626,14 +887,17 @@ class LogPane(WindowPane): if field == 'all': field = None if self.log_view.new_search( - search_string, - invert=inverted, - field=field, - search_matcher=matcher_name, - interactive=False, + search_string, + invert=inverted, + field=field, + search_matcher=matcher_name, + interactive=False, ): self.log_view.install_new_filter() + # Trigger any existing log messages to be added to the view. + self.log_view.new_logs_arrived() + def create_duplicate(self) -> 'LogPane': """Create a duplicate of this LogView.""" new_pane = LogPane(self.application, pane_title=self.pane_title()) @@ -658,9 +922,11 @@ class LogPane(WindowPane): # Add the new pane. self.application.window_manager.add_pane(new_pane) - def add_log_handler(self, - logger: Union[str, logging.Logger], - level_name: Optional[str] = None) -> None: + def add_log_handler( + self, + logger: Union[str, logging.Logger], + level_name: Optional[str] = None, + ) -> None: """Add a log handlers to this LogPane.""" if isinstance(logger, logging.Logger): @@ -672,7 +938,5 @@ class LogPane(WindowPane): if not hasattr(logging, level_name): raise Exception(f'Unknown log level: {level_name}') logger_instance.level = getattr(logging, level_name, logging.INFO) - logger_instance.addHandler(self.log_view.log_store # type: ignore - ) - self.append_pane_subtitle( # type: ignore - logger_instance.name) + logger_instance.addHandler(self.log_view.log_store) # type: ignore + self.append_pane_subtitle(logger_instance.name) # type: ignore diff --git a/pw_console/py/pw_console/log_pane_saveas_dialog.py b/pw_console/py/pw_console/log_pane_saveas_dialog.py index f142a8af8..b3adb090f 100644 --- a/pw_console/py/pw_console/log_pane_saveas_dialog.py +++ b/pw_console/py/pw_console/log_pane_saveas_dialog.py @@ -36,10 +36,12 @@ from prompt_toolkit.validation import ( Validator, ) -import pw_console.widgets.checkbox -import pw_console.widgets.border -import pw_console.widgets.mouse_handlers -import pw_console.style +from pw_console.widgets import ( + create_border, + mouse_handlers, + to_checkbox_with_keybind_indicator, + to_keybind_indicator, +) if TYPE_CHECKING: from pw_console.log_pane import LogPane @@ -47,6 +49,7 @@ if TYPE_CHECKING: class PathValidator(Validator): """Validation of file path input.""" + def validate(self, document): """Check input path leads to a valid parent directory.""" target_path = Path(document.text).expanduser() @@ -55,17 +58,20 @@ class PathValidator(Validator): raise ValidationError( # Set cursor position to the end len(document.text), - "Directory doesn't exist: %s" % document.text) + "Directory doesn't exist: %s" % document.text, + ) if target_path.is_dir(): raise ValidationError( # Set cursor position to the end len(document.text), - "File input is an existing directory: %s" % document.text) + "File input is an existing directory: %s" % document.text, + ) class LogPaneSaveAsDialog(ConditionalContainer): """Dialog box for saving logs to a file.""" + # Height of the dialog box contens in lines of text. DIALOG_HEIGHT = 3 @@ -81,9 +87,14 @@ class LogPaneSaveAsDialog(ConditionalContainer): self.input_field = TextArea( prompt=[ - ('class:saveas-dialog-setting', 'File: ', - functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self)) + ( + 'class:saveas-dialog-setting', + 'File: ', + functools.partial( + mouse_handlers.on_click, + self.focus_self, + ), + ) ], # Pre-fill the current working directory. text=self.starting_file_path, @@ -102,18 +113,21 @@ class LogPaneSaveAsDialog(ConditionalContainer): self.input_field.buffer.cursor_position = len(self.starting_file_path) - settings_bar_control = FormattedTextControl( - self.get_settings_fragments) - settings_bar_window = Window(content=settings_bar_control, - height=1, - align=WindowAlign.LEFT, - dont_extend_width=False) + settings_bar_control = FormattedTextControl(self.get_settings_fragments) + settings_bar_window = Window( + content=settings_bar_control, + height=1, + align=WindowAlign.LEFT, + dont_extend_width=False, + ) action_bar_control = FormattedTextControl(self.get_action_fragments) - action_bar_window = Window(content=action_bar_control, - height=1, - align=WindowAlign.RIGHT, - dont_extend_width=False) + action_bar_window = Window( + content=action_bar_control, + height=1, + align=WindowAlign.RIGHT, + dont_extend_width=False, + ) # Add additional keybindings for the input_field text area. key_bindings = KeyBindings() @@ -127,7 +141,7 @@ class LogPaneSaveAsDialog(ConditionalContainer): self.input_field.control.key_bindings = key_bindings super().__init__( - pw_console.widgets.border.create_border( + create_border( HSplit( [ settings_bar_window, @@ -155,15 +169,19 @@ class LogPaneSaveAsDialog(ConditionalContainer): def _toggle_table_formatting(self): self._export_with_table_formatting = ( - not self._export_with_table_formatting) + not self._export_with_table_formatting + ) def _toggle_selected_lines(self): self._export_with_selected_lines_only = ( - not self._export_with_selected_lines_only) + not self._export_with_selected_lines_only + ) - def set_export_options(self, - table_format: Optional[bool] = None, - selected_lines_only: Optional[bool] = None) -> None: + def set_export_options( + self, + table_format: Optional[bool] = None, + selected_lines_only: Optional[bool] = None, + ) -> None: # Allows external callers such as the line selection dialog to set # export format options. if table_format is not None: @@ -187,9 +205,10 @@ class LogPaneSaveAsDialog(ConditionalContainer): return False if self.log_pane.log_view.export_logs( - file_name=input_text, - use_table_formatting=self._export_with_table_formatting, - selected_lines_only=self._export_with_selected_lines_only): + file_name=input_text, + use_table_formatting=self._export_with_table_formatting, + selected_lines_only=self._export_with_selected_lines_only, + ): self.close_dialog() # Reset selected_lines_only self.set_export_options(selected_lines_only=False) @@ -202,14 +221,15 @@ class LogPaneSaveAsDialog(ConditionalContainer): def get_settings_fragments(self): """Return FormattedText with current save settings.""" # Mouse handlers - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self) + focus = functools.partial(mouse_handlers.on_click, self.focus_self) toggle_table_formatting = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self._toggle_table_formatting) + mouse_handlers.on_click, + self._toggle_table_formatting, + ) toggle_selected_lines = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self._toggle_selected_lines) + mouse_handlers.on_click, + self._toggle_selected_lines, + ) # Separator should have the focus mouse handler so clicking on any # whitespace focuses the input field. @@ -223,24 +243,28 @@ class LogPaneSaveAsDialog(ConditionalContainer): # Table checkbox fragments.extend( - pw_console.widgets.checkbox.to_checkbox_with_keybind_indicator( + to_checkbox_with_keybind_indicator( checked=self._export_with_table_formatting, key='', # No key shortcut help text description='Table Formatting', mouse_handler=toggle_table_formatting, - base_style=button_style)) + base_style=button_style, + ) + ) # Two space separator fragments.append(separator_text) # Selected lines checkbox fragments.extend( - pw_console.widgets.checkbox.to_checkbox_with_keybind_indicator( + to_checkbox_with_keybind_indicator( checked=self._export_with_selected_lines_only, key='', # No key shortcut help text description='Selected Lines Only', mouse_handler=toggle_selected_lines, - base_style=button_style)) + base_style=button_style, + ) + ) # Two space separator fragments.append(separator_text) @@ -250,12 +274,9 @@ class LogPaneSaveAsDialog(ConditionalContainer): def get_action_fragments(self): """Return FormattedText with the save action buttons.""" # Mouse handlers - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self) - cancel = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.close_dialog) - save = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.save_action) + focus = functools.partial(mouse_handlers.on_click, self.focus_self) + cancel = functools.partial(mouse_handlers.on_click, self.close_dialog) + save = functools.partial(mouse_handlers.on_click, self.save_action) # Separator should have the focus mouse handler so clicking on any # whitespace focuses the input field. @@ -267,24 +288,26 @@ class LogPaneSaveAsDialog(ConditionalContainer): fragments = [separator_text] # Cancel button fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='Ctrl-c', description='Cancel', mouse_handler=cancel, base_style=button_style, - )) + ) + ) # Two space separator fragments.append(separator_text) # Save button fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='Enter', description='Save', mouse_handler=save, base_style=button_style, - )) + ) + ) # One space separator fragments.append(('', ' ', focus)) diff --git a/pw_console/py/pw_console/log_pane_selection_dialog.py b/pw_console/py/pw_console/log_pane_selection_dialog.py index 9c76f5131..ec0b1c9b7 100644 --- a/pw_console/py/pw_console/log_pane_selection_dialog.py +++ b/pw_console/py/pw_console/log_pane_selection_dialog.py @@ -25,10 +25,12 @@ from prompt_toolkit.layout import ( WindowAlign, ) -import pw_console.style -import pw_console.widgets.checkbox -import pw_console.widgets.border -import pw_console.widgets.mouse_handlers +from pw_console.widgets import ( + create_border, + mouse_handlers, + to_checkbox_with_keybind_indicator, + to_keybind_indicator, +) if TYPE_CHECKING: from pw_console.log_pane import LogPane @@ -40,6 +42,7 @@ class LogPaneSelectionDialog(ConditionalContainer): Displays number of lines selected, buttons for copying to the clipboar or saving to a file, and buttons to select all or cancel (clear) the selection.""" + # Height of the dialog box contens in lines of text. DIALOG_HEIGHT = 3 @@ -51,14 +54,16 @@ class LogPaneSelectionDialog(ConditionalContainer): self._table_flag: bool = True selection_bar_control = FormattedTextControl(self.get_fragments) - selection_bar_window = Window(content=selection_bar_control, - height=1, - align=WindowAlign.LEFT, - dont_extend_width=False, - style='class:selection-dialog') + selection_bar_window = Window( + content=selection_bar_control, + height=1, + align=WindowAlign.LEFT, + dont_extend_width=False, + style='class:selection-dialog', + ) super().__init__( - pw_console.widgets.border.create_border( + create_border( selection_bar_window, (LogPaneSelectionDialog.DIALOG_HEIGHT - 1), border_style='class:selection-dialog-border', @@ -66,7 +71,8 @@ class LogPaneSelectionDialog(ConditionalContainer): top=False, right=False, ), - filter=Condition(lambda: self.log_view.visual_select_mode)) + filter=Condition(lambda: self.log_view.visual_select_mode), + ) def focus_log_pane(self): self.log_pane.application.focus_on_container(self.log_pane) @@ -85,65 +91,82 @@ class LogPaneSelectionDialog(ConditionalContainer): def _copy_selection(self) -> None: if self.log_view.export_logs( - to_clipboard=True, - use_table_formatting=self._table_flag, - selected_lines_only=True, - add_markdown_fence=self._markdown_flag, + to_clipboard=True, + use_table_formatting=self._table_flag, + selected_lines_only=True, + add_markdown_fence=self._markdown_flag, ): self._select_none() def _saveas_file(self) -> None: - self.log_pane.start_saveas(table_format=self._table_flag, - selected_lines_only=True) + self.log_pane.start_saveas( + table_format=self._table_flag, selected_lines_only=True + ) def get_fragments(self): """Return formatted text tuples for both rows of the selection dialog.""" - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_log_pane) + focus = functools.partial(mouse_handlers.on_click, self.focus_log_pane) one_space = ('', ' ', focus) two_spaces = ('', ' ', focus) select_all = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._select_all) + mouse_handlers.on_click, self._select_all + ) select_none = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._select_none) + mouse_handlers.on_click, self._select_none + ) copy_selection = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._copy_selection) + mouse_handlers.on_click, self._copy_selection + ) saveas_file = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._saveas_file) + mouse_handlers.on_click, self._saveas_file + ) toggle_markdown = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self._toggle_markdown_flag) + mouse_handlers.on_click, + self._toggle_markdown_flag, + ) toggle_table = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self._toggle_table_flag) + mouse_handlers.on_click, self._toggle_table_flag + ) button_style = 'class:toolbar-button-inactive' # First row of text - fragments = [('class:selection-dialog-title', ' {} Selected '.format( - self.log_view.visual_selected_log_count()), focus), one_space, - ('class:selection-dialog-default-fg', 'Format: ', focus)] + fragments = [ + ( + 'class:selection-dialog-title', + ' {} Selected '.format( + self.log_view.visual_selected_log_count() + ), + focus, + ), + one_space, + ('class:selection-dialog-default-fg', 'Format: ', focus), + ] # Table and Markdown options fragments.extend( - pw_console.widgets.checkbox.to_checkbox_with_keybind_indicator( + to_checkbox_with_keybind_indicator( self._table_flag, key='', description='Table', mouse_handler=toggle_table, - base_style='class:selection-dialog-default-bg')) + base_style='class:selection-dialog-default-bg', + ) + ) fragments.extend( - pw_console.widgets.checkbox.to_checkbox_with_keybind_indicator( + to_checkbox_with_keybind_indicator( self._markdown_flag, key='', description='Markdown', mouse_handler=toggle_markdown, - base_style='class:selection-dialog-default-bg')) + base_style='class:selection-dialog-default-bg', + ) + ) # Line break fragments.append(('', '\n')) @@ -152,40 +175,44 @@ class LogPaneSelectionDialog(ConditionalContainer): fragments.append(one_space) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='Ctrl-c', description='Cancel', mouse_handler=select_none, base_style=button_style, - )) + ) + ) fragments.append(two_spaces) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='Ctrl-a', description='Select All', mouse_handler=select_all, base_style=button_style, - )) + ) + ) fragments.append(two_spaces) fragments.append(one_space) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='', description='Save as File', mouse_handler=saveas_file, base_style=button_style, - )) + ) + ) fragments.append(two_spaces) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='', description='Copy', mouse_handler=copy_selection, base_style=button_style, - )) + ) + ) fragments.append(one_space) return fragments diff --git a/pw_console/py/pw_console/log_pane_toolbars.py b/pw_console/py/pw_console/log_pane_toolbars.py index 038b9530f..35778f4a1 100644 --- a/pw_console/py/pw_console/log_pane_toolbars.py +++ b/pw_console/py/pw_console/log_pane_toolbars.py @@ -27,9 +27,7 @@ from prompt_toolkit.layout import ( HorizontalAlign, ) -import pw_console.widgets.checkbox -import pw_console.widgets.mouse_handlers -import pw_console.style +from pw_console.style import get_toolbar_style if TYPE_CHECKING: from pw_console.log_pane import LogPane @@ -37,6 +35,7 @@ if TYPE_CHECKING: class LineInfoBar(ConditionalContainer): """One line bar for showing current and total log lines.""" + def get_tokens(self): """Return formatted text tokens for display.""" tokens = ' {} / {} '.format( @@ -48,30 +47,37 @@ class LineInfoBar(ConditionalContainer): def __init__(self, log_pane: 'LogPane'): self.log_pane = log_pane info_bar_control = FormattedTextControl(self.get_tokens) - info_bar_window = Window(content=info_bar_control, - align=WindowAlign.RIGHT, - dont_extend_width=True) + info_bar_window = Window( + content=info_bar_control, + align=WindowAlign.RIGHT, + dont_extend_width=True, + ) super().__init__( - VSplit([info_bar_window], - height=1, - style=functools.partial(pw_console.style.get_toolbar_style, - self.log_pane, - dim=True), - align=HorizontalAlign.RIGHT), + VSplit( + [info_bar_window], + height=1, + style=functools.partial( + get_toolbar_style, self.log_pane, dim=True + ), + align=HorizontalAlign.RIGHT, + ), # Only show current/total line info if not auto-following # logs. Similar to tmux behavior. - filter=Condition(lambda: not self.log_pane.log_view.follow)) + filter=Condition(lambda: not self.log_pane.log_view.follow), + ) class TableToolbar(ConditionalContainer): """One line toolbar for showing table headers.""" + TOOLBAR_HEIGHT = 1 def __init__(self, log_pane: 'LogPane'): # FormattedText of the table column headers. table_header_bar_control = FormattedTextControl( - log_pane.log_view.render_table_header) + log_pane.log_view.render_table_header + ) # Left justify the header content. table_header_bar_window = Window( content=table_header_bar_control, @@ -79,11 +85,14 @@ class TableToolbar(ConditionalContainer): dont_extend_width=False, ) super().__init__( - VSplit([table_header_bar_window], - height=1, - style=functools.partial(pw_console.style.get_toolbar_style, - log_pane, - dim=True), - align=HorizontalAlign.LEFT), - filter=Condition(lambda: log_pane.table_view and log_pane.log_view. - get_total_count() > 0)) + VSplit( + [table_header_bar_window], + height=1, + style=functools.partial(get_toolbar_style, log_pane, dim=True), + align=HorizontalAlign.LEFT, + ), + filter=Condition( + lambda: log_pane.table_view + and log_pane.log_view.get_total_count() > 0 + ), + ) diff --git a/pw_console/py/pw_console/log_screen.py b/pw_console/py/pw_console/log_screen.py index e3a0f0583..2e0d5f26d 100644 --- a/pw_console/py/pw_console/log_screen.py +++ b/pw_console/py/pw_console/log_screen.py @@ -81,6 +81,7 @@ class ScreenLine: logs. The subline is 0 since each line is the first one for this log. Both have a height of 1 since no line wrapping was performed. """ + # The StyleAndTextTuples for this line ending with a '\n'. These are the raw # prompt_toolkit formatted text tuples to display on screen. The colors and # spacing can change depending on the formatters used in the @@ -125,11 +126,13 @@ class LogScreen: It is responsible for moving the cursor_position, prepending and appending log lines as the user moves the cursor.""" + # Callable functions to retrieve logs and display formatting. get_log_source: Callable[[], Tuple[int, collections.deque[LogLine]]] get_line_wrapping: Callable[[], bool] - get_log_formatter: Callable[[], Optional[Callable[[LogLine], - StyleAndTextTuples]]] + get_log_formatter: Callable[ + [], Optional[Callable[[LogLine], StyleAndTextTuples]] + ] get_search_filter: Callable[[], Optional[LogFilter]] get_search_highlight: Callable[[], bool] @@ -144,7 +147,8 @@ class LogScreen: # wrapping to be displayed it will be represented by multiple ScreenLine # instances in this deque. line_buffer: collections.deque[ScreenLine] = dataclasses.field( - default_factory=collections.deque) + default_factory=collections.deque + ) def __post_init__(self) -> None: # Empty screen flag. Will be true if the screen contains only newlines. @@ -188,8 +192,9 @@ class LogScreen: # 6 the range below will be: # >>> list(i for i in range((10 - 6) + 1, 10 + 1)) # [5, 6, 7, 8, 9, 10] - for i in range((log_index - max_log_messages_to_fetch) + 1, - log_index + 1): + for i in range( + (log_index - max_log_messages_to_fetch) + 1, log_index + 1 + ): # If i is < 0 it's an invalid log, skip to the next line. The next # index could be 0 or higher since we are traversing in increasing # order. @@ -223,11 +228,12 @@ class LogScreen: # Loop through a copy of the line_buffer in case it is mutated before # this function is complete. for i, line in enumerate(list(self.line_buffer)): - # Is this line the cursor_position? Apply line highlighting - if (i == self.cursor_position - and (self.cursor_position < len(self.line_buffer)) - and not self.line_buffer[self.cursor_position].empty()): + if ( + i == self.cursor_position + and (self.cursor_position < len(self.line_buffer)) + and not self.line_buffer[self.cursor_position].empty() + ): # Fill in empty charaters to the width of the screen. This # ensures the backgound is highlighted to the edge of the # screen. @@ -239,10 +245,13 @@ class LogScreen: # Apply a style to highlight this line. all_lines.append( - to_formatted_text(new_fragments, - style='class:selected-log-line')) + to_formatted_text( + new_fragments, style='class:selected-log-line' + ) + ) elif line.log_index is not None and ( - marked_logs_start <= line.log_index <= marked_logs_end): + marked_logs_start <= line.log_index <= marked_logs_end + ): new_fragments = fill_character_width( line.fragments, len(line.fragments) - 1, # -1 for the ending line break @@ -251,8 +260,10 @@ class LogScreen: # Apply a style to highlight this line. all_lines.append( - to_formatted_text(new_fragments, - style='class:marked-log-line')) + to_formatted_text( + new_fragments, style='class:marked-log-line' + ) + ) else: all_lines.append(line.fragments) @@ -301,8 +312,10 @@ class LogScreen: new_index = self.cursor_position - 1 if new_index < 0: break - if (new_index < len(self.line_buffer) - and self.line_buffer[new_index].empty()): + if ( + new_index < len(self.line_buffer) + and self.line_buffer[new_index].empty() + ): # The next line is empty and has no content. break self.cursor_position -= 1 @@ -324,8 +337,10 @@ class LogScreen: new_index = self.cursor_position + 1 if new_index >= self.height: break - if (new_index < len(self.line_buffer) - and self.line_buffer[new_index].empty()): + if ( + new_index < len(self.line_buffer) + and self.line_buffer[new_index].empty() + ): # The next line is empty and has no content. break self.cursor_position += 1 @@ -374,8 +389,9 @@ class LogScreen: remaining_lines = self.scroll_subline(amount) if remaining_lines != 0 and current_line.log_index is not None: # Restore original selected line. - self._move_selection_to_log(current_line.log_index, - current_line.subline) + self._move_selection_to_log( + current_line.log_index, current_line.subline + ) return # Lines scrolled as expected, set cursor_position to top. self.cursor_position = 0 @@ -398,13 +414,14 @@ class LogScreen: remaining_lines = self.scroll_subline(amount) if remaining_lines != 0 and current_line.log_index is not None: # Restore original selected line. - self._move_selection_to_log(current_line.log_index, - current_line.subline) + self._move_selection_to_log( + current_line.log_index, current_line.subline + ) return # Lines scrolled as expected, set cursor_position to center. self.cursor_position -= amount - self.cursor_position -= (current_line.height - 1) + self.cursor_position -= current_line.height - 1 def scroll_subline(self, line_count: int = 1) -> int: """Move the cursor down or up by positive or negative lines. @@ -475,8 +492,10 @@ class LogScreen: def get_line_at_cursor_position(self) -> ScreenLine: """Returns the ScreenLine under the cursor.""" - if (self.cursor_position >= len(self.line_buffer) - or self.cursor_position < 0): + if ( + self.cursor_position >= len(self.line_buffer) + or self.cursor_position < 0 + ): return ScreenLine([('', '')]) return self.line_buffer[self.cursor_position] @@ -545,8 +564,9 @@ class LogScreen: log_index = self.line_buffer[-1].log_index return log_index - def _get_fragments_per_line(self, - log_index: int) -> List[StyleAndTextTuples]: + def _get_fragments_per_line( + self, log_index: int + ) -> List[StyleAndTextTuples]: """Return a list of lines wrapped to the screen width for a log. Before fetching the log message this function updates the log_source and @@ -575,7 +595,8 @@ class LogScreen: line_fragments, _log_line_height = insert_linebreaks( fragments, max_line_width=self.width, - truncate_long_lines=truncate_lines) + truncate_long_lines=truncate_lines, + ) # Convert the existing flattened fragments to a list of lines. fragments_per_line = split_lines(line_fragments) @@ -599,15 +620,16 @@ class LogScreen: fragments_per_line = self._get_fragments_per_line(log_index) # Target the last subline if the subline arg is set to -1. - fetch_last_subline = (subline == -1) + fetch_last_subline = subline == -1 for line_index, line in enumerate(fragments_per_line): # If we are looking for a specific subline and this isn't it, skip. if subline is not None: # If subline is set to -1 we need to append the last subline of # this log message. Skip this line if it isn't the last one. - if fetch_last_subline and (line_index != - len(fragments_per_line) - 1): + if fetch_last_subline and ( + line_index != len(fragments_per_line) - 1 + ): continue # If subline is not -1 (0 or higher) and this isn't the desired # line, skip to the next one. @@ -620,7 +642,8 @@ class LogScreen: log_index=log_index, subline=line_index, height=len(fragments_per_line), - )) + ) + ) # Remove lines from the bottom if over the screen height. if len(self.line_buffer) > self.height: @@ -647,7 +670,8 @@ class LogScreen: log_index=log_index, subline=line_index, height=len(fragments_per_line), - )) + ) + ) # Remove lines from the top if over the screen height. if len(self.line_buffer) > self.height: diff --git a/pw_console/py/pw_console/log_store.py b/pw_console/py/pw_console/log_store.py index ed6ebb447..8a8c4e83c 100644 --- a/pw_console/py/pw_console/log_store.py +++ b/pw_console/py/pw_console/log_store.py @@ -16,15 +16,14 @@ from __future__ import annotations import collections import logging -import sys from datetime import datetime from typing import Dict, List, Optional, TYPE_CHECKING -import pw_cli.color +from pw_cli.color import colors as pw_cli_colors from pw_console.console_prefs import ConsolePrefs from pw_console.log_line import LogLine -import pw_console.text_formatting +from pw_console.text_formatting import strip_ansi from pw_console.widgets.table import TableView if TYPE_CHECKING: @@ -66,7 +65,7 @@ class LogStore(logging.Handler): loggers={ 'Host Logs': [ logging.getLogger(__package__), - logging.getLogger(__file__), + logging.getLogger(__name__), ], # Set the LogStore as the value of this logger window. 'Device Logs': device_log_store, @@ -77,23 +76,21 @@ class LogStore(logging.Handler): console.setup_python_logging() console.embed() """ + def __init__(self, prefs: Optional[ConsolePrefs] = None): """Initializes the LogStore instance.""" # ConsolePrefs may not be passed on init. For example, if the user is # creating a LogStore to capture log messages before console startup. if not prefs: - prefs = ConsolePrefs(project_file=False, - project_user_file=False, - user_file=False) + prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) self.prefs = prefs # Log storage deque for fast addition and deletion from the beginning # and end of the iterable. self.logs: collections.deque = collections.deque() - # Estimate of the logs in memory. - self.byte_size: int = 0 - # Only allow this many log lines in memory. self.max_history_size: int = 1000000 @@ -130,7 +127,7 @@ class LogStore(logging.Handler): def set_formatting(self) -> None: """Setup log formatting.""" # Copy of pw_cli log formatter - colors = pw_cli.color.colors(True) + colors = pw_cli_colors(True) timestamp_prefix = colors.black_on_white('%(asctime)s') + ' ' timestamp_format = '%Y%m%d %H:%M:%S' format_string = timestamp_prefix + '%(levelname)s %(message)s' @@ -146,16 +143,15 @@ class LogStore(logging.Handler): def clear_logs(self): """Erase all stored pane lines.""" self.logs = collections.deque() - self.byte_size = 0 self.channel_counts = {} self.channel_formatted_prefix_widths = {} self.line_index = 0 def get_channel_counts(self): """Return the seen channel log counts for this conatiner.""" - return ', '.join([ - f'{name}: {count}' for name, count in self.channel_counts.items() - ]) + return ', '.join( + [f'{name}: {count}' for name, count in self.channel_counts.items()] + ) def get_total_count(self): """Total size of the logs store.""" @@ -171,10 +167,12 @@ class LogStore(logging.Handler): """Save the formatted prefix width if this is a new logger channel name.""" if self.formatter and ( - record.name - not in self.channel_formatted_prefix_widths.keys()): + record.name not in self.channel_formatted_prefix_widths.keys() + ): # Find the width of the formatted timestamp and level - format_string = self.formatter._fmt # pylint: disable=protected-access + format_string = ( + self.formatter._fmt # pylint: disable=protected-access + ) # There may not be a _fmt defined. if not format_string: @@ -182,42 +180,51 @@ class LogStore(logging.Handler): format_without_message = format_string.replace('%(message)s', '') # If any other style parameters are left, get the width of them. - if (format_without_message and 'asctime' in format_without_message - and 'levelname' in format_without_message): + if ( + format_without_message + and 'asctime' in format_without_message + and 'levelname' in format_without_message + ): formatted_time_and_level = format_without_message % dict( - asctime=record.asctime, levelname=record.levelname) + asctime=record.asctime, levelname=record.levelname + ) # Delete ANSI escape sequences. - ansi_stripped_time_and_level = ( - pw_console.text_formatting.strip_ansi( - formatted_time_and_level)) + ansi_stripped_time_and_level = strip_ansi( + formatted_time_and_level + ) self.channel_formatted_prefix_widths[record.name] = len( - ansi_stripped_time_and_level) + ansi_stripped_time_and_level + ) else: self.channel_formatted_prefix_widths[record.name] = 0 # Set the max width of all known formats so far. self.longest_channel_prefix_width = max( - self.channel_formatted_prefix_widths.values()) + self.channel_formatted_prefix_widths.values() + ) def _append_log(self, record: logging.LogRecord): """Add a new log event.""" # Format incoming log line. formatted_log = self.format(record) - ansi_stripped_log = pw_console.text_formatting.strip_ansi( - formatted_log) + ansi_stripped_log = strip_ansi(formatted_log) # Save this log. self.logs.append( - LogLine(record=record, - formatted_log=formatted_log, - ansi_stripped_log=ansi_stripped_log)) + LogLine( + record=record, + formatted_log=formatted_log, + ansi_stripped_log=ansi_stripped_log, + ) + ) # Increment this logger count - self.channel_counts[record.name] = self.channel_counts.get( - record.name, 0) + 1 + self.channel_counts[record.name] = ( + self.channel_counts.get(record.name, 0) + 1 + ) - # TODO(pwbug/614): Revisit calculating prefix widths automatically when - # line wrapping indentation is supported. + # TODO(b/235271486): Revisit calculating prefix widths automatically + # when line wrapping indentation is supported. # Set the prefix width to 0 self.channel_formatted_prefix_widths[record.name] = 0 @@ -227,12 +234,6 @@ class LogStore(logging.Handler): # Check for bigger column widths. self.table.update_metadata_column_widths(self.logs[-1]) - # Update estimated byte_size. - self.byte_size += sys.getsizeof(self.logs[-1]) - # If the total log lines is > max_history_size, delete the oldest line. - if self.get_total_count() > self.max_history_size: - self.byte_size -= sys.getsizeof(self.logs.popleft()) - def emit(self, record) -> None: """Process a new log record. diff --git a/pw_console/py/pw_console/log_view.py b/pw_console/py/pw_console/log_view.py index 2b54f9eb2..74ebd3d30 100644 --- a/pw_console/py/pw_console/log_view.py +++ b/pw_console/py/pw_console/log_view.py @@ -19,15 +19,17 @@ import collections import copy from enum import Enum import itertools +import json import logging import operator from pathlib import Path import re -import time +from threading import Thread from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING from prompt_toolkit.data_structures import Point from prompt_toolkit.formatted_text import StyleAndTextTuples +import websockets from pw_console.log_filter import ( DEFAULT_SEARCH_MATCHER, @@ -38,6 +40,7 @@ from pw_console.log_filter import ( ) from pw_console.log_screen import ScreenLine, LogScreen from pw_console.log_store import LogStore +from pw_console.python_logging import log_record_to_json from pw_console.text_formatting import remove_formatting if TYPE_CHECKING: @@ -50,6 +53,7 @@ _LOG = logging.getLogger(__package__) class FollowEvent(Enum): """Follow mode scroll event types.""" + SEARCH_MATCH = 'scroll_to_bottom' STICKY_FOLLOW = 'scroll_to_bottom_with_sticky_follow' @@ -67,8 +71,9 @@ class LogView: ): # Parent LogPane reference. Updated by calling `set_log_pane()`. self.log_pane = log_pane - self.log_store = log_store if log_store else LogStore( - prefs=application.prefs) + self.log_store = ( + log_store if log_store else LogStore(prefs=application.prefs) + ) self.log_store.set_prefs(application.prefs) self.log_store.register_viewer(self) @@ -108,7 +113,8 @@ class LogView: # Filter self.filtering_on: bool = False self.filters: 'collections.OrderedDict[str, LogFilter]' = ( - collections.OrderedDict()) + collections.OrderedDict() + ) self.filtered_logs: collections.deque = collections.deque() self.filter_existing_logs_task: Optional[asyncio.Task] = None @@ -128,12 +134,10 @@ class LogView: self._reset_log_screen_on_next_render: bool = True self._user_scroll_event: bool = False - # Max frequency in seconds of prompt_toolkit UI redraws triggered by new - # log lines. - self._ui_update_frequency = 0.05 - self._last_ui_update_time = time.time() self._last_log_store_index = 0 self._new_logs_since_last_render = True + self._new_logs_since_last_websocket_serve = True + self._last_served_websocket_index = -1 # Should new log lines be tailed? self.follow: bool = True @@ -143,6 +147,78 @@ class LogView: # Cache of formatted text tuples used in the last UI render. self._line_fragment_cache: List[StyleAndTextTuples] = [] + # websocket server variables + self.websocket_running: bool = False + self.websocket_server = None + self.websocket_port = None + self.websocket_loop = asyncio.new_event_loop() + + # Check if any logs are already in the log_store and update the view. + self.new_logs_arrived() + + def _websocket_thread_entry(self): + """Entry point for the user code thread.""" + asyncio.set_event_loop(self.websocket_loop) + self.websocket_server = websockets.serve( # type: ignore # pylint: disable=no-member + self._send_logs_over_websockets, '127.0.0.1' + ) + self.websocket_loop.run_until_complete(self.websocket_server) + self.websocket_port = self.websocket_server.ws_server.sockets[ + 0 + ].getsockname()[1] + self.log_pane.application.application.clipboard.set_text( + self.get_web_socket_url() + ) + self.websocket_running = True + self.websocket_loop.run_forever() + + def start_websocket_thread(self): + """Create a thread for running user code so the UI isn't blocked.""" + thread = Thread( + target=self._websocket_thread_entry, args=(), daemon=True + ) + thread.start() + + def stop_websocket_thread(self): + """Stop websocket server.""" + if self.websocket_running: + self.websocket_loop.call_soon_threadsafe(self.websocket_loop.stop) + self.websocket_server = None + self.websocket_port = None + self.websocket_running = False + if self.filtering_on: + self._restart_filtering() + + async def _send_logs_over_websockets(self, websocket, _path) -> None: + formatter: Callable[[LogLine], str] = operator.attrgetter( + 'ansi_stripped_log' + ) + formatter = lambda log: log_record_to_json(log.record) + + theme_colors = json.dumps( + self.log_pane.application.prefs.pw_console_color_config() + ) + # Send colors + await websocket.send(theme_colors) + + while True: + # Wait for new logs + if not self._new_logs_since_last_websocket_serve: + await asyncio.sleep(0.5) + + _start_log_index, log_source = self._get_log_lines() + log_index_range = range( + self._last_served_websocket_index + 1, self.get_total_count() + ) + + for i in log_index_range: + log_text = formatter(log_source[i]) + await websocket.send(log_text) + self._last_served_websocket_index = i + + # Flag that all logs have been served. + self._new_logs_since_last_websocket_serve = False + def view_mode_changed(self) -> None: self._reset_log_screen_on_next_render = True @@ -232,16 +308,15 @@ class LogView: self._set_match_position(i) return - def set_search_regex(self, - text, - invert, - field, - matcher: Optional[SearchMatcher] = None) -> bool: + def set_search_regex( + self, text, invert, field, matcher: Optional[SearchMatcher] = None + ) -> bool: search_matcher = matcher if matcher else self.search_matcher _LOG.debug(search_matcher) regex_text, regex_flags = preprocess_search_regex( - text, matcher=search_matcher) + text, matcher=search_matcher + ) try: compiled_regex = re.compile(regex_text, regex_flags) @@ -271,8 +346,10 @@ class LogView: """Start a new search for the given text.""" valid_matchers = list(s.name for s in SearchMatcher) selected_matcher: Optional[SearchMatcher] = None - if (search_matcher is not None - and search_matcher.upper() in valid_matchers): + if ( + search_matcher is not None + and search_matcher.upper() in valid_matchers + ): selected_matcher = SearchMatcher(search_matcher.upper()) if not self.set_search_regex(text, invert, field, selected_matcher): @@ -284,7 +361,8 @@ class LogView: if interactive: # Start count historical search matches task. self.search_match_count_task = asyncio.create_task( - self.count_search_matches()) + self.count_search_matches() + ) # Default search direction when hitting enter in the search bar. if interactive: @@ -299,7 +377,8 @@ class LogView: # Save this log_index and its match number. log_index: match_number for match_number, log_index in enumerate( - sorted(self.search_matched_lines.keys())) + sorted(self.search_matched_lines.keys()) + ) } def disable_search_highlighting(self): @@ -317,7 +396,8 @@ class LogView: # Start filtering existing log lines. self.filter_existing_logs_task = asyncio.create_task( - self.filter_past_logs()) + self.filter_past_logs() + ) # Reset existing search self.clear_search() @@ -339,6 +419,8 @@ class LogView: def apply_filter(self): """Set new filter and schedule historical log filter asyncio task.""" + if self.websocket_running: + return self.install_new_filter() self._restart_filtering() @@ -363,7 +445,8 @@ class LogView: _, logs = self._get_log_lines() if self._scrollback_start_index > 0: return collections.deque( - itertools.islice(logs, self.hidden_line_count(), len(logs))) + itertools.islice(logs, self.hidden_line_count(), len(logs)) + ) return logs def _get_table_formatter(self) -> Optional[Callable]: @@ -392,7 +475,8 @@ class LogView: self.clear_search() self.filtering_on = False self.filters: 'collections.OrderedDict[str, re.Pattern]' = ( - collections.OrderedDict()) + collections.OrderedDict() + ) self.filtered_logs.clear() # Reset scrollback start self._scrollback_start_index = 0 @@ -415,7 +499,7 @@ class LogView: self.save_search_matched_line(i) # Pause every 100 lines or so if i % 100 == 0: - await asyncio.sleep(.1) + await asyncio.sleep(0.1) async def filter_past_logs(self): """Filter past log lines.""" @@ -431,7 +515,7 @@ class LogView: # TODO(tonymd): Tune these values. # Pause every 100 lines or so if i % 100 == 0: - await asyncio.sleep(.1) + await asyncio.sleep(0.1) def set_log_pane(self, log_pane: 'LogPane'): """Set the parent LogPane instance.""" @@ -449,8 +533,11 @@ class LogView: def get_total_count(self): """Total size of the logs store.""" - return (len(self.filtered_logs) - if self.filtering_on else self.log_store.get_total_count()) + return ( + len(self.filtered_logs) + if self.filtering_on + else self.log_store.get_total_count() + ) def get_last_log_index(self): total = self.get_total_count() @@ -531,24 +618,19 @@ class LogView: self._last_log_store_index = latest_total self._new_logs_since_last_render = True + self._new_logs_since_last_websocket_serve = True if self.follow: # Set the follow event flag for the next render_content call. self.follow_event = FollowEvent.STICKY_FOLLOW - # Trigger a UI update - self._update_prompt_toolkit_ui() - - def _update_prompt_toolkit_ui(self): - """Update Prompt Toolkit UI if a certain amount of time has passed.""" - emit_time = time.time() - # Has enough time passed since last UI redraw? - if emit_time > self._last_ui_update_time + self._ui_update_frequency: - # Update last log time - self._last_ui_update_time = emit_time + if self.websocket_running: + # No terminal screen redraws are required. + return - # Trigger Prompt Toolkit UI redraw. - self.log_pane.application.redraw_ui() + # Trigger a UI update if the log window is visible. + if self.log_pane.show_pane: + self.log_pane.application.logs_redraw() def get_cursor_position(self) -> Point: """Return the position of the cursor.""" @@ -656,9 +738,9 @@ class LogView: # Select the new line self.visual_select_line(self.get_cursor_position(), autoscroll=False) - def visual_select_line(self, - mouse_position: Point, - autoscroll: bool = True) -> None: + def visual_select_line( + self, mouse_position: Point, autoscroll: bool = True + ) -> None: """Mark the log under mouse_position as visually selected.""" # Check mouse_position is valid if not 0 <= mouse_position.y < len(self.log_screen.line_buffer): @@ -728,14 +810,19 @@ class LogView: self.scroll(-1 * lines) def log_start_end_indexes_changed(self) -> bool: - return (self._last_start_index != self._current_start_index - or self._last_end_index != self._current_end_index) + return ( + self._last_start_index != self._current_start_index + or self._last_end_index != self._current_end_index + ) def render_table_header(self): """Get pre-formatted table header.""" return self.log_store.render_table_header() - def render_content(self) -> list: + def get_web_socket_url(self): + return f'http://127.0.0.1:3000/#ws={self.websocket_port}' + + def render_content(self) -> List: """Return logs to display on screen as a list of FormattedText tuples. This function determines when the log screen requires re-rendeing based @@ -745,6 +832,10 @@ class LogView: """ screen_update_needed = False + # Disable rendering if user is viewing logs on web + if self.websocket_running: + return [] + # Check window size if self.log_pane.pane_resized(): self._window_width = self.log_pane.current_log_pane_width @@ -753,8 +844,10 @@ class LogView: self._reset_log_screen_on_next_render = True if self.follow_event is not None: - if (self.follow_event == FollowEvent.SEARCH_MATCH - and self.last_search_matched_log): + if ( + self.follow_event == FollowEvent.SEARCH_MATCH + and self.last_search_matched_log + ): self.log_index = self.last_search_matched_log self.last_search_matched_log = None self._reset_log_screen_on_next_render = True @@ -780,14 +873,16 @@ class LogView: last_rendered_log_index = self.log_screen.last_appended_log_index # If so many logs have arrived than can fit on the screen, redraw # the whole screen from the new position. - if (current_log_index - - last_rendered_log_index) > self.log_screen.height: + if ( + current_log_index - last_rendered_log_index + ) > self.log_screen.height: self.log_screen.reset_logs(log_index=self.log_index) # A small amount of logs have arrived, append them one at a time # without redrawing the whole screen. else: - for i in range(last_rendered_log_index + 1, - current_log_index + 1): + for i in range( + last_rendered_log_index + 1, current_log_index + 1 + ): self.log_screen.append_log(i) screen_update_needed = True @@ -814,22 +909,29 @@ class LogView: selected_lines_only: bool = False, ) -> str: """Convert all or selected log messages to plaintext.""" + def get_table_string(log: LogLine) -> str: return remove_formatting(self.log_store.table.formatted_row(log)) - formatter: Callable[[LogLine], - str] = operator.attrgetter('ansi_stripped_log') + formatter: Callable[[LogLine], str] = operator.attrgetter( + 'ansi_stripped_log' + ) if use_table_formatting: formatter = get_table_string _start_log_index, log_source = self._get_log_lines() - log_index_range = range(self._scrollback_start_index, - self.get_total_count()) - if (selected_lines_only and self.marked_logs_start is not None - and self.marked_logs_end is not None): - log_index_range = range(self.marked_logs_start, - self.marked_logs_end + 1) + log_index_range = range( + self._scrollback_start_index, self.get_total_count() + ) + if ( + selected_lines_only + and self.marked_logs_start is not None + and self.marked_logs_end is not None + ): + log_index_range = range( + self.marked_logs_start, self.marked_logs_end + 1 + ) text_output = '' for i in log_index_range: @@ -849,8 +951,9 @@ class LogView: add_markdown_fence: bool = False, ) -> bool: """Export log lines to file or clipboard.""" - text_output = self._logs_to_text(use_table_formatting, - selected_lines_only) + text_output = self._logs_to_text( + use_table_formatting, selected_lines_only + ) if file_name: target_path = Path(file_name).expanduser() @@ -862,7 +965,8 @@ class LogView: if add_markdown_fence: text_output = '```\n' + text_output + '```\n' self.log_pane.application.application.clipboard.set_text( - text_output) + text_output + ) _LOG.debug('Copied logs to clipboard.') return True diff --git a/pw_console/py/pw_console/pigweed_code_style.py b/pw_console/py/pw_console/pigweed_code_style.py index 325cac47a..a21cd6829 100644 --- a/pw_console/py/pw_console/pigweed_code_style.py +++ b/pw_console/py/pw_console/pigweed_code_style.py @@ -13,37 +13,93 @@ # the License. """Brighter PigweedCode pygments style.""" -import copy import re from prompt_toolkit.styles.style_transformation import get_opposite_color from pygments.style import Style # type: ignore -from pygments.token import Comment, Keyword, Name, Generic # type: ignore -from pygments_style_dracula.dracula import DraculaStyle # type: ignore - -_style_list = copy.copy(DraculaStyle.styles) - -# Darker Prompt -_style_list[Generic.Prompt] = '#bfbfbf' -# Lighter comments -_style_list[Comment] = '#778899' -_style_list[Comment.Hashbang] = '#778899' -_style_list[Comment.Multiline] = '#778899' -_style_list[Comment.Preproc] = '#ff79c6' -_style_list[Comment.Single] = '#778899' -_style_list[Comment.Special] = '#778899' -# Lighter output -_style_list[Generic.Output] = '#f8f8f2' -_style_list[Generic.Emph] = '#f8f8f2' -# Remove 'italic' from these -_style_list[Keyword.Declaration] = '#8be9fd' -_style_list[Name.Builtin] = '#8be9fd' -_style_list[Name.Label] = '#8be9fd' -_style_list[Name.Variable] = '#8be9fd' -_style_list[Name.Variable.Class] = '#8be9fd' -_style_list[Name.Variable.Global] = '#8be9fd' -_style_list[Name.Variable.Instance] = '#8be9fd' +from pygments.token import Token # type: ignore + +_style_list = { + Token.Comment: '#778899', # Lighter comments + Token.Comment.Hashbang: '#778899', + Token.Comment.Multiline: '#778899', + Token.Comment.Preproc: '#ff79c6', + Token.Comment.PreprocFile: '', + Token.Comment.Single: '#778899', + Token.Comment.Special: '#778899', + Token.Error: '#f8f8f2', + Token.Escape: '', + Token.Generic.Deleted: '#8b080b', + Token.Generic.Emph: '#f8f8f2', + Token.Generic.Error: '#f8f8f2', + Token.Generic.Heading: '#f8f8f2 bold', + Token.Generic.Inserted: '#f8f8f2 bold', + Token.Generic.Output: '#f8f8f2', + Token.Generic.Prompt: '#bfbfbf', # Darker Prompt + Token.Generic.Strong: '#f8f8f2', + Token.Generic.Subheading: '#f8f8f2 bold', + Token.Generic.Traceback: '#f8f8f2', + Token.Generic: '#f8f8f2', + Token.Keyword.Constant: '#ff79c6', + Token.Keyword.Declaration: '#8be9fd', + Token.Keyword.Namespace: '#ff79c6', + Token.Keyword.Pseudo: '#ff79c6', + Token.Keyword.Reserved: '#ff79c6', + Token.Keyword.Type: '#8be9fd', + Token.Keyword: '#ff79c6', + Token.Literal.Date: '#f8f8f2', + Token.Literal.Number.Bin: '#bd93f9', + Token.Literal.Number.Float: '#bd93f9', + Token.Literal.Number.Hex: '#bd93f9', + Token.Literal.Number.Integer.Long: '#bd93f9', + Token.Literal.Number.Integer: '#bd93f9', + Token.Literal.Number.Oct: '#bd93f9', + Token.Literal.Number: '#bd93f9', + Token.Literal.String.Affix: '', + Token.Literal.String.Backtick: '#f1fa8c', + Token.Literal.String.Char: '#f1fa8c', + Token.Literal.String.Delimiter: '', + Token.Literal.String.Doc: '#f1fa8c', + Token.Literal.String.Double: '#f1fa8c', + Token.Literal.String.Escape: '#f1fa8c', + Token.Literal.String.Heredoc: '#f1fa8c', + Token.Literal.String.Interpol: '#f1fa8c', + Token.Literal.String.Other: '#f1fa8c', + Token.Literal.String.Regex: '#f1fa8c', + Token.Literal.String.Single: '#f1fa8c', + Token.Literal.String.Symbol: '#f1fa8c', + Token.Literal.String: '#f1fa8c', + Token.Literal: '#f8f8f2', + Token.Name.Attribute: '#50fa7b', + Token.Name.Builtin: '#8be9fd', + Token.Name.Builtin.Pseudo: '#f8f8f2', + Token.Name.Class: '#50fa7b', + Token.Name.Constant: '#f8f8f2', + Token.Name.Decorator: '#f8f8f2', + Token.Name.Entity: '#f8f8f2', + Token.Name.Exception: '#f8f8f2', + Token.Name.Function.Magic: '', + Token.Name.Function: '#50fa7b', + Token.Name.Label: '#8be9fd', + Token.Name.Namespace: '#f8f8f2', + Token.Name.Other: '#f8f8f2', + Token.Name.Property: '', + Token.Name.Tag: '#ff79c6', + Token.Name.Variable: '#8be9fd', + Token.Name.Variable.Class: '#8be9fd', + Token.Name.Variable.Global: '#8be9fd', + Token.Name.Variable.Instance: '#8be9fd', + Token.Name.Variable.Magic: '', + Token.Name: '#f8f8f2', + Token.Operator.Word: '#ff79c6', + Token.Operator: '#ff79c6', + Token.Other: '#f8f8f2', + Token.Punctuation: '#f8f8f2', + Token.Text.Whitespace: '#f8f8f2', + Token.Text: '#f8f8f2', + Token: '', +} _COLOR_REGEX = re.compile(r'#(?P<hex>[0-9a-fA-F]{6}) *(?P<rest>.*?)$') @@ -63,7 +119,6 @@ def swap_light_dark(color: str) -> str: class PigweedCodeStyle(Style): - background_color = '#2e2e2e' default_style = '' @@ -71,11 +126,7 @@ class PigweedCodeStyle(Style): class PigweedCodeLightStyle(Style): - background_color = '#f8f8f8' default_style = '' - styles = { - key: swap_light_dark(value) - for key, value in _style_list.items() - } + styles = {key: swap_light_dark(value) for key, value in _style_list.items()} diff --git a/pw_console/py/pw_console/plugin_mixin.py b/pw_console/py/pw_console/plugin_mixin.py index 146108086..5413934c5 100644 --- a/pw_console/py/pw_console/plugin_mixin.py +++ b/pw_console/py/pw_console/plugin_mixin.py @@ -80,6 +80,7 @@ class PluginMixin: .. _Future: https://docs.python.org/3/library/asyncio-future.html """ + def plugin_init( self, plugin_callback: Optional[Callable[..., bool]] = None, @@ -130,7 +131,8 @@ class PluginMixin: # This function will be executed in a separate thread. self._plugin_periodically_run_callback(), # Using this asyncio event loop. - self.plugin_event_loop) # type: ignore + self.plugin_event_loop, + ) # type: ignore def plugin_stop(self): self.plugin_enable_background_task = False diff --git a/pw_console/py/pw_console/plugins/bandwidth_toolbar.py b/pw_console/py/pw_console/plugins/bandwidth_toolbar.py index e661a417f..e0135a6c5 100644 --- a/pw_console/py/pw_console/plugins/bandwidth_toolbar.py +++ b/pw_console/py/pw_console/plugins/bandwidth_toolbar.py @@ -22,6 +22,7 @@ from pw_console.pyserial_wrapper import BANDWIDTH_HISTORY_CONTEXTVAR class BandwidthToolbar(WindowPaneToolbar, PluginMixin): """Toolbar for displaying bandwidth history.""" + TOOLBAR_HEIGHT = 1 def _update_toolbar_text(self): @@ -33,18 +34,27 @@ class BandwidthToolbar(WindowPaneToolbar, PluginMixin): self.plugin_logger.debug('BandwidthToolbar _update_toolbar_text') for count_name, events in self.history.items(): - tokens.extend([ - ('', ' '), - ('class:theme-bg-active class:theme-fg-active', - ' {}: '.format(count_name.title())), - ('class:theme-bg-active class:theme-fg-cyan', - '{:.3f} '.format(events.last_count())), - ('class:theme-bg-active class:theme-fg-orange', - '{} '.format(events.display_unit_title)), - ]) + tokens.extend( + [ + ('', ' '), + ( + 'class:theme-bg-active class:theme-fg-active', + ' {}: '.format(count_name.title()), + ), + ( + 'class:theme-bg-active class:theme-fg-cyan', + '{:.3f} '.format(events.last_count()), + ), + ( + 'class:theme-bg-active class:theme-fg-orange', + '{} '.format(events.display_unit_title), + ), + ] + ) if count_name == 'total': tokens.append( - ('class:theme-fg-cyan', '{}'.format(events.sparkline()))) + ('class:theme-fg-cyan', '{}'.format(events.sparkline())) + ) self.formatted_text = tokens @@ -57,9 +67,9 @@ class BandwidthToolbar(WindowPaneToolbar, PluginMixin): return [('class:theme-fg-blue', 'Serial Bandwidth Usage ')] def __init__(self, *args, **kwargs): - super().__init__(*args, - center_section_align=WindowAlign.RIGHT, - **kwargs) + super().__init__( + *args, center_section_align=WindowAlign.RIGHT, **kwargs + ) self.history = BANDWIDTH_HISTORY_CONTEXTVAR.get() self.show_toolbar = True @@ -67,8 +77,10 @@ class BandwidthToolbar(WindowPaneToolbar, PluginMixin): # Buttons for display in the center self.add_button( - ToolbarButton(description='Refresh', - mouse_handler=self._update_toolbar_text)) + ToolbarButton( + description='Refresh', mouse_handler=self._update_toolbar_text + ) + ) # Set plugin options self.background_task_update_count: int = 0 @@ -81,6 +93,8 @@ class BandwidthToolbar(WindowPaneToolbar, PluginMixin): def _background_task(self) -> bool: self.background_task_update_count += 1 self._update_toolbar_text() - self.plugin_logger.debug('BandwidthToolbar Scheduled Update: #%s', - self.background_task_update_count) + self.plugin_logger.debug( + 'BandwidthToolbar Scheduled Update: #%s', + self.background_task_update_count, + ) return True diff --git a/pw_console/py/pw_console/plugins/calc_pane.py b/pw_console/py/pw_console/plugins/calc_pane.py index f85a60072..b316e0cbe 100644 --- a/pw_console/py/pw_console/plugins/calc_pane.py +++ b/pw_console/py/pw_console/plugins/calc_pane.py @@ -42,6 +42,7 @@ class CalcPane(WindowPane): Both input and output fields are prompt_toolkit TextArea objects which can have their own options like syntax highlighting. """ + def __init__(self): # Call WindowPane.__init__ and set the title to 'Calculator' super().__init__(pane_title='Calculator') @@ -101,14 +102,16 @@ class CalcPane(WindowPane): description='Run Calculation', # Button name # Function to run when clicked. mouse_handler=self.run_calculation, - )) + ) + ) self.bottom_toolbar.add_button( ToolbarButton( key='Ctrl-c', # Key binding for this function description='Copy Output', # Button name # Function to run when clicked. mouse_handler=self.copy_all_output, - )) + ) + ) # self.container is the root container that contains objects to be # rendered in the UI, one on top of the other. @@ -142,15 +145,15 @@ class CalcPane(WindowPane): prefs.register_named_key_function( 'calc-pane.copy-selected-text', # default bindings - ['c-c']) + ['c-c'], + ) # For setting additional keybindings to the output_field. key_bindings = KeyBindings() # Map the 'calc-pane.copy-selected-text' function keybind to the # _copy_all_output function below. This will set - @prefs.register_keybinding('calc-pane.copy-selected-text', - key_bindings) + @prefs.register_keybinding('calc-pane.copy-selected-text', key_bindings) def _copy_all_output(_event: KeyPressEvent) -> None: """Copy selected text from the output buffer.""" self.copy_selected_output() @@ -176,7 +179,8 @@ class CalcPane(WindowPane): output = "\n\nIn: {}\nOut: {}".format( self.input_field.text, # NOTE: Don't use 'eval' in real code (this is just an example) - eval(self.input_field.text)) # pylint: disable=eval-used + eval(self.input_field.text), # pylint: disable=eval-used + ) except BaseException as exception: # pylint: disable=broad-except output = "\n\n{}".format(exception) @@ -186,7 +190,8 @@ class CalcPane(WindowPane): # Update the output_field with the new contents and move the # cursor_position to the end. self.output_field.buffer.document = Document( - text=new_text, cursor_position=len(new_text)) + text=new_text, cursor_position=len(new_text) + ) def copy_selected_output(self): """Copy highlighted text in the output_field to the system clipboard.""" @@ -196,4 +201,5 @@ class CalcPane(WindowPane): def copy_all_output(self): """Copy all text in the output_field to the system clipboard.""" self.application.application.clipboard.set_text( - self.output_field.buffer.text) + self.output_field.buffer.text + ) diff --git a/pw_console/py/pw_console/plugins/clock_pane.py b/pw_console/py/pw_console/plugins/clock_pane.py index 090ac8191..397c7c314 100644 --- a/pw_console/py/pw_console/plugins/clock_pane.py +++ b/pw_console/py/pw_console/plugins/clock_pane.py @@ -41,6 +41,7 @@ class ClockControl(FormattedTextControl): This is the prompt_toolkit class that is responsible for drawing the clock, handling keybindings if in focus, and mouse input. """ + def __init__(self, clock_pane: 'ClockPane', *args, **kwargs) -> None: self.clock_pane = clock_pane @@ -110,6 +111,7 @@ class ClockPane(WindowPane, PluginMixin): For an example see: https://pigweed.dev/pw_console/embedding.html#adding-plugins """ + def __init__(self, *args, **kwargs): super().__init__(*args, pane_title='Clock', **kwargs) # Some toggle settings to change view and wrap lines. @@ -161,7 +163,8 @@ class ClockPane(WindowPane, PluginMixin): description='View Mode', # Button name # Function to run when clicked. mouse_handler=self.toggle_view_mode, - )) + ) + ) # Add a checkbox button to display if wrap_lines is enabled. self.bottom_toolbar.add_button( @@ -174,7 +177,8 @@ class ClockPane(WindowPane, PluginMixin): is_checkbox=True, # lambda that returns the state of the checkbox checked=lambda: self.wrap_lines, - )) + ) + ) # self.container is the root container that contains objects to be # rendered in the UI, one on top of the other. @@ -201,8 +205,10 @@ class ClockPane(WindowPane, PluginMixin): self.background_task_update_count += 1 # Make a log message for debugging purposes. For more info see: # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior - self.plugin_logger.debug('background_task_update_count: %s', - self.background_task_update_count) + self.plugin_logger.debug( + 'background_task_update_count: %s', + self.background_task_update_count, + ) # Returning True in the background task will force the user interface to # re-draw. @@ -231,8 +237,9 @@ class ClockPane(WindowPane, PluginMixin): # pylint: disable=no-self-use # Get the date and time - date, time = datetime.now().isoformat(sep='_', - timespec='seconds').split('_') + date, time = ( + datetime.now().isoformat(sep='_', timespec='seconds').split('_') + ) # Formatted text is represented as (style, text) tuples. # For more examples see: @@ -240,7 +247,7 @@ class ClockPane(WindowPane, PluginMixin): # These styles are selected using class names and start with the # 'class:' prefix. For all classes defined by Pigweed Console see: - # https://cs.opensource.google/pigweed/pigweed/+/main:pw_console/py/pw_console/style.py;l=189 + # https://cs.pigweed.dev/pigweed/+/main:pw_console/py/pw_console/style.py;l=189 # Date in cyan matching the current Pigweed Console theme. date_with_color = ('class:theme-fg-cyan', date) @@ -252,14 +259,16 @@ class ClockPane(WindowPane, PluginMixin): space = ('', ' ') # Concatenate the (style, text) tuples. - return FormattedText([ - line_break, - space, - space, - date_with_color, - space, - time_with_color, - ]) + return FormattedText( + [ + line_break, + space, + space, + date_with_color, + space, + time_with_color, + ] + ) def _get_example_text(self): """Examples of how to create formatted text.""" @@ -279,155 +288,178 @@ class ClockPane(WindowPane, PluginMixin): # Standard ANSI colors examples fragments.append( - FormattedText([ - # These tuples follow this format: - # (style_string, text_to_display) - ('ansiblack', 'ansiblack'), - wide_space, - ('ansired', 'ansired'), - wide_space, - ('ansigreen', 'ansigreen'), - wide_space, - ('ansiyellow', 'ansiyellow'), - wide_space, - ('ansiblue', 'ansiblue'), - wide_space, - ('ansimagenta', 'ansimagenta'), - wide_space, - ('ansicyan', 'ansicyan'), - wide_space, - ('ansigray', 'ansigray'), - wide_space, + FormattedText( + [ + # These tuples follow this format: + # (style_string, text_to_display) + ('ansiblack', 'ansiblack'), + wide_space, + ('ansired', 'ansired'), + wide_space, + ('ansigreen', 'ansigreen'), + wide_space, + ('ansiyellow', 'ansiyellow'), + wide_space, + ('ansiblue', 'ansiblue'), + wide_space, + ('ansimagenta', 'ansimagenta'), + wide_space, + ('ansicyan', 'ansicyan'), + wide_space, + ('ansigray', 'ansigray'), + wide_space, + newline, + ('ansibrightblack', 'ansibrightblack'), + space, + ('ansibrightred', 'ansibrightred'), + space, + ('ansibrightgreen', 'ansibrightgreen'), + space, + ('ansibrightyellow', 'ansibrightyellow'), + space, + ('ansibrightblue', 'ansibrightblue'), + space, + ('ansibrightmagenta', 'ansibrightmagenta'), + space, + ('ansibrightcyan', 'ansibrightcyan'), + space, + ('ansiwhite', 'ansiwhite'), + space, + ] + ) + ) + + fragments.append(HTML('\n<u>Background Colors</u>\n')) + fragments.append( + FormattedText( + [ + # Here's an example of a style that specifies both + # background and foreground colors. The background color is + # prefixed with 'bg:'. The foreground color follows that + # with no prefix. + ('bg:ansiblack ansiwhite', 'ansiblack'), + wide_space, + ('bg:ansired', 'ansired'), + wide_space, + ('bg:ansigreen', 'ansigreen'), + wide_space, + ('bg:ansiyellow', 'ansiyellow'), + wide_space, + ('bg:ansiblue ansiwhite', 'ansiblue'), + wide_space, + ('bg:ansimagenta', 'ansimagenta'), + wide_space, + ('bg:ansicyan', 'ansicyan'), + wide_space, + ('bg:ansigray', 'ansigray'), + wide_space, + ('', '\n'), + ('bg:ansibrightblack', 'ansibrightblack'), + space, + ('bg:ansibrightred', 'ansibrightred'), + space, + ('bg:ansibrightgreen', 'ansibrightgreen'), + space, + ('bg:ansibrightyellow', 'ansibrightyellow'), + space, + ('bg:ansibrightblue', 'ansibrightblue'), + space, + ('bg:ansibrightmagenta', 'ansibrightmagenta'), + space, + ('bg:ansibrightcyan', 'ansibrightcyan'), + space, + ('bg:ansiwhite', 'ansiwhite'), + space, + ] + ) + ) + + # pylint: disable=line-too-long + # These themes use Pigweed Console style classes. See full list in: + # https://cs.pigweed.dev/pigweed/+/main:pw_console/py/pw_console/style.py;l=189 + # pylint: enable=line-too-long + fragments.append(HTML('\n\n<u>Current Theme Foreground Colors</u>\n')) + fragments.append( + [ + ('class:theme-fg-red', 'class:theme-fg-red'), newline, - ('ansibrightblack', 'ansibrightblack'), - space, - ('ansibrightred', 'ansibrightred'), - space, - ('ansibrightgreen', 'ansibrightgreen'), - space, - ('ansibrightyellow', 'ansibrightyellow'), - space, - ('ansibrightblue', 'ansibrightblue'), - space, - ('ansibrightmagenta', 'ansibrightmagenta'), + ('class:theme-fg-orange', 'class:theme-fg-orange'), + newline, + ('class:theme-fg-yellow', 'class:theme-fg-yellow'), + newline, + ('class:theme-fg-green', 'class:theme-fg-green'), + newline, + ('class:theme-fg-cyan', 'class:theme-fg-cyan'), + newline, + ('class:theme-fg-blue', 'class:theme-fg-blue'), + newline, + ('class:theme-fg-purple', 'class:theme-fg-purple'), + newline, + ('class:theme-fg-magenta', 'class:theme-fg-magenta'), + newline, + ] + ) + + fragments.append(HTML('\n<u>Current Theme Background Colors</u>\n')) + fragments.append( + [ + ('class:theme-bg-red', 'class:theme-bg-red'), + newline, + ('class:theme-bg-orange', 'class:theme-bg-orange'), + newline, + ('class:theme-bg-yellow', 'class:theme-bg-yellow'), + newline, + ('class:theme-bg-green', 'class:theme-bg-green'), + newline, + ('class:theme-bg-cyan', 'class:theme-bg-cyan'), + newline, + ('class:theme-bg-blue', 'class:theme-bg-blue'), + newline, + ('class:theme-bg-purple', 'class:theme-bg-purple'), + newline, + ('class:theme-bg-magenta', 'class:theme-bg-magenta'), + newline, + ] + ) + + fragments.append(HTML('\n<u>Theme UI Colors</u>\n')) + fragments.append( + [ + ('class:theme-fg-default', 'class:theme-fg-default'), space, - ('ansibrightcyan', 'ansibrightcyan'), + ('class:theme-bg-default', 'class:theme-bg-default'), space, - ('ansiwhite', 'ansiwhite'), + ('class:theme-bg-active', 'class:theme-bg-active'), space, - ])) - - fragments.append(HTML('\n<u>Background Colors</u>\n')) - fragments.append( - FormattedText([ - # Here's an example of a style that specifies both background - # and foreground colors. The background color is prefixed with - # 'bg:'. The foreground color follows that with no prefix. - ('bg:ansiblack ansiwhite', 'ansiblack'), - wide_space, - ('bg:ansired', 'ansired'), - wide_space, - ('bg:ansigreen', 'ansigreen'), - wide_space, - ('bg:ansiyellow', 'ansiyellow'), - wide_space, - ('bg:ansiblue ansiwhite', 'ansiblue'), - wide_space, - ('bg:ansimagenta', 'ansimagenta'), - wide_space, - ('bg:ansicyan', 'ansicyan'), - wide_space, - ('bg:ansigray', 'ansigray'), - wide_space, - ('', '\n'), - ('bg:ansibrightblack', 'ansibrightblack'), + ('class:theme-fg-active', 'class:theme-fg-active'), space, - ('bg:ansibrightred', 'ansibrightred'), + ('class:theme-bg-inactive', 'class:theme-bg-inactive'), space, - ('bg:ansibrightgreen', 'ansibrightgreen'), + ('class:theme-fg-inactive', 'class:theme-fg-inactive'), + newline, + ('class:theme-fg-dim', 'class:theme-fg-dim'), space, - ('bg:ansibrightyellow', 'ansibrightyellow'), + ('class:theme-bg-dim', 'class:theme-bg-dim'), space, - ('bg:ansibrightblue', 'ansibrightblue'), + ('class:theme-bg-dialog', 'class:theme-bg-dialog'), space, - ('bg:ansibrightmagenta', 'ansibrightmagenta'), + ( + 'class:theme-bg-line-highlight', + 'class:theme-bg-line-highlight', + ), space, - ('bg:ansibrightcyan', 'ansibrightcyan'), + ( + 'class:theme-bg-button-active', + 'class:theme-bg-button-active', + ), space, - ('bg:ansiwhite', 'ansiwhite'), + ( + 'class:theme-bg-button-inactive', + 'class:theme-bg-button-inactive', + ), space, - ])) - - # These themes use Pigweed Console style classes. See full list in: - # https://cs.opensource.google/pigweed/pigweed/+/main:pw_console/py/pw_console/style.py;l=189 - fragments.append(HTML('\n\n<u>Current Theme Foreground Colors</u>\n')) - fragments.append([ - ('class:theme-fg-red', 'class:theme-fg-red'), - newline, - ('class:theme-fg-orange', 'class:theme-fg-orange'), - newline, - ('class:theme-fg-yellow', 'class:theme-fg-yellow'), - newline, - ('class:theme-fg-green', 'class:theme-fg-green'), - newline, - ('class:theme-fg-cyan', 'class:theme-fg-cyan'), - newline, - ('class:theme-fg-blue', 'class:theme-fg-blue'), - newline, - ('class:theme-fg-purple', 'class:theme-fg-purple'), - newline, - ('class:theme-fg-magenta', 'class:theme-fg-magenta'), - newline, - ]) - - fragments.append(HTML('\n<u>Current Theme Background Colors</u>\n')) - fragments.append([ - ('class:theme-bg-red', 'class:theme-bg-red'), - newline, - ('class:theme-bg-orange', 'class:theme-bg-orange'), - newline, - ('class:theme-bg-yellow', 'class:theme-bg-yellow'), - newline, - ('class:theme-bg-green', 'class:theme-bg-green'), - newline, - ('class:theme-bg-cyan', 'class:theme-bg-cyan'), - newline, - ('class:theme-bg-blue', 'class:theme-bg-blue'), - newline, - ('class:theme-bg-purple', 'class:theme-bg-purple'), - newline, - ('class:theme-bg-magenta', 'class:theme-bg-magenta'), - newline, - ]) - - fragments.append(HTML('\n<u>Theme UI Colors</u>\n')) - fragments.append([ - ('class:theme-fg-default', 'class:theme-fg-default'), - space, - ('class:theme-bg-default', 'class:theme-bg-default'), - space, - ('class:theme-bg-active', 'class:theme-bg-active'), - space, - ('class:theme-fg-active', 'class:theme-fg-active'), - space, - ('class:theme-bg-inactive', 'class:theme-bg-inactive'), - space, - ('class:theme-fg-inactive', 'class:theme-fg-inactive'), - newline, - ('class:theme-fg-dim', 'class:theme-fg-dim'), - space, - ('class:theme-bg-dim', 'class:theme-bg-dim'), - space, - ('class:theme-bg-dialog', 'class:theme-bg-dialog'), - space, - ('class:theme-bg-line-highlight', 'class:theme-bg-line-highlight'), - space, - ('class:theme-bg-button-active', 'class:theme-bg-button-active'), - space, - ('class:theme-bg-button-inactive', - 'class:theme-bg-button-inactive'), - space, - ]) + ] + ) # Return all formatted text lists merged together. return merge_formatted_text(fragments) diff --git a/pw_console/py/pw_console/plugins/twenty48_pane.py b/pw_console/py/pw_console/plugins/twenty48_pane.py new file mode 100644 index 000000000..891b2481d --- /dev/null +++ b/pw_console/py/pw_console/plugins/twenty48_pane.py @@ -0,0 +1,561 @@ +# Copyright 2022 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Example Plugin that displays some dynamic content: a game of 2048.""" + +from random import choice +from typing import Iterable, List, Tuple, TYPE_CHECKING +import time + +from prompt_toolkit.filters import has_focus +from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent +from prompt_toolkit.layout import ( + AnyContainer, + Dimension, + FormattedTextControl, + HSplit, + Window, + WindowAlign, + VSplit, +) +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.widgets import MenuItem + +from pw_console.widgets import ( + create_border, + FloatingWindowPane, + ToolbarButton, + WindowPaneToolbar, +) +from pw_console.plugin_mixin import PluginMixin +from pw_console.get_pw_console_app import get_pw_console_app + +if TYPE_CHECKING: + from pw_console.console_app import ConsoleApp + +Twenty48Cell = Tuple[int, int, int] + + +class Twenty48Game: + """2048 Game.""" + + def __init__(self) -> None: + self.colors = { + 2: 'bg:#dd6', + 4: 'bg:#da6', + 8: 'bg:#d86', + 16: 'bg:#d66', + 32: 'bg:#d6a', + 64: 'bg:#a6d', + 128: 'bg:#66d', + 256: 'bg:#68a', + 512: 'bg:#6a8', + 1024: 'bg:#6d6', + 2048: 'bg:#0f8', + 4096: 'bg:#0ff', + } + self.board: List[List[int]] + self.last_board: List[Twenty48Cell] + self.move_count: int + self.width: int = 4 + self.height: int = 4 + self.max_value: int = 0 + self.start_time: float + self.reset_game() + + def reset_game(self) -> None: + self.start_time = time.time() + self.max_value = 2 + self.move_count = 0 + self.board = [] + for _i in range(self.height): + self.board.append([0] * self.width) + self.last_board = list(self.all_cells()) + self.add_random_tiles(2) + + def stats(self) -> StyleAndTextTuples: + """Returns stats on the game in progress.""" + elapsed_time = int(time.time() - self.start_time) + minutes = int(elapsed_time / 60.0) + seconds = elapsed_time % 60 + fragments: StyleAndTextTuples = [] + fragments.append(('', '\n')) + fragments.append(('', f'Moves: {self.move_count}')) + fragments.append(('', '\n')) + fragments.append(('', 'Time: {:0>2}:{:0>2}'.format(minutes, seconds))) + fragments.append(('', '\n')) + fragments.append(('', f'Max: {self.max_value}')) + fragments.append(('', '\n\n')) + fragments.append(('', 'Press R to restart\n')) + fragments.append(('', '\n')) + fragments.append(('', 'Arrow keys to move')) + return fragments + + def __pt_formatted_text__(self) -> StyleAndTextTuples: + """Returns the game board formatted in a grid with colors.""" + fragments: StyleAndTextTuples = [] + + def print_row(row: List[int], include_number: bool = False) -> None: + fragments.append(('', ' ')) + for col in row: + style = 'class:theme-fg-default ' + if col > 0: + style = '#000 ' + style += self.colors.get(col, '') + text = ' ' * 6 + if include_number: + text = '{:^6}'.format(col) + fragments.append((style, text)) + fragments.append(('', '\n')) + + fragments.append(('', '\n')) + for row in self.board: + print_row(row) + print_row(row, include_number=True) + print_row(row) + + return fragments + + def __repr__(self) -> str: + board = '' + for row_cells in self.board: + for column in row_cells: + board += '{:^6}'.format(column) + board += '\n' + return board + + def all_cells(self) -> Iterable[Twenty48Cell]: + for row, row_cells in enumerate(self.board): + for col, cell_value in enumerate(row_cells): + yield (row, col, cell_value) + + def update_max_value(self) -> None: + for _row, _col, value in self.all_cells(): + if value > self.max_value: + self.max_value = value + + def empty_cells(self) -> Iterable[Twenty48Cell]: + for row, row_cells in enumerate(self.board): + for col, cell_value in enumerate(row_cells): + if cell_value != 0: + continue + yield (row, col, cell_value) + + def _board_changed(self) -> bool: + return self.last_board != list(self.all_cells()) + + def complete_move(self) -> None: + if not self._board_changed(): + # Move did nothing, ignore. + return + + self.update_max_value() + self.move_count += 1 + self.add_random_tiles() + self.last_board = list(self.all_cells()) + + def add_random_tiles(self, count: int = 1) -> None: + for _i in range(count): + empty_cells = list(self.empty_cells()) + if not empty_cells: + return + row, col, _value = choice(empty_cells) + self.board[row][col] = 2 + + def row(self, row_index: int) -> Iterable[Twenty48Cell]: + for col, cell_value in enumerate(self.board[row_index]): + yield (row_index, col, cell_value) + + def col(self, col_index: int) -> Iterable[Twenty48Cell]: + for row, row_cells in enumerate(self.board): + for col, cell_value in enumerate(row_cells): + if col == col_index: + yield (row, col, cell_value) + + def non_zero_row_values(self, index: int) -> Tuple[List, List]: + non_zero_values = [ + value for row, col, value in self.row(index) if value != 0 + ] + padding = [0] * (self.width - len(non_zero_values)) + return (non_zero_values, padding) + + def move_right(self) -> None: + for i in range(self.height): + non_zero_values, padding = self.non_zero_row_values(i) + self.board[i] = padding + non_zero_values + + def move_left(self) -> None: + for i in range(self.height): + non_zero_values, padding = self.non_zero_row_values(i) + self.board[i] = non_zero_values + padding + + def add_horizontal(self, reverse=False) -> None: + for i in range(self.width): + this_row = list(self.row(i)) + if reverse: + this_row = list(reversed(this_row)) + for row, col, this_cell in this_row: + if this_cell == 0 or col >= self.width - 1: + continue + next_cell = self.board[row][col + 1] + if this_cell == next_cell: + self.board[row][col] = 0 + self.board[row][col + 1] = this_cell * 2 + break + + def non_zero_col_values(self, index: int) -> Tuple[List, List]: + non_zero_values = [ + value for row, col, value in self.col(index) if value != 0 + ] + padding = [0] * (self.height - len(non_zero_values)) + return (non_zero_values, padding) + + def _set_column(self, col_index: int, values: List[int]) -> None: + for row, value in enumerate(values): + self.board[row][col_index] = value + + def add_vertical(self, reverse=False) -> None: + for i in range(self.height): + this_column = list(self.col(i)) + if reverse: + this_column = list(reversed(this_column)) + for row, col, this_cell in this_column: + if this_cell == 0 or row >= self.height - 1: + continue + next_cell = self.board[row + 1][col] + if this_cell == next_cell: + self.board[row][col] = 0 + self.board[row + 1][col] = this_cell * 2 + break + + def move_down(self) -> None: + for col_index in range(self.width): + non_zero_values, padding = self.non_zero_col_values(col_index) + self._set_column(col_index, padding + non_zero_values) + + def move_up(self) -> None: + for col_index in range(self.width): + non_zero_values, padding = self.non_zero_col_values(col_index) + self._set_column(col_index, non_zero_values + padding) + + def press_down(self) -> None: + self.move_down() + self.add_vertical(reverse=True) + self.move_down() + self.complete_move() + + def press_up(self) -> None: + self.move_up() + self.add_vertical() + self.move_up() + self.complete_move() + + def press_right(self) -> None: + self.move_right() + self.add_horizontal(reverse=True) + self.move_right() + self.complete_move() + + def press_left(self) -> None: + self.move_left() + self.add_horizontal() + self.move_left() + self.complete_move() + + +class Twenty48Control(FormattedTextControl): + """Example prompt_toolkit UIControl for displaying formatted text. + + This is the prompt_toolkit class that is responsible for drawing the 2048, + handling keybindings if in focus, and mouse input. + """ + + def __init__(self, twenty48_pane: 'Twenty48Pane', *args, **kwargs) -> None: + self.twenty48_pane = twenty48_pane + self.game = self.twenty48_pane.game + + # Set some custom key bindings to toggle the view mode and wrap lines. + key_bindings = KeyBindings() + + @key_bindings.add('R') + def _restart(_event: KeyPressEvent) -> None: + """Restart the game.""" + self.game.reset_game() + + @key_bindings.add('q') + def _quit(_event: KeyPressEvent) -> None: + """Quit the game.""" + self.twenty48_pane.close_dialog() + + @key_bindings.add('j') + @key_bindings.add('down') + def _move_down(_event: KeyPressEvent) -> None: + """Move down""" + self.game.press_down() + + @key_bindings.add('k') + @key_bindings.add('up') + def _move_up(_event: KeyPressEvent) -> None: + """Move up.""" + self.game.press_up() + + @key_bindings.add('h') + @key_bindings.add('left') + def _move_left(_event: KeyPressEvent) -> None: + """Move left.""" + self.game.press_left() + + @key_bindings.add('l') + @key_bindings.add('right') + def _move_right(_event: KeyPressEvent) -> None: + """Move right.""" + self.game.press_right() + + # Include the key_bindings keyword arg when passing to the parent class + # __init__ function. + kwargs['key_bindings'] = key_bindings + # Call the parent FormattedTextControl.__init__ + super().__init__(*args, **kwargs) + + def mouse_handler(self, mouse_event: MouseEvent): + """Mouse handler for this control.""" + # If the user clicks anywhere this function is run. + + # Mouse positions relative to this control. x is the column starting + # from the left size as zero. y is the row starting with the top as + # zero. + _click_x = mouse_event.position.x + _click_y = mouse_event.position.y + + # Mouse click behavior usually depends on if this window pane is in + # focus. If not in focus, then focus on it when left clicking. If + # already in focus then perform the action specific to this window. + + # If not in focus, change focus to this 2048 pane and do nothing else. + if not has_focus(self.twenty48_pane)(): + if mouse_event.event_type == MouseEventType.MOUSE_UP: + get_pw_console_app().focus_on_container(self.twenty48_pane) + # Mouse event handled, return None. + return None + + # If code reaches this point, this window is already in focus. + # if mouse_event.event_type == MouseEventType.MOUSE_UP: + # # Toggle the view mode. + # self.twenty48_pane.toggle_view_mode() + # # Mouse event handled, return None. + # return None + + # Mouse event not handled, return NotImplemented. + return NotImplemented + + +class Twenty48Pane(FloatingWindowPane, PluginMixin): + """Example Pigweed Console plugin to play 2048. + + The Twenty48Pane is a WindowPane based plugin that displays an interactive + game of 2048. It inherits from both WindowPane and PluginMixin. It can be + added on console startup by calling: :: + + my_console.add_window_plugin(Twenty48Pane()) + + For an example see: + https://pigweed.dev/pw_console/embedding.html#adding-plugins + """ + + def __init__(self, include_resize_handle: bool = True, **kwargs): + super().__init__( + pane_title='2048', + height=Dimension(preferred=17), + width=Dimension(preferred=50), + **kwargs, + ) + self.game = Twenty48Game() + + # Hide by default. + self.show_pane = False + + # Create a toolbar for display at the bottom of the 2048 window. It + # will show the window title and buttons. + self.bottom_toolbar = WindowPaneToolbar( + self, include_resize_handle=include_resize_handle + ) + + # Add a button to restart the game. + self.bottom_toolbar.add_button( + ToolbarButton( + key='R', # Key binding help text for this function + description='Restart', # Button name + # Function to run when clicked. + mouse_handler=self.game.reset_game, + ) + ) + # Add a button to restart the game. + self.bottom_toolbar.add_button( + ToolbarButton( + key='q', # Key binding help text for this function + description='Quit', # Button name + # Function to run when clicked. + mouse_handler=self.close_dialog, + ) + ) + + # Every FormattedTextControl object (Twenty48Control) needs to live + # inside a prompt_toolkit Window() instance. Here is where you specify + # alignment, style, and dimensions. See the prompt_toolkit docs for all + # opitons: + # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window + self.twenty48_game_window = Window( + # Set the content to a Twenty48Control instance. + content=Twenty48Control( + self, # This Twenty48Pane class + self.game, # Content from Twenty48Game.__pt_formatted_text__() + show_cursor=False, + focusable=True, + ), + # Make content left aligned + align=WindowAlign.LEFT, + # These two set to false make this window fill all available space. + dont_extend_width=True, + dont_extend_height=False, + wrap_lines=False, + width=Dimension(preferred=28), + height=Dimension(preferred=15), + ) + + self.twenty48_stats_window = Window( + content=Twenty48Control( + self, # This Twenty48Pane class + self.game.stats, # Content from Twenty48Game.stats() + show_cursor=False, + focusable=True, + ), + # Make content left aligned + align=WindowAlign.LEFT, + # These two set to false make this window fill all available space. + width=Dimension(preferred=20), + dont_extend_width=False, + dont_extend_height=False, + wrap_lines=False, + ) + + # self.container is the root container that contains objects to be + # rendered in the UI, one on top of the other. + self.container = self._create_pane_container( + create_border( + HSplit( + [ + # Vertical split content + VSplit( + [ + # Left side will show the game board. + self.twenty48_game_window, + # Stats will be shown on the right. + self.twenty48_stats_window, + ] + ), + # The bottom_toolbar is shown below the VSplit. + self.bottom_toolbar, + ] + ), + title='2048', + border_style='class:command-runner-border', + # left_margin_columns=1, + # right_margin_columns=1, + ) + ) + + self.dialog_content: List[AnyContainer] = [ + # Vertical split content + VSplit( + [ + # Left side will show the game board. + self.twenty48_game_window, + # Stats will be shown on the right. + self.twenty48_stats_window, + ] + ), + # The bottom_toolbar is shown below the VSplit. + self.bottom_toolbar, + ] + # Wrap the dialog content in a border + self.bordered_dialog_content = create_border( + HSplit(self.dialog_content), + title='2048', + border_style='class:command-runner-border', + ) + # self.container is the root container that contains objects to be + # rendered in the UI, one on top of the other. + if include_resize_handle: + self.container = self._create_pane_container(*self.dialog_content) + else: + self.container = self._create_pane_container( + self.bordered_dialog_content + ) + + # This plugin needs to run a task in the background periodically and + # uses self.plugin_init() to set which function to run, and how often. + # This is provided by PluginMixin. See the docs for more info: + # https://pigweed.dev/pw_console/plugins.html#background-tasks + self.plugin_init( + plugin_callback=self._background_task, + # Run self._background_task once per second. + plugin_callback_frequency=1.0, + plugin_logger_name='pw_console_example_2048_plugin', + ) + + def get_top_level_menus(self) -> List[MenuItem]: + def _toggle_dialog() -> None: + self.toggle_dialog() + + return [ + MenuItem( + '[2048]', + children=[ + MenuItem( + 'Example Top Level Menu', handler=None, disabled=True + ), + # Menu separator + MenuItem('-', None), + MenuItem('Show/Hide 2048 Game', handler=_toggle_dialog), + MenuItem('Restart', handler=self.game.reset_game), + ], + ), + ] + + def pw_console_init(self, app: 'ConsoleApp') -> None: + """Set the Pigweed Console application instance. + + This function is called after the Pigweed Console starts up and allows + access to the user preferences. Prefs is required for creating new + user-remappable keybinds.""" + self.application = app + + def _background_task(self) -> bool: + """Function run in the background for the ClockPane plugin.""" + # Optional: make a log message for debugging purposes. For more info + # see: + # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior + # self.plugin_logger.debug('background_task_update_count: %s', + # self.background_task_update_count) + + # Returning True in the background task will force the user interface to + # re-draw. + # Returning False means no updates required. + + if self.show_pane: + # Return true so the game clock is updated. + return True + + # Game window is hidden, don't redraw. + return False diff --git a/pw_console/py/pw_console/progress_bar/__init__.py b/pw_console/py/pw_console/progress_bar/__init__.py index 5e0fa188b..4a9a0337f 100644 --- a/pw_console/py/pw_console/progress_bar/__init__.py +++ b/pw_console/py/pw_console/progress_bar/__init__.py @@ -14,7 +14,8 @@ """Pigweed Console progress bar functions.""" from pw_console.progress_bar.progress_bar_state import TASKS_CONTEXTVAR from pw_console.progress_bar.progress_bar_task_counter import ( - ProgressBarTaskCounter) + ProgressBarTaskCounter, +) __all__ = [ 'start_progress', @@ -32,17 +33,17 @@ def start_progress(task_name: str, total: int, hide_eta=False): progress_state.tasks[task_name] = ProgressBarTaskCounter( name=task_name, total=total, - prompt_toolkit_counter=progress_state.instance(range(total), - label=task_name)) + prompt_toolkit_counter=progress_state.instance( + range(total), label=task_name + ), + ) ptc = progress_state.tasks[task_name].prompt_toolkit_counter ptc.hide_eta = hide_eta # type: ignore -def update_progress(task_name: str, - count=1, - completed=False, - canceled=False, - new_total=None): +def update_progress( + task_name: str, count=1, completed=False, canceled=False, new_total=None +): progress_state = TASKS_CONTEXTVAR.get() # The caller may not actually get canceled and will continue trying to # update after an interrupt. @@ -60,7 +61,9 @@ def update_progress(task_name: str, progress_state.tasks[task_name].update(count) # Check if all tasks are complete - if (progress_state.instance is not None - and progress_state.all_tasks_complete): + if ( + progress_state.instance is not None + and progress_state.all_tasks_complete + ): if hasattr(progress_state.instance, '__exit__'): progress_state.instance.__exit__() diff --git a/pw_console/py/pw_console/progress_bar/progress_bar_impl.py b/pw_console/py/pw_console/progress_bar/progress_bar_impl.py index fc818ede3..08dd62d55 100644 --- a/pw_console/py/pw_console/progress_bar/progress_bar_impl.py +++ b/pw_console/py/pw_console/progress_bar/progress_bar_impl.py @@ -59,7 +59,6 @@ class TextIfNotHidden(Text): ) -> AnyFormattedText: formatted_text = super().format(progress_bar, progress, width) if hasattr(progress, 'hide_eta') and progress.hide_eta: # type: ignore - formatted_text = [('', ' ' * width)] return formatted_text @@ -92,13 +91,13 @@ class TimeLeftIfNotHidden(TimeLeft): class ProgressBarImpl: """ProgressBar for rendering in an existing prompt_toolkit application.""" + def __init__( self, title: AnyFormattedText = None, formatters: Optional[Sequence[Formatter]] = None, style: Optional[BaseStyle] = None, ) -> None: - self.title = title self.formatters = formatters or create_default_formatters() self.counters: List[ProgressBarCounter[object]] = [] @@ -121,19 +120,23 @@ class ProgressBarImpl: progress_controls = [ Window( - content=_ProgressControl(self, f), # type: ignore + content=_ProgressControl(self, f, None), # type: ignore width=functools.partial(width_for_formatter, f), - ) for f in self.formatters + ) + for f in self.formatters ] - self.container = HSplit([ - title_toolbar, - VSplit( - progress_controls, - height=lambda: D(min=len(self.counters), - max=len(self.counters)), - ), - ]) + self.container = HSplit( + [ + title_toolbar, + VSplit( + progress_controls, + height=lambda: D( + min=len(self.counters), max=len(self.counters) + ), + ), + ] + ) def __pt_container__(self): return self.container @@ -162,6 +165,7 @@ class ProgressBarImpl: data, label=label, remove_when_done=remove_when_done, - total=total) + total=total, + ) self.counters.append(counter) return counter diff --git a/pw_console/py/pw_console/progress_bar/progress_bar_state.py b/pw_console/py/pw_console/progress_bar/progress_bar_state.py index de8486a59..159cc3165 100644 --- a/pw_console/py/pw_console/progress_bar/progress_bar_state.py +++ b/pw_console/py/pw_console/progress_bar/progress_bar_state.py @@ -30,17 +30,15 @@ from pw_console.progress_bar.progress_bar_impl import ( TimeLeftIfNotHidden, ) from pw_console.progress_bar.progress_bar_task_counter import ( - ProgressBarTaskCounter) + ProgressBarTaskCounter, +) from pw_console.style import generate_styles CUSTOM_FORMATTERS = [ formatters.Label(suffix=': '), formatters.Rainbow( - formatters.Bar(start='|Pigw', - end='|', - sym_a='e', - sym_b='d!', - sym_c=' ')), + formatters.Bar(start='|Pigw', end='|', sym_a='e', sym_b='d!', sym_c=' ') + ), formatters.Text(' '), formatters.Progress(), formatters.Text(' ['), @@ -67,16 +65,18 @@ class ProgressBarState: """Pigweed Console wide state for all repl progress bars. An instance of this class is intended to be a global variable.""" + tasks: Dict[str, ProgressBarTaskCounter] = field(default_factory=dict) instance: Optional[Union[ProgressBar, ProgressBarImpl]] = None def _install_sigint_handler(self) -> None: """Add ctrl-c handling if not running inside pw_console""" + def handle_sigint(_signum, _frame): # Shut down the ProgressBar prompt_toolkit application prog_bar = self.instance if prog_bar is not None and hasattr(prog_bar, '__exit__'): - prog_bar.__exit__() + prog_bar.__exit__() # pylint: disable=unnecessary-dunder-call raise KeyboardInterrupt signal.signal(signal.SIGINT, handle_sigint) @@ -85,15 +85,17 @@ class ProgressBarState: prog_bar = self.instance if not prog_bar: if prompt_toolkit_app_running(): - prog_bar = ProgressBarImpl(style=get_app_or_none().style, - formatters=CUSTOM_FORMATTERS) + prog_bar = ProgressBarImpl( + style=get_app_or_none().style, formatters=CUSTOM_FORMATTERS + ) else: self._install_sigint_handler() - prog_bar = ProgressBar(style=generate_styles(), - formatters=CUSTOM_FORMATTERS) + prog_bar = ProgressBar( + style=generate_styles(), formatters=CUSTOM_FORMATTERS + ) # Start the ProgressBar prompt_toolkit application in a separate # thread. - prog_bar.__enter__() + prog_bar.__enter__() # pylint: disable=unnecessary-dunder-call self.instance = prog_bar return self.instance @@ -103,8 +105,11 @@ class ProgressBarState: if task.completed or task.canceled: ptc = task.prompt_toolkit_counter self.tasks.pop(task_name, None) - if (self.instance and self.instance.counters - and ptc in self.instance.counters): + if ( + self.instance + and self.instance.counters + and ptc in self.instance.counters + ): self.instance.counters.remove(ptc) @property @@ -128,5 +133,6 @@ class ProgressBarState: return None -TASKS_CONTEXTVAR = (ContextVar('pw_console_progress_bar_tasks', - default=ProgressBarState())) +TASKS_CONTEXTVAR = ContextVar( + 'pw_console_progress_bar_tasks', default=ProgressBarState() +) diff --git a/pw_console/py/pw_console/progress_bar/progress_bar_task_counter.py b/pw_console/py/pw_console/progress_bar/progress_bar_task_counter.py index 43eee1920..1da0cfb31 100644 --- a/pw_console/py/pw_console/progress_bar/progress_bar_task_counter.py +++ b/pw_console/py/pw_console/progress_bar/progress_bar_task_counter.py @@ -30,6 +30,7 @@ def _redraw_ui() -> None: @dataclass class ProgressBarTaskCounter: """Class to hold a single progress bar state.""" + name: str total: int count: int = 0 diff --git a/pw_console/py/pw_console/pw_ptpython_repl.py b/pw_console/py/pw_console/pw_ptpython_repl.py index 5d68578f8..03f405424 100644 --- a/pw_console/py/pw_console/pw_ptpython_repl.py +++ b/pw_console/py/pw_console/pw_ptpython_repl.py @@ -17,8 +17,12 @@ import asyncio import functools import io import logging +import os import sys +import shlex +import subprocess from typing import Iterable, Optional, TYPE_CHECKING +from unittest.mock import patch from prompt_toolkit.buffer import Buffer from prompt_toolkit.layout.controls import BufferControl @@ -29,28 +33,61 @@ from prompt_toolkit.filters import ( to_filter, ) from ptpython.completer import ( # type: ignore - CompletePrivateAttributes, PythonCompleter, + CompletePrivateAttributes, + PythonCompleter, ) import ptpython.repl # type: ignore from ptpython.layout import ( # type: ignore - CompletionVisualisation, Dimension, + CompletionVisualisation, + Dimension, ) +import pygments.plugin -import pw_console.text_formatting +from pw_console.pigweed_code_style import ( + PigweedCodeStyle, + PigweedCodeLightStyle, +) +from pw_console.text_formatting import remove_formatting if TYPE_CHECKING: from pw_console.repl_pane import ReplPane _LOG = logging.getLogger(__package__) +_SYSTEM_COMMAND_LOG = logging.getLogger('pw_console_system_command') + +_original_find_plugin_styles = pygments.plugin.find_plugin_styles + + +def _wrapped_find_plugin_styles(): + """Patch pygment find_plugin_styles to also include Pigweed codes styles + + This allows using these themes without requiring Python entrypoints. + """ + for style in [ + ('pigweed-code', PigweedCodeStyle), + ('pigweed-code-light', PigweedCodeLightStyle), + ]: + yield style + yield from _original_find_plugin_styles() class MissingPtpythonBufferControl(Exception): """Exception for a missing ptpython BufferControl object.""" -class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-instance-attributes +def _user_input_is_a_shell_command(text: str) -> bool: + return text.startswith('!') + + +class PwPtPythonRepl( + ptpython.repl.PythonRepl +): # pylint: disable=too-many-instance-attributes """A ptpython repl class with changes to code execution and output related methods.""" + + @patch( + 'pygments.styles.find_plugin_styles', new=_wrapped_find_plugin_styles + ) def __init__( self, *args, @@ -58,7 +95,6 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst extra_completers: Optional[Iterable] = None, **ptpython_kwargs, ): - completer = None if extra_completers: # Create the default python completer used by @@ -94,7 +130,8 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst self.show_status_bar = False self.show_exit_confirmation = False self.complete_private_attributes = ( - CompletePrivateAttributes.IF_NO_PUBLIC) + CompletePrivateAttributes.IF_NO_PUBLIC + ) # Function signature that shows args, kwargs, and types under the cursor # of the input window. @@ -106,7 +143,8 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst # Turn off the completion menu in ptpython. The CompletionsMenu in # ConsoleApp.root_container will handle this. self.completion_visualisation: CompletionVisualisation = ( - CompletionVisualisation.NONE) + CompletionVisualisation.NONE + ) # Additional state variables. self.repl_pane: 'Optional[ReplPane]' = None @@ -123,7 +161,8 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst 'ptpython/layout.py#L598\n' '\n' 'The installed version of ptpython may not be compatible with' - ' pw console; please try re-running environment setup.') + ' pw console; please try re-running environment setup.' + ) try: # Fetch the Window's BufferControl object. @@ -136,8 +175,12 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst # [create_python_input_window()] + extra_body # ), ... ptpython_buffer_control = ( - self.ptpython_layout.root_container.children[0].children[0]. - children[0].content.children[0].content) + self.ptpython_layout.root_container.children[0] # type: ignore + .children[0] + .children[0] + .content.children[0] + .content + ) # This should be a BufferControl instance if not isinstance(ptpython_buffer_control, BufferControl): raise MissingPtpythonBufferControl(error_message) @@ -160,14 +203,12 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst def _save_result(self, formatted_text): """Save the last repl execution result.""" - unformatted_result = pw_console.text_formatting.remove_formatting( - formatted_text) + unformatted_result = remove_formatting(formatted_text) self._last_result = unformatted_result def _save_exception(self, formatted_text): """Save the last repl exception.""" - unformatted_result = pw_console.text_formatting.remove_formatting( - formatted_text) + unformatted_result = remove_formatting(formatted_text) self._last_exception = unformatted_result def clear_last_result(self): @@ -216,8 +257,7 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst if result_object is not None: # Use ptpython formatted results: formatted_result = self._format_result_output(result_object) - result_text = pw_console.text_formatting.remove_formatting( - formatted_result) + result_text = remove_formatting(formatted_result) # Job is finished, append the last result. self.repl_pane.append_result_to_executed_code( @@ -232,11 +272,59 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst # Rebuild output buffer. self.repl_pane.update_output_buffer( - 'pw_ptpython_repl.user_code_complete_callback') + 'pw_ptpython_repl.user_code_complete_callback' + ) # Trigger a prompt_toolkit application redraw. self.repl_pane.application.application.invalidate() + async def _run_system_command( # pylint: disable=no-self-use + self, text, stdout_proxy, _stdin_proxy + ) -> int: + """Run a shell command and print results to the repl.""" + command = shlex.split(text) + returncode = None + env = os.environ.copy() + # Force colors in Pigweed subcommands and some terminal apps. + env['PW_USE_COLOR'] = '1' + env['CLICOLOR_FORCE'] = '1' + + def _handle_output(output): + # Force tab characters to 8 spaces to prevent \t from showing in + # prompt_toolkit. + output = output.replace('\t', ' ') + # Strip some ANSI sequences that don't render. + output = output.replace('\x1b(B\x1b[m', '') + output = output.replace('\x1b[1m', '') + stdout_proxy.write(output) + _SYSTEM_COMMAND_LOG.info(output.rstrip()) + + with subprocess.Popen( + command, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + errors='replace', + ) as proc: + # Print the command + _SYSTEM_COMMAND_LOG.info('') + _SYSTEM_COMMAND_LOG.info('$ %s', text) + while returncode is None: + if not proc.stdout: + continue + + # Check for one line and update. + output = proc.stdout.readline() + _handle_output(output) + + returncode = proc.poll() + + # Print any remaining lines. + for output in proc.stdout.readlines(): + _handle_output(output) + + return returncode + async def _run_user_code(self, text, stdout_proxy, stdin_proxy): """Run user code and capture stdout+err. @@ -256,7 +344,12 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst # Run user repl code try: - result = await self.run_and_show_expression_async(text) + if _user_input_is_a_shell_command(text): + result = await self._run_system_command( + text[1:], stdout_proxy, stdin_proxy + ) + else: + result = await self.run_and_show_expression_async(text) finally: # Always restore original stdout and stderr sys.stdout = original_stdout @@ -269,7 +362,7 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst return { 'stdout': stdout_contents, 'stderr': stderr_contents, - 'result': result + 'result': result, } def _accept_handler(self, buff: Buffer) -> bool: @@ -297,23 +390,30 @@ class PwPtPythonRepl(ptpython.repl.PythonRepl): # pylint: disable=too-many-inst repl_input_text = '' # Override stdout temp_stdout.write( - 'Error: Interactive help() is not compatible with this repl.') + 'Error: Interactive help() is not compatible with this repl.' + ) + + # Pop open the system command log pane for shell commands. + if _user_input_is_a_shell_command(repl_input_text): + self.repl_pane.application.setup_command_runner_log_pane() # Execute the repl code in the the separate user_code thread loop. future = asyncio.run_coroutine_threadsafe( # This function will be executed in a separate thread. self._run_user_code(repl_input_text, temp_stdout, temp_stderr), # Using this asyncio event loop. - self.repl_pane.application.user_code_loop) # type: ignore + self.repl_pane.application.user_code_loop, + ) # type: ignore # Save the input text and future object. - self.repl_pane.append_executed_code(repl_input_text, future, - temp_stdout, - temp_stderr) # type: ignore + self.repl_pane.append_executed_code( + repl_input_text, future, temp_stdout, temp_stderr + ) # type: ignore # Run user_code_complete_callback() when done. - done_callback = functools.partial(self.user_code_complete_callback, - repl_input_text) + done_callback = functools.partial( + self.user_code_complete_callback, repl_input_text + ) future.add_done_callback(done_callback) # Rebuild the parent ReplPane output buffer. diff --git a/pw_console/py/pw_console/pyserial_wrapper.py b/pw_console/py/pw_console/pyserial_wrapper.py index ac00e0520..57c297a52 100644 --- a/pw_console/py/pw_console/pyserial_wrapper.py +++ b/pw_console/py/pw_console/pyserial_wrapper.py @@ -12,19 +12,24 @@ # License for the specific language governing permissions and limitations under # the License. """Wrapers for pyserial classes to log read and write data.""" +from __future__ import annotations from contextvars import ContextVar import logging import textwrap +from typing import TYPE_CHECKING -import serial # type: ignore +import serial from pw_console.widgets.event_count_history import EventCountHistory +if TYPE_CHECKING: + from _typeshed import ReadableBuffer + _LOG = logging.getLogger('pw_console.serial_debug_logger') -def _log_hex_strings(data, prefix=''): +def _log_hex_strings(data: bytes, prefix=''): """Create alinged hex number and character view log messages.""" # Make a list of 2 character hex number strings. hex_numbers = textwrap.wrap(data.hex(), 2) @@ -36,7 +41,7 @@ def _log_hex_strings(data, prefix=''): .replace("'>", '', 1) # Remove ' from the end .rjust(2) for b in data - ] # yapf: disable + ] # Replace non-printable bytes with dots. for i, num in enumerate(hex_numbers): @@ -46,70 +51,120 @@ def _log_hex_strings(data, prefix=''): hex_numbers_msg = ' '.join(hex_numbers) hex_chars_msg = ' '.join(hex_chars) - _LOG.debug('%s%s', - prefix, - hex_numbers_msg, - extra=dict(extra_metadata_fields={ - 'msg': hex_numbers_msg, - })) - _LOG.debug('%s%s', - prefix, - hex_chars_msg, - extra=dict(extra_metadata_fields={ - 'msg': hex_chars_msg, - })) - - -BANDWIDTH_HISTORY_CONTEXTVAR = (ContextVar('pw_console_bandwidth_history', - default={ - 'total': - EventCountHistory(interval=3), - 'read': - EventCountHistory(interval=3), - 'write': - EventCountHistory(interval=3), - })) + _LOG.debug( + '%s%s', + prefix, + hex_numbers_msg, + extra=dict( + extra_metadata_fields={ + 'msg': hex_numbers_msg, + 'view': 'hex', + } + ), + ) + _LOG.debug( + '%s%s', + prefix, + hex_chars_msg, + extra=dict( + extra_metadata_fields={ + 'msg': hex_chars_msg, + 'view': 'chars', + } + ), + ) + + +BANDWIDTH_HISTORY_CONTEXTVAR = ContextVar( + 'pw_console_bandwidth_history', + default={ + 'total': EventCountHistory(interval=3), + 'read': EventCountHistory(interval=3), + 'write': EventCountHistory(interval=3), + }, +) class SerialWithLogging(serial.Serial): # pylint: disable=too-many-ancestors """pyserial with read and write wrappers for logging.""" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.pw_bps_history = BANDWIDTH_HISTORY_CONTEXTVAR.get() - def read(self, *args, **kwargs): - data = super().read(*args, **kwargs) + def read(self, size: int = 1) -> bytes: + data = super().read(size) self.pw_bps_history['read'].log(len(data)) self.pw_bps_history['total'].log(len(data)) if len(data) > 0: prefix = 'Read %2d B: ' % len(data) - _LOG.debug('%s%s', - prefix, - data, - extra=dict(extra_metadata_fields={ - 'mode': 'Read', - 'bytes': len(data), - 'msg': str(data), - })) + _LOG.debug( + '%s%s', + prefix, + data, + extra=dict( + extra_metadata_fields={ + 'mode': 'Read', + 'bytes': len(data), + 'view': 'bytes', + 'msg': str(data), + } + ), + ) _log_hex_strings(data, prefix=prefix) + # Print individual lines + for line in data.decode( + encoding='utf-8', errors='ignore' + ).splitlines(): + _LOG.debug( + '%s', + line, + extra=dict( + extra_metadata_fields={ + 'msg': line, + 'view': 'lines', + } + ), + ) + return data - def write(self, data, *args, **kwargs): - self.pw_bps_history['write'].log(len(data)) - self.pw_bps_history['total'].log(len(data)) + def write(self, data: ReadableBuffer) -> None: + if isinstance(data, bytes) and len(data) > 0: + self.pw_bps_history['write'].log(len(data)) + self.pw_bps_history['total'].log(len(data)) - if len(data) > 0: prefix = 'Write %2d B: ' % len(data) - _LOG.debug('%s%s', - prefix, - data, - extra=dict(extra_metadata_fields={ - 'mode': 'Write', - 'bytes': len(data), - 'msg': str(data) - })) + _LOG.debug( + '%s%s', + prefix, + data, + extra=dict( + extra_metadata_fields={ + 'mode': 'Write', + 'bytes': len(data), + 'view': 'bytes', + 'msg': str(data), + } + ), + ) _log_hex_strings(data, prefix=prefix) - super().write(data, *args, **kwargs) + # Print individual lines + for line in data.decode( + encoding='utf-8', errors='ignore' + ).splitlines(): + _LOG.debug( + '%s', + line, + extra=dict( + extra_metadata_fields={ + 'msg': line, + 'view': 'lines', + } + ), + ) + + super().write(data) diff --git a/pw_console/py/pw_console/python_logging.py b/pw_console/py/pw_console/python_logging.py index 7f5d9beaa..362158909 100644 --- a/pw_console/py/pw_console/python_logging.py +++ b/pw_console/py/pw_console/python_logging.py @@ -14,10 +14,11 @@ """Python logging helper fuctions.""" import copy +from datetime import datetime +import json import logging import tempfile -from datetime import datetime -from typing import Iterable, Iterator, Optional +from typing import Any, Dict, Iterable, Iterator, Optional def all_loggers() -> Iterator[logging.Logger]: @@ -28,8 +29,9 @@ def all_loggers() -> Iterator[logging.Logger]: yield logging.getLogger(logger_name) -def create_temp_log_file(prefix: Optional[str] = None, - add_time: bool = True) -> str: +def create_temp_log_file( + prefix: Optional[str] = None, add_time: bool = True +) -> str: """Create a unique tempfile for saving logs. Example format: /tmp/pw_console_2021-05-04_151807_8hem6iyq @@ -38,23 +40,25 @@ def create_temp_log_file(prefix: Optional[str] = None, prefix = str(__package__) # Grab the current system timestamp as a string. - isotime = datetime.now().isoformat(sep='_', timespec='seconds') + isotime = datetime.now().isoformat(sep="_", timespec="seconds") # Timestamp string should not have colons in it. - isotime = isotime.replace(':', '') + isotime = isotime.replace(":", "") if add_time: - prefix += f'_{isotime}' + prefix += f"_{isotime}" log_file_name = None - with tempfile.NamedTemporaryFile(prefix=f'{prefix}_', - delete=False) as log_file: + with tempfile.NamedTemporaryFile( + prefix=f"{prefix}_", delete=False + ) as log_file: log_file_name = log_file.name return log_file_name def set_logging_last_resort_file_handler( - file_name: Optional[str] = None) -> None: + file_name: Optional[str] = None, +) -> None: log_file = file_name if file_name else create_temp_log_file() logging.lastResort = logging.FileHandler(log_file) @@ -64,13 +68,15 @@ def disable_stdout_handlers(logger: logging.Logger) -> None: for handler in copy.copy(logger.handlers): # Must use type() check here since this returns True: # isinstance(logging.FileHandler, logging.StreamHandler) - if type(handler) == logging.StreamHandler: # pylint: disable=unidiomatic-typecheck + # pylint: disable=unidiomatic-typecheck + if type(handler) == logging.StreamHandler: logger.removeHandler(handler) + # pylint: enable=unidiomatic-typecheck def setup_python_logging( last_resort_filename: Optional[str] = None, - loggers_with_no_propagation: Optional[Iterable[logging.Logger]] = None + loggers_with_no_propagation: Optional[Iterable[logging.Logger]] = None, ) -> None: """Disable log handlers for full screen prompt_toolkit applications.""" if not loggers_with_no_propagation: @@ -90,21 +96,93 @@ def setup_python_logging( # Prevent these loggers from propagating to the root logger. hidden_host_loggers = [ - 'pw_console', - 'pw_console.plugins', - + "pw_console", + "pw_console.plugins", # prompt_toolkit triggered debug log messages - 'prompt_toolkit', - 'prompt_toolkit.buffer', - 'parso.python.diff', - 'parso.cache', - 'pw_console.serial_debug_logger', + "prompt_toolkit", + "prompt_toolkit.buffer", + "parso.python.diff", + "parso.cache", + "pw_console.serial_debug_logger", ] for logger_name in hidden_host_loggers: logging.getLogger(logger_name).propagate = False # Set asyncio log level to WARNING - logging.getLogger('asyncio').setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.WARNING) # Always set DEBUG level for serial debug. - logging.getLogger('pw_console.serial_debug_logger').setLevel(logging.DEBUG) + logging.getLogger("pw_console.serial_debug_logger").setLevel(logging.DEBUG) + + +def log_record_to_json(record: logging.LogRecord) -> str: + log_dict: Dict[str, Any] = {} + log_dict["message"] = record.getMessage() + log_dict["levelno"] = record.levelno + log_dict["levelname"] = record.levelname + log_dict["args"] = record.args + + if hasattr(record, "extra_metadata_fields") and ( + record.extra_metadata_fields # type: ignore + ): + fields = record.extra_metadata_fields # type: ignore + log_dict["fields"] = {} + for key, value in fields.items(): + if key == "msg": + log_dict["message"] = value + continue + + log_dict["fields"][key] = str(value) + + return json.dumps(log_dict) + + +class JsonLogFormatter(logging.Formatter): + """Json Python logging Formatter + + Use this formatter to log pw_console messages to a file in json + format. Column values normally shown in table view will be populated in the + 'fields' key. + + Example log entry: + + .. code-block:: json + + { + "message": "System init", + "levelno": 20, + "levelname": "INF", + "args": [ + "0:00", + "pw_system ", + "System init" + ], + "fields": { + "module": "pw_system", + "file": "pw_system/init.cc", + "timestamp": "0:00" + } + } + + Example usage: + + .. code-block:: python + + import logging + import pw_console.python_logging + + _DEVICE_LOG = logging.getLogger('rpc_device') + + json_filehandler = logging.FileHandler('logs.json', encoding='utf-8') + json_filehandler.setLevel(logging.DEBUG) + json_filehandler.setFormatter( + pw_console.python_logging.JsonLogFormatter()) + _DEVICE_LOG.addHandler(json_filehandler) + + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def format(self, record: logging.LogRecord) -> str: + return log_record_to_json(record) diff --git a/pw_console/py/pw_console/quit_dialog.py b/pw_console/py/pw_console/quit_dialog.py index b466580eb..6195dd2fb 100644 --- a/pw_console/py/pw_console/quit_dialog.py +++ b/pw_console/py/pw_console/quit_dialog.py @@ -30,9 +30,11 @@ from prompt_toolkit.layout import ( WindowAlign, ) -import pw_console.widgets.border -import pw_console.widgets.checkbox -import pw_console.widgets.mouse_handlers +from pw_console.widgets import ( + create_border, + mouse_handlers, + to_keybind_indicator, +) if TYPE_CHECKING: from pw_console.console_app import ConsoleApp @@ -45,17 +47,18 @@ class QuitDialog(ConditionalContainer): DIALOG_HEIGHT = 2 - def __init__(self, - application: ConsoleApp, - on_quit: Optional[Callable] = None): + def __init__( + self, application: ConsoleApp, on_quit: Optional[Callable] = None + ): self.application = application self.show_dialog = False # Tracks the last focused container, to enable restoring focus after # closing the dialog. self.last_focused_pane = None - self.on_quit_function = (on_quit if on_quit else - self._default_on_quit_function) + self.on_quit_function = ( + on_quit if on_quit else self._default_on_quit_function + ) # Quit keybindings are active when this dialog is in focus key_bindings = KeyBindings() @@ -82,13 +85,15 @@ class QuitDialog(ConditionalContainer): get_cursor_position=lambda: Point(len(self.exit_message), 0), ) - action_bar_window = Window(content=action_bar_control, - height=QuitDialog.DIALOG_HEIGHT, - align=WindowAlign.LEFT, - dont_extend_width=False) + action_bar_window = Window( + content=action_bar_control, + height=QuitDialog.DIALOG_HEIGHT, + align=WindowAlign.LEFT, + dont_extend_width=False, + ) super().__init__( - pw_console.widgets.border.create_border( + create_border( HSplit( [action_bar_window], height=QuitDialog.DIALOG_HEIGHT, @@ -133,12 +138,11 @@ class QuitDialog(ConditionalContainer): """Return FormattedText with action buttons.""" # Mouse handlers - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self) - cancel = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.close_dialog) + focus = functools.partial(mouse_handlers.on_click, self.focus_self) + cancel = functools.partial(mouse_handlers.on_click, self.close_dialog) quit_action = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self.quit_action) + mouse_handlers.on_click, self.quit_action + ) # Separator should have the focus mouse handler so clicking on any # whitespace focuses the input field. @@ -152,24 +156,26 @@ class QuitDialog(ConditionalContainer): # Cancel button fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='n / Ctrl-c', description='Cancel', mouse_handler=cancel, base_style=button_style, - )) + ) + ) # Two space separator fragments.append(separator_text) # Save button fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='y / Ctrl-d', description='Quit', mouse_handler=quit_action, base_style=button_style, - )) + ) + ) # One space separator fragments.append(('', ' ', focus)) diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py index 10d52a5e5..20a3b9504 100644 --- a/pw_console/py/pw_console/repl_pane.py +++ b/pw_console/py/pw_console/repl_pane.py @@ -21,11 +21,14 @@ import pprint from dataclasses import dataclass from typing import ( Any, + Awaitable, Callable, Dict, List, Optional, + Tuple, TYPE_CHECKING, + Union, ) from prompt_toolkit.filters import ( @@ -46,19 +49,19 @@ from prompt_toolkit.layout import ( ) from prompt_toolkit.lexers import PygmentsLexer # type: ignore from pygments.lexers.python import PythonConsoleLexer # type: ignore + # Alternative Formatting # from IPython.lib.lexers import IPythonConsoleLexer # type: ignore from pw_console.progress_bar.progress_bar_state import TASKS_CONTEXTVAR from pw_console.pw_ptpython_repl import PwPtPythonRepl +from pw_console.style import get_pane_style from pw_console.widgets import ( ToolbarButton, WindowPane, WindowPaneHSplit, WindowPaneToolbar, ) -import pw_console.mouse -import pw_console.style if TYPE_CHECKING: from pw_console.console_app import ConsoleApp @@ -74,13 +77,15 @@ _REPL_OUTPUT_SCROLL_AMOUNT = 5 @dataclass class UserCodeExecution: """Class to hold a single user repl execution event.""" + input: str future: concurrent.futures.Future output: str stdout: str stderr: str - stdout_check_task: Optional[concurrent.futures.Future] = None + stdout_check_task: Optional[Awaitable] = None result_object: Optional[Any] = None + result_str: Optional[str] = None exception_text: Optional[str] = None @property @@ -109,7 +114,7 @@ class ReplPane(WindowPane): ) -> None: super().__init__(application, pane_title) - self.executed_code: List = [] + self.executed_code: List[UserCodeExecution] = [] self.application = application self.pw_ptpython_repl = python_repl @@ -141,9 +146,11 @@ class ReplPane(WindowPane): # Override output buffer mouse wheel scroll self.output_field.window._scroll_up = ( # type: ignore - self.scroll_output_up) + self.scroll_output_up + ) self.output_field.window._scroll_down = ( # type: ignore - self.scroll_output_down) + self.scroll_output_down + ) self.bottom_toolbar = self._create_input_toolbar() self.results_toolbar = self._create_output_toolbar() @@ -164,10 +171,14 @@ class ReplPane(WindowPane): # 2. Progress bars if any ConditionalContainer( DynamicContainer( - self.get_progress_bar_task_container), + self.get_progress_bar_task_container + ), filter=Condition( - lambda: not self.progress_state. - all_tasks_complete)), + # pylint: disable=line-too-long + lambda: not self.progress_state.all_tasks_complete + # pylint: enable=line-too-long + ), + ), # 3. Static separator toolbar. self.results_toolbar, ], @@ -188,11 +199,12 @@ class ReplPane(WindowPane): # Repl pane dimensions height=lambda: self.height, width=lambda: self.width, - style=functools.partial(pw_console.style.get_pane_style, - self), + style=functools.partial(get_pane_style, self), ), - floats=[]), - filter=Condition(lambda: self.show_pane)) + floats=[], + ), + filter=Condition(lambda: self.show_pane), + ) def toggle_wrap_output_lines(self): """Enable or disable output line wraping/truncation.""" @@ -254,11 +266,15 @@ class ReplPane(WindowPane): focus_check_container=self.pw_ptpython_repl, ) bottom_toolbar.add_button( - ToolbarButton('Ctrl-v', 'Paste', - self.paste_system_clipboard_to_input_buffer)) + ToolbarButton( + 'Ctrl-v', 'Paste', self.paste_system_clipboard_to_input_buffer + ) + ) bottom_toolbar.add_button( - ToolbarButton('Ctrl-c', 'Copy / Clear', - self.copy_or_clear_input_buffer)) + ToolbarButton( + 'Ctrl-c', 'Copy / Clear', self.copy_or_clear_input_buffer + ) + ) bottom_toolbar.add_button(ToolbarButton('Enter', 'Run', self.run_code)) bottom_toolbar.add_button(ToolbarButton('F2', 'Settings')) bottom_toolbar.add_button(ToolbarButton('F3', 'History')) @@ -273,19 +289,32 @@ class ReplPane(WindowPane): include_resize_handle=False, ) results_toolbar.add_button( - ToolbarButton(description='Wrap lines', - mouse_handler=self.toggle_wrap_output_lines, - is_checkbox=True, - checked=lambda: self.wrap_output_lines)) + ToolbarButton( + description='Wrap lines', + mouse_handler=self.toggle_wrap_output_lines, + is_checkbox=True, + checked=lambda: self.wrap_output_lines, + ) + ) results_toolbar.add_button( - ToolbarButton('Ctrl-Alt-c', 'Copy All Output', - self.copy_all_output_text)) + ToolbarButton( + 'Ctrl-Alt-c', 'Copy All Output', self.copy_all_output_text + ) + ) results_toolbar.add_button( - ToolbarButton('Ctrl-c', 'Copy Selected Text', - self.copy_output_selection)) + ToolbarButton( + 'Ctrl-c', 'Copy Selected Text', self.copy_output_selection + ) + ) results_toolbar.add_button( - ToolbarButton('Shift+Arrows / Mouse Drag', 'Select Text')) + ToolbarButton( + description='Clear', mouse_handler=self.clear_output_buffer + ) + ) + results_toolbar.add_button( + ToolbarButton('Shift+Arrows / Mouse Drag', 'Select Text') + ) return results_toolbar @@ -302,12 +331,14 @@ class ReplPane(WindowPane): def copy_all_output_text(self): """Copy all text in the Python output to the system clipboard.""" self.application.application.clipboard.set_text( - self.output_field.buffer.text) + self.output_field.buffer.text + ) def copy_all_input_text(self): """Copy all text in the Python input to the system clipboard.""" self.application.application.clipboard.set_text( - self.pw_ptpython_repl.default_buffer.text) + self.pw_ptpython_repl.default_buffer.text + ) # pylint: disable=no-self-use def get_all_key_bindings(self) -> List: @@ -316,16 +347,36 @@ class ReplPane(WindowPane): # return [load_python_bindings(self.pw_ptpython_repl)] # Hand-crafted bindings for display in the HelpWindow: - return [{ - 'Execute code': ['Enter', 'Option-Enter', 'Alt-Enter'], - 'Reverse search history': ['Ctrl-r'], - 'Erase input buffer.': ['Ctrl-c'], - 'Show settings.': ['F2'], - 'Show history.': ['F3'], - }] - - def get_all_menu_options(self): - return [] + return [ + { + 'Execute code': ['Enter', 'Option-Enter', 'Alt-Enter'], + 'Reverse search history': ['Ctrl-r'], + 'Erase input buffer.': ['Ctrl-c'], + 'Show settings.': ['F2'], + 'Show history.': ['F3'], + } + ] + + def get_window_menu_options( + self, + ) -> List[Tuple[str, Union[Callable, None]]]: + return [ + ( + 'Python Input > Paste', + self.paste_system_clipboard_to_input_buffer, + ), + ('Python Input > Copy or Clear', self.copy_or_clear_input_buffer), + ('Python Input > Run', self.run_code), + # Menu separator + ('-', None), + ( + 'Python Output > Toggle Wrap lines', + self.toggle_wrap_output_lines, + ), + ('Python Output > Copy All', self.copy_all_output_text), + ('Python Output > Copy Selection', self.copy_output_selection), + ('Python Output > Clear', self.clear_output_buffer), + ] def run_code(self): """Trigger a repl code execution on mouse click.""" @@ -339,6 +390,9 @@ class ReplPane(WindowPane): else: self.interrupt_last_code_execution() + def insert_text_into_input_buffer(self, text: str) -> None: + self.pw_ptpython_repl.default_buffer.insert_text(text) + def paste_system_clipboard_to_input_buffer(self, erase_buffer=False): if erase_buffer: self.clear_input_buffer() @@ -352,6 +406,10 @@ class ReplPane(WindowPane): # Clear any displayed function signatures. self.pw_ptpython_repl.on_reset() + def clear_output_buffer(self): + self.executed_code.clear() + self.update_output_buffer() + def copy_or_clear_input_buffer(self): # Copy selected text if a selection is active. if self.pw_ptpython_repl.default_buffer.selection_state: @@ -388,8 +446,9 @@ class ReplPane(WindowPane): for line in text.splitlines(): _LOG.debug('[PYTHON %s] %s', prefix, line.strip()) - async def periodically_check_stdout(self, user_code: UserCodeExecution, - stdout_proxy, stderr_proxy): + async def periodically_check_stdout( + self, user_code: UserCodeExecution, stdout_proxy, stderr_proxy + ): while not user_code.future.done(): await asyncio.sleep(0.3) stdout_text_so_far = stdout_proxy.getvalue() @@ -403,15 +462,13 @@ class ReplPane(WindowPane): self.update_output_buffer('repl_pane.periodic_check') def append_executed_code(self, text, future, temp_stdout, temp_stderr): - user_code = UserCodeExecution(input=text, - future=future, - output=None, - stdout=None, - stderr=None) + user_code = UserCodeExecution( + input=text, future=future, output=None, stdout=None, stderr=None + ) background_stdout_check = asyncio.create_task( - self.periodically_check_stdout(user_code, temp_stdout, - temp_stderr)) + self.periodically_check_stdout(user_code, temp_stdout, temp_stderr) + ) user_code.stdout_check_task = background_stdout_check self.executed_code.append(user_code) self._log_executed_code(user_code, prefix='START') @@ -426,7 +483,6 @@ class ReplPane(WindowPane): exception_text='', result_object=None, ): - code = self._get_executed_code(future) if code: code.output = result_text @@ -434,21 +490,36 @@ class ReplPane(WindowPane): code.stderr = stderr_text code.exception_text = exception_text code.result_object = result_object + if result_object is not None: + code.result_str = self._format_result_object(result_object) + self._log_executed_code(code, prefix='FINISH') self.update_output_buffer('repl_pane.append_result_to_executed_code') - def get_output_buffer_text(self, code_items=None, show_index=True): - content_width = (self.current_pane_width - if self.current_pane_width else 80) + def _format_result_object(self, result_object: Any) -> str: + """Pretty print format a Python object respecting the window width.""" + content_width = ( + self.current_pane_width if self.current_pane_width else 80 + ) pprint_respecting_width = pprint.PrettyPrinter( - indent=2, width=content_width).pformat + indent=2, width=content_width + ).pformat + + return pprint_respecting_width(result_object) + def get_output_buffer_text( + self, + code_items: Optional[List[UserCodeExecution]] = None, + show_index: bool = True, + ): executed_code = code_items or self.executed_code template = self.application.get_template('repl_output.jinja') - return template.render(code_items=executed_code, - result_format=pprint_respecting_width, - show_index=show_index) + + return template.render( + code_items=executed_code, + show_index=show_index, + ) def update_output_buffer(self, *unused_args): text = self.get_output_buffer_text() @@ -456,16 +527,31 @@ class ReplPane(WindowPane): # instead of the end of the last line. text += '\n' self.output_field.buffer.set_document( - Document(text=text, cursor_position=len(text))) + Document(text=text, cursor_position=len(text)) + ) self.application.redraw_ui() def input_or_output_has_focus(self) -> Condition: @Condition def test() -> bool: - if has_focus(self.output_field)() or has_focus( - self.pw_ptpython_repl)(): + if ( + has_focus(self.output_field)() + or has_focus(self.pw_ptpython_repl)() + ): return True return False return test + + def history_completions(self) -> List[Tuple[str, str]]: + return [ + ( + ' '.join([line.lstrip() for line in text.splitlines()]), + # Pass original text as the completion result. + text, + ) + for text in list( + self.pw_ptpython_repl.history.load_history_strings() + ) + ] diff --git a/pw_console/py/pw_console/search_toolbar.py b/pw_console/py/pw_console/search_toolbar.py index 72ad91794..c140a074e 100644 --- a/pw_console/py/pw_console/search_toolbar.py +++ b/pw_console/py/pw_console/search_toolbar.py @@ -37,8 +37,11 @@ from prompt_toolkit.widgets import TextArea from prompt_toolkit.validation import DynamicValidator from pw_console.log_view import RegexValidator, SearchMatcher -# import pw_console.widgets.checkbox -import pw_console.widgets.mouse_handlers +from pw_console.widgets import ( + mouse_handlers, + to_checkbox_with_keybind_indicator, + to_keybind_indicator, +) if TYPE_CHECKING: from pw_console.log_pane import LogPane @@ -59,9 +62,14 @@ class SearchToolbar(ConditionalContainer): self.input_field = TextArea( prompt=[ - ('class:search-bar-setting', '/', - functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self)) + ( + 'class:search-bar-setting', + '/', + functools.partial( + mouse_handlers.on_click, + self.focus_self, + ), + ) ], focusable=True, focus_on_click=True, @@ -111,28 +119,38 @@ class SearchToolbar(ConditionalContainer): HSplit( [ # Top row - VSplit([ - # Search Settings toggles, only show if the search input - # field is in focus. - ConditionalContainer(settings_bar_window, - filter=has_focus( - self.input_field)), - - # Match count numbers and buttons, only show if the - # search input is NOT in focus. - ConditionalContainer( - match_count_window, - filter=~has_focus(self.input_field)), # pylint: disable=invalid-unary-operand-type - ConditionalContainer( - match_buttons_window, - filter=~has_focus(self.input_field)), # pylint: disable=invalid-unary-operand-type - ]), + VSplit( + [ + # Search Settings toggles, only show if the search + # input field is in focus. + ConditionalContainer( + settings_bar_window, + filter=has_focus(self.input_field), + ), + # Match count numbers and buttons, only show if the + # search input is NOT in focus. + # pylint: disable=invalid-unary-operand-type + ConditionalContainer( + match_count_window, + filter=~has_focus(self.input_field), + ), + ConditionalContainer( + match_buttons_window, + filter=~has_focus(self.input_field), + ), + # pylint: enable=invalid-unary-operand-type + ] + ), # Bottom row - VSplit([ - self.input_field, - ConditionalContainer(input_field_buttons_window, - filter=has_focus(self)) - ]) + VSplit( + [ + self.input_field, + ConditionalContainer( + input_field_buttons_window, + filter=has_focus(self), + ), + ] + ), ], height=SearchToolbar.TOOLBAR_HEIGHT, style='class:search-bar', @@ -215,7 +233,8 @@ class SearchToolbar(ConditionalContainer): def _toggle_search_follow(self) -> None: self.log_view.follow_search_match = ( - not self.log_view.follow_search_match) + not self.log_view.follow_search_match + ) # If automatically jumping to the next search match, disable normal # follow mode. if self.log_view.follow_search_match: @@ -241,14 +260,15 @@ class SearchToolbar(ConditionalContainer): # Don't apply an empty search. return False - if self.log_pane.log_view.new_search(buff.text, - invert=self._search_invert, - field=self._search_field): + if self.log_pane.log_view.new_search( + buff.text, invert=self._search_invert, field=self._search_field + ): self._search_successful = True # Don't close the search bar, instead focus on the log content. self.log_pane.application.focus_on_container( - self.log_pane.log_display_window) + self.log_pane.log_display_window + ) # Keep existing search text. return True @@ -257,12 +277,13 @@ class SearchToolbar(ConditionalContainer): def get_search_help_fragments(self): """Return FormattedText with search general help keybinds.""" - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self) + focus = functools.partial(mouse_handlers.on_click, self.focus_self) start_search = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._start_search) + mouse_handlers.on_click, self._start_search + ) close_search = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self.cancel_search) + mouse_handlers.on_click, self.cancel_search + ) # Search toolbar is darker than pane toolbars, use the darker button # style here. @@ -277,27 +298,33 @@ class SearchToolbar(ConditionalContainer): fragments.extend(separator_text) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( - 'Enter', 'Search', start_search, base_style=button_style)) + to_keybind_indicator( + 'Enter', 'Search', start_search, base_style=button_style + ) + ) fragments.extend(separator_text) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( - 'Ctrl-c', 'Cancel', close_search, base_style=button_style)) + to_keybind_indicator( + 'Ctrl-c', 'Cancel', close_search, base_style=button_style + ) + ) return fragments def get_search_settings_fragments(self): """Return FormattedText with current search settings and keybinds.""" - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_self) + focus = functools.partial(mouse_handlers.on_click, self.focus_self) next_field = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._next_field) + mouse_handlers.on_click, self._next_field + ) toggle_invert = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._invert_search) + mouse_handlers.on_click, self._invert_search + ) next_matcher = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self.log_pane.log_view.select_next_search_matcher) + mouse_handlers.on_click, + self.log_pane.log_view.select_next_search_matcher, + ) separator_text = [('', ' ', focus)] @@ -312,42 +339,51 @@ class SearchToolbar(ConditionalContainer): fragments.extend(separator_text) selected_column_text = [ - (button_style + ' class:search-bar-setting', - (self._search_field.title() if self._search_field else 'All'), - next_field), + ( + button_style + ' class:search-bar-setting', + (self._search_field.title() if self._search_field else 'All'), + next_field, + ), ] fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( 'Ctrl-t', 'Column:', next_field, middle_fragments=selected_column_text, base_style=button_style, - )) + ) + ) fragments.extend(separator_text) fragments.extend( - pw_console.widgets.checkbox.to_checkbox_with_keybind_indicator( + to_checkbox_with_keybind_indicator( self._search_invert, 'Ctrl-v', 'Invert', toggle_invert, - base_style=button_style)) + base_style=button_style, + ) + ) fragments.extend(separator_text) # Matching Method current_matcher_text = [ - (button_style + ' class:search-bar-setting', - str(self.log_pane.log_view.search_matcher.name), next_matcher) + ( + button_style + ' class:search-bar-setting', + str(self.log_pane.log_view.search_matcher.name), + next_matcher, + ) ] fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( 'Ctrl-n', 'Matcher:', next_matcher, middle_fragments=current_matcher_text, base_style=button_style, - )) + ) + ) fragments.extend(separator_text) return fragments @@ -359,13 +395,13 @@ class SearchToolbar(ConditionalContainer): def get_match_count_fragments(self): """Return formatted text for the match count indicator.""" - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_log_pane) + focus = functools.partial(mouse_handlers.on_click, self.focus_log_pane) two_spaces = ('', ' ', focus) # Check if this line is a search match match_number = self.log_view.search_matched_lines.get( - self.log_view.log_index, -1) + self.log_view.log_index, -1 + ) # If valid, increment the zero indexed value by one for better human # readability. @@ -377,77 +413,89 @@ class SearchToolbar(ConditionalContainer): return [ ('class:search-match-count-dialog-title', ' Match ', focus), - ('', '{} / {}'.format(match_number, - len(self.log_view.search_matched_lines)), - focus), + ( + '', + '{} / {}'.format( + match_number, len(self.log_view.search_matched_lines) + ), + focus, + ), two_spaces, ] def get_button_fragments(self) -> StyleAndTextTuples: """Return formatted text for the action buttons.""" - focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.focus_log_pane) + focus = functools.partial(mouse_handlers.on_click, self.focus_log_pane) one_space = ('', ' ', focus) two_spaces = ('', ' ', focus) - cancel = functools.partial(pw_console.widgets.mouse_handlers.on_click, - self.cancel_search) + cancel = functools.partial(mouse_handlers.on_click, self.cancel_search) create_filter = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._create_filter) + mouse_handlers.on_click, self._create_filter + ) next_match = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._next_match) + mouse_handlers.on_click, self._next_match + ) previous_match = functools.partial( - pw_console.widgets.mouse_handlers.on_click, self._previous_match) + mouse_handlers.on_click, self._previous_match + ) toggle_search_follow = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self._toggle_search_follow) + mouse_handlers.on_click, + self._toggle_search_follow, + ) button_style = 'class:toolbar-button-inactive' fragments = [] fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='n', description='Next', mouse_handler=next_match, base_style=button_style, - )) + ) + ) fragments.append(two_spaces) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='N', description='Previous', mouse_handler=previous_match, base_style=button_style, - )) + ) + ) fragments.append(two_spaces) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='Ctrl-c', description='Cancel', mouse_handler=cancel, base_style=button_style, - )) + ) + ) fragments.append(two_spaces) fragments.extend( - pw_console.widgets.checkbox.to_keybind_indicator( + to_keybind_indicator( key='Ctrl-Alt-f', description='Add Filter', mouse_handler=create_filter, base_style=button_style, - )) + ) + ) fragments.append(two_spaces) fragments.extend( - pw_console.widgets.checkbox.to_checkbox_with_keybind_indicator( + to_checkbox_with_keybind_indicator( checked=self.log_view.follow_search_match, key='', description='Jump to new matches', mouse_handler=toggle_search_follow, - base_style=button_style)) + base_style=button_style, + ) + ) fragments.append(one_space) return fragments diff --git a/pw_console/py/pw_console/style.py b/pw_console/py/pw_console/style.py index da252b478..6f90465e8 100644 --- a/pw_console/py/pw_console/style.py +++ b/pw_console/py/pw_console/style.py @@ -213,7 +213,7 @@ _THEME_NAME_MAPPING = { 'dark': DarkColors(), 'high-contrast-dark': HighContrastDarkColors(), 'ansi': AnsiTerm(), -} # yapf: disable +} def get_theme_colors(theme_name=''): @@ -233,20 +233,20 @@ def generate_styles(theme_name='dark'): 'pane_inactive': 'bg:{} {}'.format(theme.dim_bg, theme.dim_fg), # Use default for active panes. 'pane_active': 'bg:{} {}'.format(theme.default_bg, theme.default_fg), - # Brighten active pane toolbars. 'toolbar_active': 'bg:{} {}'.format(theme.active_bg, theme.active_fg), - 'toolbar_inactive': 'bg:{} {}'.format(theme.inactive_bg, - theme.inactive_fg), - + 'toolbar_inactive': 'bg:{} {}'.format( + theme.inactive_bg, theme.inactive_fg + ), # Dimmer toolbar. - 'toolbar_dim_active': 'bg:{} {}'.format(theme.active_bg, - theme.active_fg), - 'toolbar_dim_inactive': 'bg:{} {}'.format(theme.default_bg, - theme.inactive_fg), + 'toolbar_dim_active': 'bg:{} {}'.format( + theme.active_bg, theme.active_fg + ), + 'toolbar_dim_inactive': 'bg:{} {}'.format( + theme.default_bg, theme.inactive_fg + ), # Used for pane titles 'toolbar_accent': theme.cyan_accent, - 'toolbar-button-decoration': '{}'.format(theme.cyan_accent), 'toolbar-setting-active': 'bg:{} {}'.format( theme.green_accent, @@ -254,79 +254,75 @@ def generate_styles(theme_name='dark'): ), 'toolbar-button-active': 'bg:{}'.format(theme.button_active_bg), 'toolbar-button-inactive': 'bg:{}'.format(theme.button_inactive_bg), - # prompt_toolkit scrollbar styles: - 'scrollbar.background': 'bg:{} {}'.format(theme.default_bg, - theme.default_fg), + 'scrollbar.background': 'bg:{} {}'.format( + theme.default_bg, theme.default_fg + ), # Scrollbar handle, bg is the bar color. - 'scrollbar.button': 'bg:{} {}'.format(theme.purple_accent, - theme.default_bg), - 'scrollbar.arrow': 'bg:{} {}'.format(theme.default_bg, - theme.blue_accent), + 'scrollbar.button': 'bg:{} {}'.format( + theme.purple_accent, theme.default_bg + ), + 'scrollbar.arrow': 'bg:{} {}'.format( + theme.default_bg, theme.blue_accent + ), # Unstyled scrollbar classes: # 'scrollbar.start' # 'scrollbar.end' - # Top menu bar styles 'menu-bar': 'bg:{} {}'.format(theme.inactive_bg, theme.inactive_fg), - 'menu-bar.selected-item': 'bg:{} {}'.format(theme.blue_accent, - theme.inactive_bg), + 'menu-bar.selected-item': 'bg:{} {}'.format( + theme.blue_accent, theme.inactive_bg + ), # Menu background 'menu': 'bg:{} {}'.format(theme.dialog_bg, theme.dim_fg), # Menu item separator 'menu-border': theme.magenta_accent, - # Top bar logo + keyboard shortcuts - 'logo': '{} bold'.format(theme.magenta_accent), + 'logo': '{} bold'.format(theme.magenta_accent), 'keybind': '{} bold'.format(theme.purple_accent), 'keyhelp': theme.dim_fg, - # Help window styles 'help_window_content': 'bg:{} {}'.format(theme.dialog_bg, theme.dim_fg), 'frame.border': 'bg:{} {}'.format(theme.dialog_bg, theme.purple_accent), - 'pane_indicator_active': 'bg:{}'.format(theme.magenta_accent), 'pane_indicator_inactive': 'bg:{}'.format(theme.inactive_bg), - 'pane_title_active': '{} bold'.format(theme.magenta_accent), 'pane_title_inactive': '{}'.format(theme.purple_accent), - - 'window-tab-active': 'bg:{} {}'.format(theme.active_bg, - theme.cyan_accent), - 'window-tab-inactive': 'bg:{} {}'.format(theme.inactive_bg, - theme.inactive_fg), - - 'pane_separator': 'bg:{} {}'.format(theme.default_bg, - theme.purple_accent), - + 'window-tab-active': 'bg:{} {}'.format( + theme.active_bg, theme.cyan_accent + ), + 'window-tab-inactive': 'bg:{} {}'.format( + theme.inactive_bg, theme.inactive_fg + ), + 'pane_separator': 'bg:{} {}'.format( + theme.default_bg, theme.purple_accent + ), # Search matches 'search': 'bg:{} {}'.format(theme.cyan_accent, theme.default_bg), - 'search.current': 'bg:{} {}'.format(theme.cyan_accent, - theme.default_bg), - + 'search.current': 'bg:{} {}'.format( + theme.cyan_accent, theme.default_bg + ), # Highlighted line styles 'selected-log-line': 'bg:{}'.format(theme.line_highlight_bg), 'marked-log-line': 'bg:{}'.format(theme.selected_line_bg), 'cursor-line': 'bg:{} nounderline'.format(theme.line_highlight_bg), - # Messages like 'Window too small' - 'warning-text': 'bg:{} {}'.format(theme.default_bg, - theme.yellow_accent), - - 'log-time': 'bg:{} {}'.format(theme.default_fg, - theme.default_bg), - + 'warning-text': 'bg:{} {}'.format( + theme.default_bg, theme.yellow_accent + ), + 'log-time': 'bg:{} {}'.format(theme.default_fg, theme.default_bg), # Apply foreground only for level and column values. This way the text # can inherit the background color of the parent window pane or line # selection. 'log-level-{}'.format(logging.CRITICAL): '{} bold'.format( - theme.red_accent), + theme.red_accent + ), 'log-level-{}'.format(logging.ERROR): '{}'.format(theme.red_accent), 'log-level-{}'.format(logging.WARNING): '{}'.format( - theme.yellow_accent), + theme.yellow_accent + ), 'log-level-{}'.format(logging.INFO): '{}'.format(theme.purple_accent), 'log-level-{}'.format(logging.DEBUG): '{}'.format(theme.blue_accent), - 'log-table-column-0': '{}'.format(theme.cyan_accent), 'log-table-column-1': '{}'.format(theme.green_accent), 'log-table-column-2': '{}'.format(theme.yellow_accent), @@ -335,52 +331,55 @@ def generate_styles(theme_name='dark'): 'log-table-column-5': '{}'.format(theme.blue_accent), 'log-table-column-6': '{}'.format(theme.orange_accent), 'log-table-column-7': '{}'.format(theme.red_accent), - 'search-bar': 'bg:{}'.format(theme.inactive_bg), - 'search-bar-title': 'bg:{} {}'.format(theme.cyan_accent, - theme.default_bg), + 'search-bar-title': 'bg:{} {}'.format( + theme.cyan_accent, theme.default_bg + ), 'search-bar-setting': '{}'.format(theme.cyan_accent), - 'search-bar-border': 'bg:{} {}'.format(theme.inactive_bg, - theme.cyan_accent), + 'search-bar-border': 'bg:{} {}'.format( + theme.inactive_bg, theme.cyan_accent + ), 'search-match-count-dialog': 'bg:{}'.format(theme.inactive_bg), 'search-match-count-dialog-title': '{}'.format(theme.cyan_accent), 'search-match-count-dialog-default-fg': '{}'.format(theme.default_fg), 'search-match-count-dialog-border': 'bg:{} {}'.format( - theme.inactive_bg, - theme.cyan_accent), - + theme.inactive_bg, theme.cyan_accent + ), 'filter-bar': 'bg:{}'.format(theme.inactive_bg), - 'filter-bar-title': 'bg:{} {}'.format(theme.red_accent, - theme.default_bg), + 'filter-bar-title': 'bg:{} {}'.format( + theme.red_accent, theme.default_bg + ), 'filter-bar-setting': '{}'.format(theme.cyan_accent), 'filter-bar-delete': '{}'.format(theme.red_accent), 'filter-bar-delimiter': '{}'.format(theme.purple_accent), - 'saveas-dialog': 'bg:{}'.format(theme.inactive_bg), - 'saveas-dialog-title': 'bg:{} {}'.format(theme.inactive_bg, - theme.default_fg), + 'saveas-dialog-title': 'bg:{} {}'.format( + theme.inactive_bg, theme.default_fg + ), 'saveas-dialog-setting': '{}'.format(theme.cyan_accent), - 'saveas-dialog-border': 'bg:{} {}'.format(theme.inactive_bg, - theme.cyan_accent), - + 'saveas-dialog-border': 'bg:{} {}'.format( + theme.inactive_bg, theme.cyan_accent + ), 'selection-dialog': 'bg:{}'.format(theme.inactive_bg), 'selection-dialog-title': '{}'.format(theme.yellow_accent), 'selection-dialog-default-fg': '{}'.format(theme.default_fg), 'selection-dialog-action-bg': 'bg:{}'.format(theme.yellow_accent), 'selection-dialog-action-fg': '{}'.format(theme.button_inactive_bg), - 'selection-dialog-border': 'bg:{} {}'.format(theme.inactive_bg, - theme.yellow_accent), - + 'selection-dialog-border': 'bg:{} {}'.format( + theme.inactive_bg, theme.yellow_accent + ), 'quit-dialog': 'bg:{}'.format(theme.inactive_bg), - 'quit-dialog-border': 'bg:{} {}'.format(theme.inactive_bg, - theme.red_accent), - + 'quit-dialog-border': 'bg:{} {}'.format( + theme.inactive_bg, theme.red_accent + ), 'command-runner': 'bg:{}'.format(theme.inactive_bg), - 'command-runner-title': 'bg:{} {}'.format(theme.inactive_bg, - theme.default_fg), + 'command-runner-title': 'bg:{} {}'.format( + theme.inactive_bg, theme.default_fg + ), 'command-runner-setting': '{}'.format(theme.purple_accent), - 'command-runner-border': 'bg:{} {}'.format(theme.inactive_bg, - theme.purple_accent), + 'command-runner-border': 'bg:{} {}'.format( + theme.inactive_bg, theme.purple_accent + ), 'command-runner-selected-item': 'bg:{}'.format(theme.selected_line_bg), 'command-runner-fuzzy-highlight-0': '{}'.format(theme.blue_accent), 'command-runner-fuzzy-highlight-1': '{}'.format(theme.cyan_accent), @@ -388,7 +387,6 @@ def generate_styles(theme_name='dark'): 'command-runner-fuzzy-highlight-3': '{}'.format(theme.yellow_accent), 'command-runner-fuzzy-highlight-4': '{}'.format(theme.orange_accent), 'command-runner-fuzzy-highlight-5': '{}'.format(theme.red_accent), - # Progress Bar Styles # Entire set of ProgressBars - no title is used in pw_console 'title': '', @@ -407,7 +405,6 @@ def generate_styles(theme_name='dark'): 'total': '{}'.format(theme.cyan_accent), 'time-elapsed': '{}'.format(theme.purple_accent), 'time-left': '{}'.format(theme.magenta_accent), - # Named theme color classes for use in user plugins. 'theme-fg-red': '{}'.format(theme.red_accent), 'theme-fg-orange': '{}'.format(theme.orange_accent), @@ -425,25 +422,19 @@ def generate_styles(theme_name='dark'): 'theme-bg-blue': 'bg:{}'.format(theme.blue_accent), 'theme-bg-purple': 'bg:{}'.format(theme.purple_accent), 'theme-bg-magenta': 'bg:{}'.format(theme.magenta_accent), - 'theme-bg-active': 'bg:{}'.format(theme.active_bg), 'theme-fg-active': '{}'.format(theme.active_fg), - 'theme-bg-inactive': 'bg:{}'.format(theme.inactive_bg), 'theme-fg-inactive': '{}'.format(theme.inactive_fg), - 'theme-fg-default': '{}'.format(theme.default_fg), 'theme-bg-default': 'bg:{}'.format(theme.default_bg), - 'theme-fg-dim': '{}'.format(theme.dim_fg), 'theme-bg-dim': 'bg:{}'.format(theme.dim_bg), - 'theme-bg-dialog': 'bg:{}'.format(theme.dialog_bg), 'theme-bg-line-highlight': 'bg:{}'.format(theme.line_highlight_bg), - 'theme-bg-button-active': 'bg:{}'.format(theme.button_active_bg), 'theme-bg-button-inactive': 'bg:{}'.format(theme.button_inactive_bg), - } # yapf: disable + } return Style.from_dict(pw_console_styles) @@ -469,10 +460,9 @@ def get_pane_style(pt_container) -> str: return 'class:pane_inactive' -def get_pane_indicator(pt_container, - title, - mouse_handler=None, - hide_indicator=False) -> StyleAndTextTuples: +def get_pane_indicator( + pt_container, title, mouse_handler=None, hide_indicator=False +) -> StyleAndTextTuples: """Return formatted text for a pane indicator and title.""" inactive_indicator: OneStyleAndTextTuple @@ -481,8 +471,11 @@ def get_pane_indicator(pt_container, active_title: OneStyleAndTextTuple if mouse_handler: - inactive_indicator = ('class:pane_indicator_inactive', ' ', - mouse_handler) + inactive_indicator = ( + 'class:pane_indicator_inactive', + ' ', + mouse_handler, + ) active_indicator = ('class:pane_indicator_active', ' ', mouse_handler) inactive_title = ('class:pane_title_inactive', title, mouse_handler) active_title = ('class:pane_title_active', title, mouse_handler) diff --git a/pw_console/py/pw_console/templates/__init__.py b/pw_console/py/pw_console/templates/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/pw_console/py/pw_console/templates/__init__.py diff --git a/pw_console/py/pw_console/templates/repl_output.jinja b/pw_console/py/pw_console/templates/repl_output.jinja index 4c482c109..976cb8b1d 100644 --- a/pw_console/py/pw_console/templates/repl_output.jinja +++ b/pw_console/py/pw_console/templates/repl_output.jinja @@ -30,8 +30,8 @@ Running... {% endif %} {% if code.exception_text %} {{ code.exception_text }} -{% elif code.result_object %} -{{ result_format(code.result_object) }} +{% elif code.result_str %} +{{ code.result_str }} {% elif code.output %} {{ code.output }} {% endif %} diff --git a/pw_console/py/pw_console/test_mode.py b/pw_console/py/pw_console/test_mode.py new file mode 100644 index 000000000..815792c1d --- /dev/null +++ b/pw_console/py/pw_console/test_mode.py @@ -0,0 +1,86 @@ +# Copyright 2022 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""pw_console test mode functions.""" + +import asyncio +import time +import re +import random +import logging +from threading import Thread +from typing import Dict, List, Tuple + +FAKE_DEVICE_LOGGER_NAME = 'pw_console_fake_device' + +_ROOT_LOG = logging.getLogger('') +_FAKE_DEVICE_LOG = logging.getLogger(FAKE_DEVICE_LOGGER_NAME) + + +def start_fake_logger(lines, log_thread_entry, log_thread_loop): + fake_log_messages = prepare_fake_logs(lines) + + test_log_thread = Thread(target=log_thread_entry, args=(), daemon=True) + test_log_thread.start() + + background_log_task = asyncio.run_coroutine_threadsafe( + # This function will be executed in a separate thread. + log_forever(fake_log_messages), + # Using this asyncio event loop. + log_thread_loop, + ) # type: ignore + return background_log_task + + +def prepare_fake_logs(lines) -> List[Tuple[str, Dict]]: + fake_logs: List[Tuple[str, Dict]] = [] + key_regex = re.compile(r':kbd:`(?P<key>[^`]+)`') + for line in lines: + if not line: + continue + + keyboard_key = '' + search = key_regex.search(line) + if search: + keyboard_key = search.group(1) + + fake_logs.append((line, {'keys': keyboard_key})) + return fake_logs + + +async def log_forever(fake_log_messages: List[Tuple[str, Dict]]): + """Test mode async log generator coroutine that runs forever.""" + _ROOT_LOG.info('Fake log device connected.') + start_time = time.time() + message_count = 0 + + # Fake module column names. + module_names = ['APP', 'RADIO', 'BAT', 'USB', 'CPU'] + while True: + if message_count > 32 or message_count < 2: + await asyncio.sleep(0.1) + fake_log = random.choice(fake_log_messages) + + module_name = module_names[message_count % len(module_names)] + _FAKE_DEVICE_LOG.info( + fake_log[0], + extra=dict( + extra_metadata_fields=dict( + module=module_name, + file='fake_app.cc', + timestamp=time.time() - start_time, + **fake_log[1], + ) + ), + ) + message_count += 1 diff --git a/pw_console/py/pw_console/text_formatting.py b/pw_console/py/pw_console/text_formatting.py index 6fb53f98b..485f13fd4 100644 --- a/pw_console/py/pw_console/text_formatting.py +++ b/pw_console/py/pw_console/text_formatting.py @@ -30,7 +30,8 @@ def strip_ansi(text: str): def split_lines( - input_fragments: StyleAndTextTuples) -> List[StyleAndTextTuples]: + input_fragments: StyleAndTextTuples, +) -> List[StyleAndTextTuples]: """Break a flattened list of StyleAndTextTuples into a list of lines. Ending line breaks are not preserved.""" @@ -50,9 +51,10 @@ def split_lines( def insert_linebreaks( - input_fragments: StyleAndTextTuples, - max_line_width: int, - truncate_long_lines: bool = True) -> Tuple[StyleAndTextTuples, int]: + input_fragments: StyleAndTextTuples, + max_line_width: int, + truncate_long_lines: bool = True, +) -> Tuple[StyleAndTextTuples, int]: """Add line breaks at max_line_width if truncate_long_lines is True. Returns input_fragments with each character as it's own formatted text @@ -118,7 +120,8 @@ def insert_linebreaks( def join_adjacent_style_tuples( - fragments: StyleAndTextTuples) -> StyleAndTextTuples: + fragments: StyleAndTextTuples, +) -> StyleAndTextTuples: """Join adjacent FormattedTextTuples if they have the same style.""" new_fragments: StyleAndTextTuples = [] @@ -145,13 +148,15 @@ def join_adjacent_style_tuples( return new_fragments -def fill_character_width(input_fragments: StyleAndTextTuples, - fragment_width: int, - window_width: int, - line_wrapping: bool = False, - remaining_width: int = 0, - horizontal_scroll_amount: int = 0, - add_cursor: bool = False) -> StyleAndTextTuples: +def fill_character_width( + input_fragments: StyleAndTextTuples, + fragment_width: int, + window_width: int, + line_wrapping: bool = False, + remaining_width: int = 0, + horizontal_scroll_amount: int = 0, + add_cursor: bool = False, +) -> StyleAndTextTuples: """Fill line to the width of the window using spaces.""" # Calculate the number of spaces to add at the end. empty_characters = window_width - fragment_width @@ -195,7 +200,8 @@ def fill_character_width(input_fragments: StyleAndTextTuples, def flatten_formatted_text_tuples( - lines: Iterable[StyleAndTextTuples]) -> StyleAndTextTuples: + lines: Iterable[StyleAndTextTuples], +) -> StyleAndTextTuples: """Flatten a list of lines of FormattedTextTuples This function will also remove trailing newlines to avoid displaying extra diff --git a/pw_console/py/pw_console/widgets/__init__.py b/pw_console/py/pw_console/widgets/__init__.py index 946e3af5a..133df8201 100644 --- a/pw_console/py/pw_console/widgets/__init__.py +++ b/pw_console/py/pw_console/widgets/__init__.py @@ -14,6 +14,7 @@ """Pigweed Console Reusable UI widgets.""" # pylint: disable=unused-import +from pw_console.widgets.border import create_border from pw_console.widgets.checkbox import ( ToolbarButton, to_checkbox, @@ -23,5 +24,9 @@ from pw_console.widgets.checkbox import ( to_checkbox_text, ) from pw_console.widgets.mouse_handlers import on_click -from pw_console.widgets.window_pane import WindowPane, WindowPaneHSplit +from pw_console.widgets.window_pane import ( + FloatingWindowPane, + WindowPane, + WindowPaneHSplit, +) from pw_console.widgets.window_pane_toolbar import WindowPaneToolbar diff --git a/pw_console/py/pw_console/widgets/border.py b/pw_console/py/pw_console/widgets/border.py index 0cf1170ef..33096560d 100644 --- a/pw_console/py/pw_console/widgets/border.py +++ b/pw_console/py/pw_console/widgets/border.py @@ -28,7 +28,7 @@ def create_border( # pylint: disable=too-many-arguments content: AnyContainer, content_height: Optional[int] = None, - title: str = '', + title: Union[Callable[[], str], str] = '', border_style: Union[Callable[[], str], str] = '', base_style: Union[Callable[[], str], str] = '', top: bool = True, @@ -49,13 +49,17 @@ def create_border( top_border_items: List[AnyContainer] = [] if left: top_border_items.append( - Window(width=1, height=1, char=top_left_char, style=border_style)) + Window(width=1, height=1, char=top_left_char, style=border_style) + ) title_text = None if title: - title_text = FormattedTextControl([ - ('', f'{horizontal_char}{horizontal_char} {title} ') - ]) + if isinstance(title, str): + title_text = FormattedTextControl( + [('', f'{horizontal_char}{horizontal_char} {title} ')] + ) + else: + title_text = FormattedTextControl(title) top_border_items.append( Window( @@ -63,49 +67,66 @@ def create_border( char=horizontal_char, # Expand width to max available space dont_extend_width=False, - style=border_style)) + style=border_style, + ) + ) if right: top_border_items.append( - Window(width=1, height=1, char=top_right_char, style=border_style)) + Window(width=1, height=1, char=top_right_char, style=border_style) + ) content_items: List[AnyContainer] = [] if left: content_items.append( - Window(width=1, - height=content_height, - char=vertical_char, - style=border_style)) + Window( + width=1, + height=content_height, + char=vertical_char, + style=border_style, + ) + ) if left_margin_columns > 0: content_items.append( - Window(width=left_margin_columns, - height=content_height, - char=' ', - style=border_style)) + Window( + width=left_margin_columns, + height=content_height, + char=' ', + style=border_style, + ) + ) content_items.append(content) if right_margin_columns > 0: content_items.append( - Window(width=right_margin_columns, - height=content_height, - char=' ', - style=border_style)) + Window( + width=right_margin_columns, + height=content_height, + char=' ', + style=border_style, + ) + ) if right: content_items.append( - Window(width=1, height=2, char=vertical_char, style=border_style)) + Window(width=1, height=2, char=vertical_char, style=border_style) + ) bottom_border_items: List[AnyContainer] = [] if left: bottom_border_items.append( - Window(width=1, height=1, char=bottom_left_char)) + Window(width=1, height=1, char=bottom_left_char) + ) bottom_border_items.append( Window( char=horizontal_char, # Expand width to max available space - dont_extend_width=False)) + dont_extend_width=False, + ) + ) if right: bottom_border_items.append( - Window(width=1, height=1, char=bottom_right_char)) + Window(width=1, height=1, char=bottom_right_char) + ) rows: List[AnyContainer] = [] if top: @@ -113,9 +134,7 @@ def create_border( rows.append(VSplit(content_items, height=content_height)) if bottom: rows.append( - VSplit(bottom_border_items, - height=1, - padding=0, - style=border_style)) + VSplit(bottom_border_items, height=1, padding=0, style=border_style) + ) return HSplit(rows, style=base_style) diff --git a/pw_console/py/pw_console/widgets/checkbox.py b/pw_console/py/pw_console/widgets/checkbox.py index 91805e543..874ce990f 100644 --- a/pw_console/py/pw_console/widgets/checkbox.py +++ b/pw_console/py/pw_console/widgets/checkbox.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright 2021 The Pigweed Authors # # Licensed under the Apache License, Version 2.0 (the "License"); you may not @@ -79,19 +77,21 @@ def to_checkbox_with_keybind_indicator( ): """Create a clickable keybind indicator with checkbox for toolbars.""" if mouse_handler: - return to_keybind_indicator(key, - description, - mouse_handler, - leading_fragments=[ - to_checkbox(checked, mouse_handler, - **checkbox_kwargs) - ], - base_style=base_style) + return to_keybind_indicator( + key, + description, + mouse_handler, + leading_fragments=[ + to_checkbox(checked, mouse_handler, **checkbox_kwargs) + ], + base_style=base_style, + ) return to_keybind_indicator( key, description, leading_fragments=[to_checkbox(checked, **checkbox_kwargs)], - base_style=base_style) + base_style=base_style, + ) def to_keybind_indicator( @@ -114,7 +114,8 @@ def to_keybind_indicator( def append_fragment_with_base_style(frag_list, fragment) -> None: if mouse_handler: frag_list.append( - (base_style + fragment[0], fragment[1], mouse_handler)) + (base_style + fragment[0], fragment[1], mouse_handler) + ) else: frag_list.append((base_style + fragment[0], fragment[1])) @@ -126,7 +127,8 @@ def to_keybind_indicator( # Function name if mouse_handler: fragments.append( - (base_style + description_style, description, mouse_handler)) + (base_style + description_style, description, mouse_handler) + ) else: fragments.append((base_style + description_style, description)) @@ -137,8 +139,9 @@ def to_keybind_indicator( # Separator and keybind if key: if mouse_handler: - fragments.append((base_style + description_style, _KEY_SEPARATOR, - mouse_handler)) + fragments.append( + (base_style + description_style, _KEY_SEPARATOR, mouse_handler) + ) fragments.append((base_style + key_style, key, mouse_handler)) else: fragments.append((base_style + description_style, _KEY_SEPARATOR)) diff --git a/pw_console/py/pw_console/widgets/event_count_history.py b/pw_console/py/pw_console/widgets/event_count_history.py index 1779ad0dc..242f615e4 100644 --- a/pw_console/py/pw_console/widgets/event_count_history.py +++ b/pw_console/py/pw_console/widgets/event_count_history.py @@ -84,8 +84,8 @@ class EventCountHistory: def last_count_with_units(self) -> str: return '{:.3f} [{}]'.format( - self._last_count * self.display_unit_factor, - self.display_unit_title) + self._last_count * self.display_unit_factor, self.display_unit_title + ) def __repr__(self) -> str: sparkline = '' @@ -96,9 +96,9 @@ class EventCountHistory: def __pt_formatted_text__(self): return [('', self.__repr__())] - def sparkline(self, - min_value: int = 0, - max_value: Optional[int] = None) -> str: + def sparkline( + self, min_value: int = 0, max_value: Optional[int] = None + ) -> str: msg = ''.rjust(self.history_limit) if len(self.history) == 0: return msg @@ -112,8 +112,10 @@ class EventCountHistory: msg = '' for i in self.history: # (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min - index = int((((1.0 * i) - minimum) / max_minus_min) * - len(self.scale_characters)) + index = int( + (((1.0 * i) - minimum) / max_minus_min) + * len(self.scale_characters) + ) if index >= len(self.scale_characters): index = len(self.scale_characters) - 1 msg += self.scale_characters[index] diff --git a/pw_console/py/pw_console/widgets/mouse_handlers.py b/pw_console/py/pw_console/widgets/mouse_handlers.py index af3c58644..d9d04e980 100644 --- a/pw_console/py/pw_console/widgets/mouse_handlers.py +++ b/pw_console/py/pw_console/widgets/mouse_handlers.py @@ -53,6 +53,7 @@ def on_click(on_click_function: Callable, mouse_event: MouseEvent): class EmptyMouseHandler(MouseHandlers): """MouseHandler that does not propagate events.""" + def set_mouse_handler_for_range( self, x_min: int, diff --git a/pw_console/py/pw_console/widgets/table.py b/pw_console/py/pw_console/widgets/table.py index ea3154392..e486fd2b9 100644 --- a/pw_console/py/pw_console/widgets/table.py +++ b/pw_console/py/pw_console/widgets/table.py @@ -20,7 +20,7 @@ from prompt_toolkit.formatted_text import StyleAndTextTuples from pw_console.console_prefs import ConsolePrefs from pw_console.log_line import LogLine -import pw_console.text_formatting +from pw_console.text_formatting import strip_ansi class TableView: @@ -52,18 +52,19 @@ class TableView: self.column_padding = ' ' * self.prefs.spaces_between_columns def all_column_names(self): - columns_names = [ - name for name, _width in self._ordered_column_widths() - ] + columns_names = [name for name, _width in self._ordered_column_widths()] return columns_names + ['message'] def _width_of_justified_fields(self): """Calculate the width of all columns except LAST_TABLE_COLUMN_NAMES.""" padding_width = len(self.column_padding) - used_width = sum([ - width + padding_width for key, width in self.column_widths.items() - if key not in TableView.LAST_TABLE_COLUMN_NAMES - ]) + used_width = sum( + [ + width + padding_width + for key, width in self.column_widths.items() + if key not in TableView.LAST_TABLE_COLUMN_NAMES + ] + ) return used_width def _ordered_column_widths(self): @@ -94,8 +95,14 @@ class TableView: return ordered_columns.items() - def update_metadata_column_widths(self, log: LogLine): + def update_metadata_column_widths(self, log: LogLine) -> None: """Calculate the max widths for each metadata field.""" + if log.metadata is None: + log.update_metadata() + # If extra fields still don't exist, no need to update column widths. + if log.metadata is None: + return + for field_name, value in log.metadata.fields.items(): value_string = str(value) @@ -110,8 +117,7 @@ class TableView: self.column_widths[field_name] = len(value_string) # Update log level character width. - ansi_stripped_level = pw_console.text_formatting.strip_ansi( - log.record.levelname) + ansi_stripped_level = strip_ansi(log.record.levelname) if len(ansi_stripped_level) > self.column_widths['level']: self.column_widths['level'] = len(ansi_stripped_level) @@ -125,15 +131,15 @@ class TableView: # Update time column width to current prefs setting self.column_widths['time'] = self._default_time_width if self.prefs.hide_date_from_log_time: - self.column_widths['time'] = (self._default_time_width - - self._year_month_day_width) + self.column_widths['time'] = ( + self._default_time_width - self._year_month_day_width + ) for name, width in self._ordered_column_widths(): # These fields will be shown at the end - if name in ['msg', 'message']: + if name in TableView.LAST_TABLE_COLUMN_NAMES: continue - fragments.append( - (default_style, name.title()[:width].ljust(width))) + fragments.append((default_style, name.title()[:width].ljust(width))) fragments.append(('', self.column_padding)) fragments.append((default_style, 'Message')) @@ -163,7 +169,7 @@ class TableView: columns = {} for name, width in self._ordered_column_widths(): # Skip these modifying these fields - if name in ['msg', 'message']: + if name in TableView.LAST_TABLE_COLUMN_NAMES: continue # hasattr checks are performed here since a log record may not have @@ -172,25 +178,28 @@ class TableView: if name == 'time' and hasattr(log.record, 'asctime'): time_text = log.record.asctime if self.prefs.hide_date_from_log_time: - time_text = time_text[self._year_month_day_width:] - time_style = self.prefs.column_style('time', - time_text, - default='class:log-time') - columns['time'] = (time_style, - time_text.ljust(self.column_widths['time'])) + time_text = time_text[self._year_month_day_width :] + time_style = self.prefs.column_style( + 'time', time_text, default='class:log-time' + ) + columns['time'] = ( + time_style, + time_text.ljust(self.column_widths['time']), + ) continue if name == 'level' and hasattr(log.record, 'levelname'): # Remove any existing ANSI formatting and apply our colors. - level_text = pw_console.text_formatting.strip_ansi( - log.record.levelname) + level_text = strip_ansi(log.record.levelname) level_style = self.prefs.column_style( 'level', level_text, - default='class:log-level-{}'.format(log.record.levelno)) - columns['level'] = (level_style, - level_text.ljust( - self.column_widths['level'])) + default='class:log-level-{}'.format(log.record.levelno), + ) + columns['level'] = ( + level_style, + level_text.ljust(self.column_widths['level']), + ) continue value = log.metadata.fields.get(name, ' ') @@ -211,8 +220,7 @@ class TableView: # Grab the message to appear after the justified columns with ANSI # escape sequences removed. - message_text = pw_console.text_formatting.strip_ansi( - log.record.message) + message_text = strip_ansi(log.record.message) message = log.metadata.fields.get( 'msg', message_text.rstrip(), # Remove any trailing line breaks @@ -239,12 +247,15 @@ class TableView: # For raw strings that don't have their own ANSI colors, apply the # theme color style for this column. if isinstance(column_value, str): - fallback_style = 'class:log-table-column-{}'.format( - i + index_modifier) if 0 <= i <= 7 else default_style - - style = self.prefs.column_style(column_name, - column_value.rstrip(), - default=fallback_style) + fallback_style = ( + 'class:log-table-column-{}'.format(i + index_modifier) + if 0 <= i <= 7 + else default_style + ) + + style = self.prefs.column_style( + column_name, column_value.rstrip(), default=fallback_style + ) table_fragments.append((style, column_value)) table_fragments.append(padding_formatted_text) diff --git a/pw_console/py/pw_console/widgets/window_pane.py b/pw_console/py/pw_console/widgets/window_pane.py index ab2484a74..1c9bd7284 100644 --- a/pw_console/py/pw_console/widgets/window_pane.py +++ b/pw_console/py/pw_console/widgets/window_pane.py @@ -14,7 +14,7 @@ """Window pane base class.""" from abc import ABC -from typing import Any, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, List, Optional, Tuple, TYPE_CHECKING, Union import functools from prompt_toolkit.layout.dimension import AnyDimension @@ -27,12 +27,10 @@ from prompt_toolkit.layout import ( HSplit, walk, ) +from prompt_toolkit.widgets import MenuItem from pw_console.get_pw_console_app import get_pw_console_app - -import pw_console.widgets.checkbox -import pw_console.widgets.mouse_handlers -import pw_console.style +from pw_console.style import get_pane_style if TYPE_CHECKING: from pw_console.console_app import ConsoleApp @@ -44,6 +42,7 @@ class WindowPaneHSplit(HSplit): This overrides the write_to_screen function to save the width and height of the container to be rendered. """ + def __init__(self, parent_window_pane, *args, **kwargs): # Save a reference to the parent window pane. self.parent_window_pane = parent_window_pane @@ -60,11 +59,18 @@ class WindowPaneHSplit(HSplit): ) -> None: # Save the width and height for the current render pass. This will be # used by the log pane to render the correct amount of log lines. - self.parent_window_pane.update_pane_size(write_position.width, - write_position.height) + self.parent_window_pane.update_pane_size( + write_position.width, write_position.height + ) # Continue writing content to the screen. - super().write_to_screen(screen, mouse_handlers, write_position, - parent_style, erase_bg, z_index) + super().write_to_screen( + screen, + mouse_handlers, + write_position, + parent_style, + erase_bg, + z_index, + ) class WindowPane(ABC): @@ -86,6 +92,8 @@ class WindowPane(ABC): self._pane_title = pane_title self._pane_subtitle: str = '' + self.extra_tab_style: Optional[str] = None + # Default width and height to 10 lines each. They will be resized by the # WindowManager later. self.height = height if height else Dimension(preferred=10) @@ -145,7 +153,7 @@ class WindowPane(ABC): object.""" return self.container # pylint: disable=no-member - def get_all_key_bindings(self) -> list: + def get_all_key_bindings(self) -> List: """Return keybinds for display in the help window. For example: @@ -167,7 +175,9 @@ class WindowPane(ABC): # pylint: disable=no-self-use return [] - def get_all_menu_options(self) -> list: + def get_window_menu_options( + self, + ) -> List[Tuple[str, Union[Callable, None]]]: """Return menu options for the window pane. Should return a list of tuples containing with the display text and @@ -176,10 +186,17 @@ class WindowPane(ABC): # pylint: disable=no-self-use return [] + def get_top_level_menus(self) -> List[MenuItem]: + """Return MenuItems to be displayed on the main pw_console menu bar.""" + # pylint: disable=no-self-use + return [] + def pane_resized(self) -> bool: """Return True if the current window size has changed.""" - return (self.last_pane_width != self.current_pane_width - or self.last_pane_height != self.current_pane_height) + return ( + self.last_pane_width != self.current_pane_width + or self.last_pane_height != self.current_pane_height + ) def update_pane_size(self, width, height) -> None: """Save pane width and height for the current UI render pass.""" @@ -198,9 +215,10 @@ class WindowPane(ABC): # Window pane dimensions height=lambda: self.height, width=lambda: self.width, - style=functools.partial(pw_console.style.get_pane_style, self), + style=functools.partial(get_pane_style, self), ), - filter=Condition(lambda: self.show_pane)) + filter=Condition(lambda: self.show_pane), + ) def has_child_container(self, child_container: AnyContainer) -> bool: if not child_container: @@ -209,3 +227,45 @@ class WindowPane(ABC): if container == child_container: return True return False + + +class FloatingWindowPane(WindowPane): + """The Pigweed Console FloatingWindowPane class.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Tracks the last focused container, to enable restoring focus after + # closing the dialog. + self.last_focused_pane = None + + def close_dialog(self) -> None: + """Close runner dialog box.""" + self.show_pane = False + + # Restore original focus if possible. + if self.last_focused_pane: + self.application.focus_on_container(self.last_focused_pane) + else: + # Fallback to focusing on the main menu. + self.application.focus_main_menu() + + self.application.update_menu_items() + + def open_dialog(self) -> None: + self.show_pane = True + self.last_focused_pane = self.application.focused_window() + self.focus_self() + self.application.redraw_ui() + + self.application.update_menu_items() + + def toggle_dialog(self) -> bool: + if self.show_pane: + self.close_dialog() + else: + self.open_dialog() + # The focused window has changed. Return true so + # ConsoleApp.run_pane_menu_option does not set the focus to the main + # menu. + return True diff --git a/pw_console/py/pw_console/widgets/window_pane_toolbar.py b/pw_console/py/pw_console/widgets/window_pane_toolbar.py index 3c16beb62..18ec4cd9a 100644 --- a/pw_console/py/pw_console/widgets/window_pane_toolbar.py +++ b/pw_console/py/pw_console/widgets/window_pane_toolbar.py @@ -28,19 +28,24 @@ from prompt_toolkit.layout import ( from prompt_toolkit.mouse_events import MouseEvent, MouseEventType from pw_console.get_pw_console_app import get_pw_console_app -import pw_console.style +from pw_console.style import ( + get_pane_indicator, + get_button_style, + get_toolbar_style, +) from pw_console.widgets import ( ToolbarButton, + mouse_handlers, to_checkbox_with_keybind_indicator, to_keybind_indicator, ) -import pw_console.widgets.mouse_handlers _LOG = logging.getLogger(__package__) class WindowPaneResizeHandle(FormattedTextControl): """Button to initiate window pane resize drag events.""" + def __init__(self, parent_window_pane: Any, *args, **kwargs) -> None: self.parent_window_pane = parent_window_pane super().__init__(*args, **kwargs) @@ -50,7 +55,8 @@ class WindowPaneResizeHandle(FormattedTextControl): # Start resize mouse drag event if mouse_event.event_type == MouseEventType.MOUSE_DOWN: get_pw_console_app().window_manager.start_resize_pane( - self.parent_window_pane) + self.parent_window_pane + ) # Mouse event handled, return None. return None @@ -60,6 +66,7 @@ class WindowPaneResizeHandle(FormattedTextControl): class WindowPaneToolbar: """One line toolbar for display at the bottom of of a window pane.""" + # pylint: disable=too-many-instance-attributes TOOLBAR_HEIGHT = 1 @@ -71,20 +78,18 @@ class WindowPaneToolbar: # No title was set, fetch the parent window pane title if available. parent_pane_title = self.parent_window_pane.pane_title() title = parent_pane_title if parent_pane_title else title - return pw_console.style.get_pane_indicator(self.focus_check_container, - f' {title} ', - self.focus_mouse_handler) + return get_pane_indicator( + self.focus_check_container, f' {title} ', self.focus_mouse_handler + ) def get_center_text_tokens(self): """Return formatted text tokens for display in the center part of the toolbar.""" - button_style = pw_console.style.get_button_style( - self.focus_check_container) + button_style = get_button_style(self.focus_check_container) # FormattedTextTuple contents: (Style, Text, Mouse handler) - separator_text = [('', ' ') - ] # 2 spaces of separaton between keybinds. + separator_text = [('', ' ')] # 2 spaces of separaton between keybinds. if self.focus_mouse_handler: separator_text = [('', ' ', self.focus_mouse_handler)] @@ -95,8 +100,9 @@ class WindowPaneToolbar: on_click_handler = None if button.mouse_handler: on_click_handler = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - button.mouse_handler) + mouse_handlers.on_click, + button.mouse_handler, + ) if button.is_checkbox: fragments.extend( @@ -105,13 +111,18 @@ class WindowPaneToolbar: button.key, button.description, on_click_handler, - base_style=button_style)) + base_style=button_style, + ) + ) else: fragments.extend( - to_keybind_indicator(button.key, - button.description, - on_click_handler, - base_style=button_style)) + to_keybind_indicator( + button.key, + button.description, + on_click_handler, + base_style=button_style, + ) + ) fragments.extend(separator_text) @@ -124,22 +135,40 @@ class WindowPaneToolbar: """Return formatted text tokens for display.""" fragments = [] if not has_focus(self.focus_check_container.__pt_container__())(): - fragments.append(( - 'class:toolbar-button-inactive class:toolbar-button-decoration', - ' ', self.focus_mouse_handler)) - fragments.append(('class:toolbar-button-inactive class:keyhelp', - 'click to focus', self.focus_mouse_handler)) - fragments.append(( - 'class:toolbar-button-inactive class:toolbar-button-decoration', - ' ', self.focus_mouse_handler)) - fragments.append( - ('', ' {} '.format(self.subtitle()), self.focus_mouse_handler)) + if self.click_to_focus_text: + fragments.append( + ( + 'class:toolbar-button-inactive ' + 'class:toolbar-button-decoration', + ' ', + self.focus_mouse_handler, + ) + ) + fragments.append( + ( + 'class:toolbar-button-inactive class:keyhelp', + self.click_to_focus_text, + self.focus_mouse_handler, + ) + ) + fragments.append( + ( + 'class:toolbar-button-inactive ' + 'class:toolbar-button-decoration', + ' ', + self.focus_mouse_handler, + ) + ) + if self.subtitle: + fragments.append( + ('', ' {} '.format(self.subtitle()), self.focus_mouse_handler) + ) return fragments def get_resize_handle(self): - return pw_console.style.get_pane_indicator(self.focus_check_container, - '─══─', - hide_indicator=True) + return get_pane_indicator( + self.focus_check_container, '─══─', hide_indicator=True + ) def add_button(self, button: ToolbarButton): self.buttons.append(button) @@ -153,11 +182,12 @@ class WindowPaneToolbar: focus_action_callable: Optional[Callable] = None, center_section_align: WindowAlign = WindowAlign.LEFT, include_resize_handle: bool = True, + click_to_focus_text: str = 'click to focus', ): - self.parent_window_pane = parent_window_pane self.title = title self.subtitle = subtitle + self.click_to_focus_text = click_to_focus_text # Assume check this container for focus self.focus_check_container = self @@ -186,8 +216,9 @@ class WindowPaneToolbar: self.focus_mouse_handler = None if self.focus_action_callable: self.focus_mouse_handler = functools.partial( - pw_console.widgets.mouse_handlers.on_click, - self.focus_action_callable) + mouse_handlers.on_click, + self.focus_action_callable, + ) self.buttons: List[ToolbarButton] = [] self.show_toolbar = True @@ -211,8 +242,9 @@ class WindowPaneToolbar: dont_extend_width=True, ) - get_toolbar_style = functools.partial( - pw_console.style.get_toolbar_style, self.focus_check_container) + wrapped_get_toolbar_style = functools.partial( + get_toolbar_style, self.focus_check_container + ) sections = [ self.left_section_window, @@ -234,14 +266,15 @@ class WindowPaneToolbar: self.toolbar_vsplit = VSplit( sections, height=WindowPaneToolbar.TOOLBAR_HEIGHT, - style=get_toolbar_style, + style=wrapped_get_toolbar_style, ) self.container = self._create_toolbar_container(self.toolbar_vsplit) def _create_toolbar_container(self, content): return ConditionalContainer( - content, filter=Condition(lambda: self.show_toolbar)) + content, filter=Condition(lambda: self.show_toolbar) + ) def __pt_container__(self): """Return the prompt_toolkit root container for this log pane. diff --git a/pw_console/py/pw_console/window_list.py b/pw_console/py/pw_console/window_list.py index ec21b5810..2f543eb50 100644 --- a/pw_console/py/pw_console/window_list.py +++ b/pw_console/py/pw_console/window_list.py @@ -31,8 +31,7 @@ from prompt_toolkit.layout import ( ) from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton -import pw_console.style -import pw_console.widgets.mouse_handlers +from pw_console.widgets import mouse_handlers as pw_console_mouse_handlers if TYPE_CHECKING: # pylint: disable=ungrouped-imports @@ -43,6 +42,7 @@ _LOG = logging.getLogger(__package__) class DisplayMode(Enum): """WindowList display modes.""" + STACK = 'Stacked' TABBED = 'Tabbed' @@ -60,6 +60,7 @@ class WindowListHSplit(HSplit): of the container for the current render pass. It also handles overriding mouse handlers for triggering window resize adjustments. """ + def __init__(self, parent_window_list, *args, **kwargs): # Save a reference to the parent window pane. self.parent_window_list = parent_window_list @@ -78,8 +79,7 @@ class WindowListHSplit(HSplit): # Is resize mode active? if self.parent_window_list.resize_mode: # Ignore future mouse_handler updates. - new_mouse_handlers = ( - pw_console.widgets.mouse_handlers.EmptyMouseHandler()) + new_mouse_handlers = pw_console_mouse_handlers.EmptyMouseHandler() # Set existing mouse_handlers to the parent_window_list's # mouse_handler. This will handle triggering resize events. mouse_handlers.set_mouse_handler_for_range( @@ -87,15 +87,25 @@ class WindowListHSplit(HSplit): write_position.xpos + write_position.width, write_position.ypos, write_position.ypos + write_position.height, - self.parent_window_list.mouse_handler) + self.parent_window_list.mouse_handler, + ) # Save the width, height, and draw position for the current render pass. self.parent_window_list.update_window_list_size( - write_position.width, write_position.height, write_position.xpos, - write_position.ypos) + write_position.width, + write_position.height, + write_position.xpos, + write_position.ypos, + ) # Continue writing content to the screen. - super().write_to_screen(screen, new_mouse_handlers, write_position, - parent_style, erase_bg, z_index) + super().write_to_screen( + screen, + new_mouse_handlers, + write_position, + parent_style, + erase_bg, + z_index, + ) class WindowList: @@ -207,14 +217,16 @@ class WindowList: command_runner_focused_pane = None if self.application.command_runner_is_open(): command_runner_focused_pane = ( - self.application.command_runner_last_focused_pane()) + self.application.command_runner_last_focused_pane() + ) for index, pane in enumerate(self.active_panes): in_focus = False if has_focus(pane)(): in_focus = True elif command_runner_focused_pane and pane.has_child_container( - command_runner_focused_pane): + command_runner_focused_pane + ): in_focus = True if in_focus: @@ -224,6 +236,7 @@ class WindowList: return focused_pane def get_pane_titles(self, omit_subtitles=False, use_menu_title=True): + """Return formatted text for the window pane tab bar.""" fragments = [] separator = ('', ' ') fragments.append(separator) @@ -234,24 +247,39 @@ class WindowList: if omit_subtitles: text = f' {title} ' - fragments.append(( - # Style - ('class:window-tab-active' if pane_index - == self.focused_pane_index else 'class:window-tab-inactive'), - # Text - text, - # Mouse handler - functools.partial( - pw_console.widgets.mouse_handlers.on_click, - functools.partial(self.switch_to_tab, pane_index), - ), - )) + tab_style = ( + 'class:window-tab-active' + if pane_index == self.focused_pane_index + else 'class:window-tab-inactive' + ) + if pane.extra_tab_style: + tab_style += ' ' + pane.extra_tab_style + + fragments.append( + ( + # Style + tab_style, + # Text + text, + # Mouse handler + functools.partial( + pw_console_mouse_handlers.on_click, + functools.partial(self.switch_to_tab, pane_index), + ), + ) + ) fragments.append(separator) return fragments def switch_to_tab(self, index: int): self.focused_pane_index = index + # Make the selected tab visible and hide the rest. + for i, pane in enumerate(self.active_panes): + pane.show_pane = False + if i == index: + pane.show_pane = True + # refresh_ui() will focus on the new tab container. self.refresh_ui() @@ -259,8 +287,15 @@ class WindowList: self.display_mode = mode if self.display_mode == DisplayMode.TABBED: + # Default to focusing on the first window / tab. self.focused_pane_index = 0 - # Un-hide all panes, they must be visible to switch between tabs. + # Hide all other panes so log redraw events are not triggered. + for pane in self.active_panes: + pane.show_pane = False + # Keep the selected tab visible + self.active_panes[self.focused_pane_index].show_pane = True + else: + # Un-hide all panes if switching from tabbed back to stacked. for pane in self.active_panes: pane.show_pane = True @@ -274,7 +309,8 @@ class WindowList: if self.display_mode == DisplayMode.TABBED: self.application.focus_on_container( - self.active_panes[self.focused_pane_index]) + self.active_panes[self.focused_pane_index] + ) self.application.redraw_ui() @@ -300,8 +336,9 @@ class WindowList: self._set_window_heights(new_heights) - def update_window_list_size(self, width, height, xposition, - yposition) -> None: + def update_window_list_size( + self, width, height, xposition, yposition + ) -> None: """Save width and height of the repl pane for the current UI render pass.""" if width: @@ -311,24 +348,25 @@ class WindowList: self.last_window_list_height = self.current_window_list_height self.current_window_list_height = height if xposition: - self.last_window_list_xposition = ( - self.current_window_list_xposition) + self.last_window_list_xposition = self.current_window_list_xposition self.current_window_list_xposition = xposition if yposition: - self.last_window_list_yposition = ( - self.current_window_list_yposition) + self.last_window_list_yposition = self.current_window_list_yposition self.current_window_list_yposition = yposition - if (self.current_window_list_width != self.last_window_list_width - or self.current_window_list_height != - self.last_window_list_height): + if ( + self.current_window_list_width != self.last_window_list_width + or self.current_window_list_height != self.last_window_list_height + ): self.rebalance_window_heights() def mouse_handler(self, mouse_event: MouseEvent): mouse_position = mouse_event.position - if (mouse_event.event_type == MouseEventType.MOUSE_MOVE - and mouse_event.button == MouseButton.LEFT): + if ( + mouse_event.event_type == MouseEventType.MOUSE_MOVE + and mouse_event.button == MouseButton.LEFT + ): self.mouse_resize(mouse_position.x, mouse_position.y) elif mouse_event.event_type == MouseEventType.MOUSE_UP: self.stop_resize() @@ -366,16 +404,21 @@ class WindowList: def _create_window_tab_toolbar(self): tab_bar_control = FormattedTextControl( - functools.partial(self.get_pane_titles, - omit_subtitles=True, - use_menu_title=False)) - tab_bar_window = Window(content=tab_bar_control, - align=WindowAlign.LEFT, - dont_extend_width=True) + functools.partial( + self.get_pane_titles, omit_subtitles=True, use_menu_title=False + ) + ) + tab_bar_window = Window( + content=tab_bar_control, + align=WindowAlign.LEFT, + dont_extend_width=True, + ) - spacer = Window(content=FormattedTextControl([('', '')]), - align=WindowAlign.LEFT, - dont_extend_width=False) + spacer = Window( + content=FormattedTextControl([('', '')]), + align=WindowAlign.LEFT, + dont_extend_width=False, + ) tab_toolbar = VSplit( [ @@ -440,7 +483,8 @@ class WindowList: existing_pane_index -= 1 try: self.application.focus_on_container( - self.active_panes[existing_pane_index]) + self.active_panes[existing_pane_index] + ) except ValueError: # ValueError will be raised if the the pane at # existing_pane_index can't be accessed. @@ -481,15 +525,13 @@ class WindowList: self._update_resize_current_row() self.application.redraw_ui() - def adjust_pane_size(self, - pane, - diff: int = _WINDOW_HEIGHT_ADJUST) -> None: + def adjust_pane_size(self, pane, diff: int = _WINDOW_HEIGHT_ADJUST) -> None: """Increase or decrease a given pane's height.""" # Placeholder next_pane value to allow setting width and height without # any consequences if there is no next visible pane. - next_pane = HSplit([], - height=Dimension(preferred=10), - width=Dimension(preferred=10)) # type: ignore + next_pane = HSplit( + [], height=Dimension(preferred=10), width=Dimension(preferred=10) + ) # type: ignore # Try to get the next visible pane to subtract a weight value from. next_visible_pane = self._get_next_visible_pane_after(pane) if next_visible_pane: @@ -498,8 +540,9 @@ class WindowList: # If the last pane is selected, and there are at least 2 panes, make # next_pane the previous pane. try: - if len(self.active_panes) >= 2 and (self.active_panes.index(pane) - == len(self.active_panes) - 1): + if len(self.active_panes) >= 2 and ( + self.active_panes.index(pane) == len(self.active_panes) - 1 + ): next_pane = self.active_panes[-2] except ValueError: # Ignore ValueError raised if self.active_panes[-2] doesn't exist. @@ -535,8 +578,9 @@ class WindowList: old_values = [ p.height.preferred for p in self.active_panes if p.show_pane ] - new_heights = [int(available_height / len(old_values)) - ] * len(old_values) + new_heights = [int(available_height / len(old_values))] * len( + old_values + ) self._set_window_heights(new_heights) @@ -584,11 +628,3 @@ class WindowList: if next_pane.show_pane: return next_pane return None - - def focus_next_visible_pane(self, pane): - """Focus on the next visible window pane if possible.""" - next_visible_pane = self._get_next_visible_pane_after(pane) - if next_visible_pane: - self.application.layout.focus(next_visible_pane) - return - self.application.focus_main_menu() diff --git a/pw_console/py/pw_console/window_manager.py b/pw_console/py/pw_console/window_manager.py index 044751bf8..817e1d7ff 100644 --- a/pw_console/py/pw_console/window_manager.py +++ b/pw_console/py/pw_console/window_manager.py @@ -35,9 +35,11 @@ from prompt_toolkit.widgets import MenuItem from pw_console.console_prefs import ConsolePrefs, error_unknown_window from pw_console.log_pane import LogPane -import pw_console.widgets.checkbox -from pw_console.widgets import WindowPaneToolbar -import pw_console.widgets.mouse_handlers +from pw_console.widgets import ( + WindowPaneToolbar, + to_checkbox_text, +) +from pw_console.widgets import mouse_handlers as pw_console_mouse_handlers from pw_console.window_list import WindowList, DisplayMode _LOG = logging.getLogger(__package__) @@ -48,8 +50,10 @@ _WINDOW_SPLIT_ADJUST = 1 class WindowListResizeHandle(FormattedTextControl): """Button to initiate window list resize drag events.""" - def __init__(self, window_manager, window_list: Any, *args, - **kwargs) -> None: + + def __init__( + self, window_manager, window_list: Any, *args, **kwargs + ) -> None: self.window_manager = window_manager self.window_list = window_list super().__init__(*args, **kwargs) @@ -73,6 +77,7 @@ class WindowManagerVSplit(VSplit): of the container for the current render pass. It also handles overriding mouse handlers for triggering window resize adjustments. """ + def __init__(self, parent_window_manager, *args, **kwargs): # Save a reference to the parent window pane. self.parent_window_manager = parent_window_manager @@ -91,8 +96,7 @@ class WindowManagerVSplit(VSplit): # Is resize mode active? if self.parent_window_manager.resize_mode: # Ignore future mouse_handler updates. - new_mouse_handlers = ( - pw_console.widgets.mouse_handlers.EmptyMouseHandler()) + new_mouse_handlers = pw_console_mouse_handlers.EmptyMouseHandler() # Set existing mouse_handlers to the parent_window_managers's # mouse_handler. This will handle triggering resize events. mouse_handlers.set_mouse_handler_for_range( @@ -100,14 +104,22 @@ class WindowManagerVSplit(VSplit): write_position.xpos + write_position.width, write_position.ypos, write_position.ypos + write_position.height, - self.parent_window_manager.mouse_handler) + self.parent_window_manager.mouse_handler, + ) # Save the width and height for the current render pass. self.parent_window_manager.update_window_manager_size( - write_position.width, write_position.height) + write_position.width, write_position.height + ) # Continue writing content to the screen. - super().write_to_screen(screen, new_mouse_handlers, write_position, - parent_style, erase_bg, z_index) + super().write_to_screen( + screen, + new_mouse_handlers, + write_position, + parent_style, + erase_bg, + z_index, + ) class WindowManagerHSplit(HSplit): @@ -117,6 +129,7 @@ class WindowManagerHSplit(HSplit): of the container for the current render pass. It also handles overriding mouse handlers for triggering window resize adjustments. """ + def __init__(self, parent_window_manager, *args, **kwargs): # Save a reference to the parent window pane. self.parent_window_manager = parent_window_manager @@ -135,8 +148,7 @@ class WindowManagerHSplit(HSplit): # Is resize mode active? if self.parent_window_manager.resize_mode: # Ignore future mouse_handler updates. - new_mouse_handlers = ( - pw_console.widgets.mouse_handlers.EmptyMouseHandler()) + new_mouse_handlers = pw_console_mouse_handlers.EmptyMouseHandler() # Set existing mouse_handlers to the parent_window_managers's # mouse_handler. This will handle triggering resize events. mouse_handlers.set_mouse_handler_for_range( @@ -144,14 +156,22 @@ class WindowManagerHSplit(HSplit): write_position.xpos + write_position.width, write_position.ypos, write_position.ypos + write_position.height, - self.parent_window_manager.mouse_handler) + self.parent_window_manager.mouse_handler, + ) # Save the width and height for the current render pass. self.parent_window_manager.update_window_manager_size( - write_position.width, write_position.height) + write_position.width, write_position.height + ) # Continue writing content to the screen. - super().write_to_screen(screen, new_mouse_handlers, write_position, - parent_style, erase_bg, z_index) + super().write_to_screen( + screen, + new_mouse_handlers, + write_position, + parent_style, + erase_bg, + z_index, + ) class WindowManager: @@ -193,13 +213,16 @@ class WindowManager: self.last_window_manager_height = self.current_window_manager_height self.current_window_manager_height = height - if (self.current_window_manager_width != self.last_window_manager_width - or self.current_window_manager_height != - self.last_window_manager_height): + if ( + self.current_window_manager_width != self.last_window_manager_width + or self.current_window_manager_height + != self.last_window_manager_height + ): self.rebalance_window_list_sizes() - def _set_window_list_sizes(self, new_heights: List[int], - new_widths: List[int]) -> None: + def _set_window_list_sizes( + self, new_heights: List[int], new_widths: List[int] + ) -> None: for window_list in self.window_lists: window_list.height = Dimension(preferred=new_heights[0]) new_heights = new_heights[1:] @@ -221,9 +244,7 @@ class WindowManager: old_height_total = max(sum(old_heights), 1) old_width_total = max(sum(old_widths), 1) - height_percentages = [ - value / old_height_total for value in old_heights - ] + height_percentages = [value / old_height_total for value in old_heights] width_percentages = [value / old_width_total for value in old_widths] new_heights = [ @@ -240,9 +261,7 @@ class WindowManager: self.current_window_manager_height for h in new_heights ] else: - new_widths = [ - self.current_window_manager_width for h in new_widths - ] + new_widths = [self.current_window_manager_width for h in new_widths] self._set_window_list_sizes(new_heights, new_widths) @@ -309,7 +328,8 @@ class WindowManager: def delete_empty_window_lists(self): empty_lists = [ - window_list for window_list in self.window_lists + window_list + for window_list in self.window_lists if window_list.empty() ] for empty_list in empty_lists: @@ -348,7 +368,8 @@ class WindowManager: separator_padding, Window( content=WindowListResizeHandle( - self, window_list, "║\n║\n║"), + self, window_list, "║\n║\n║" + ), char='│', width=1, dont_extend_height=True, @@ -382,7 +403,8 @@ class WindowManager: def update_root_container_body(self): # Replace the root MenuContainer body with the new split. self.application.root_container.container.content.children[ - 1] = self.create_root_container() + 1 + ] = self.create_root_container() def _get_active_window_list_and_pane(self): active_pane = None @@ -405,8 +427,10 @@ class WindowManager: return index def run_action_on_active_pane(self, function_name): - _active_window_list, active_pane = ( - self._get_active_window_list_and_pane()) + ( + _active_window_list, + active_pane, + ) = self._get_active_window_list_and_pane() if not hasattr(active_pane, function_name): return method_to_call = getattr(active_pane, function_name) @@ -419,8 +443,10 @@ class WindowManager: def focus_next_pane(self, reverse_order=False) -> None: """Focus on the next visible window pane or tab.""" - active_window_list, active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + active_pane, + ) = self._get_active_window_list_and_pane() if active_window_list is None: return @@ -442,15 +468,17 @@ class WindowManager: # Action: Switch to the first pane of the next window list. if next_pane_index >= pane_count or next_pane_index < 0: # Get the next window_list - next_window_list_index = ((active_window_list_index + increment) % - window_list_count) + next_window_list_index = ( + active_window_list_index + increment + ) % window_list_count next_window_list = self.window_lists[next_window_list_index] # If tabbed window mode is enabled, switch to the first tab. if next_window_list.display_mode == DisplayMode.TABBED: if reverse_order: next_window_list.switch_to_tab( - len(next_window_list.active_panes) - 1) + len(next_window_list.active_panes) - 1 + ) else: next_window_list.switch_to_tab(0) return @@ -484,8 +512,10 @@ class WindowManager: return def move_pane_left(self): - active_window_list, active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return @@ -512,8 +542,10 @@ class WindowManager: self.delete_empty_window_lists() def move_pane_right(self): - active_window_list, active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return @@ -538,32 +570,40 @@ class WindowManager: self.delete_empty_window_lists() def move_pane_up(self): - active_window_list, _active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + _active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return active_window_list.move_pane_up() def move_pane_down(self): - active_window_list, _active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + _active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return active_window_list.move_pane_down() def shrink_pane(self): - active_window_list, _active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + _active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return active_window_list.shrink_pane() def enlarge_pane(self): - active_window_list, _active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + _active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return @@ -573,16 +613,20 @@ class WindowManager: if len(self.window_lists) < 2: return - active_window_list, _active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + _active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return self.adjust_split_size(active_window_list, -_WINDOW_SPLIT_ADJUST) def enlarge_split(self): - active_window_list, _active_pane = ( - self._get_active_window_list_and_pane()) + ( + active_window_list, + _active_pane, + ) = self._get_active_window_list_and_pane() if not active_window_list: return @@ -599,20 +643,23 @@ class WindowManager: available_width = self.current_window_manager_width old_heights = [w.height.preferred for w in self.window_lists] old_widths = [w.width.preferred for w in self.window_lists] - new_heights = [int(available_height / len(old_heights)) - ] * len(old_heights) + new_heights = [int(available_height / len(old_heights))] * len( + old_heights + ) new_widths = [int(available_width / len(old_widths))] * len(old_widths) self._set_window_list_sizes(new_heights, new_widths) def _get_next_window_list_for_resizing( - self, window_list: WindowList) -> Optional[WindowList]: + self, window_list: WindowList + ) -> Optional[WindowList]: window_list_index = self.window_list_index(window_list) if window_list_index is None: return None - next_window_list_index = ((window_list_index + 1) % - len(self.window_lists)) + next_window_list_index = (window_list_index + 1) % len( + self.window_lists + ) # Use the previous window if we are on the last split if window_list_index == len(self.window_lists) - 1: @@ -621,9 +668,9 @@ class WindowManager: next_window_list = self.window_lists[next_window_list_index] return next_window_list - def adjust_split_size(self, - window_list: WindowList, - diff: int = _WINDOW_SPLIT_ADJUST) -> None: + def adjust_split_size( + self, window_list: WindowList, diff: int = _WINDOW_SPLIT_ADJUST + ) -> None: """Increase or decrease a given window_list's vertical split width.""" # No need to resize if only one split. if len(self.window_lists) < 2: @@ -670,8 +717,7 @@ class WindowManager: def toggle_pane(self, pane): """Toggle a pane on or off.""" - window_list, _pane_index = ( - self._find_window_list_and_pane_index(pane)) + window_list, _pane_index = self.find_window_list_and_pane_index(pane) # Don't hide the window if tabbed mode is enabled. Switching to a # separate tab is preffered. @@ -695,8 +741,9 @@ class WindowManager: def check_for_all_hidden_panes_and_unhide(self) -> None: """Scan for window_lists containing only hidden panes.""" for window_list in self.window_lists: - all_hidden = all(not pane.show_pane - for pane in window_list.active_panes) + all_hidden = all( + not pane.show_pane for pane in window_list.active_panes + ) if all_hidden: # Unhide the first pane self.toggle_pane(window_list.active_panes[0]) @@ -713,17 +760,19 @@ class WindowManager: def active_panes(self): """Return all active panes from all window lists.""" return chain.from_iterable( - map(operator.attrgetter('active_panes'), self.window_lists)) + map(operator.attrgetter('active_panes'), self.window_lists) + ) def start_resize_pane(self, pane): - window_list, pane_index = self._find_window_list_and_pane_index(pane) + window_list, pane_index = self.find_window_list_and_pane_index(pane) window_list.start_resize(pane, pane_index) def mouse_resize(self, xpos, ypos): if self.resize_target_window_list_index is None: return target_window_list = self.window_lists[ - self.resize_target_window_list_index] + self.resize_target_window_list_index + ] diff = ypos - self.resize_current_row if self.vertical_window_list_spliting(): @@ -739,8 +788,10 @@ class WindowManager: """MouseHandler used when resize_mode == True.""" mouse_position = mouse_event.position - if (mouse_event.event_type == MouseEventType.MOUSE_MOVE - and mouse_event.button == MouseButton.LEFT): + if ( + mouse_event.event_type == MouseEventType.MOUSE_MOVE + and mouse_event.button == MouseButton.LEFT + ): self.mouse_resize(mouse_position.x, mouse_position.y) elif mouse_event.event_type == MouseEventType.MOUSE_UP: self.stop_resize() @@ -829,7 +880,7 @@ class WindowManager: self.resize_current_row = 0 self.resize_current_column = 0 - def _find_window_list_and_pane_index(self, pane: Any): + def find_window_list_and_pane_index(self, pane: Any): pane_index = None parent_window_list = None for window_list in self.window_lists: @@ -840,8 +891,9 @@ class WindowManager: return parent_window_list, pane_index def remove_pane(self, existing_pane: Any): - window_list, _pane_index = ( - self._find_window_list_and_pane_index(existing_pane)) + window_list, _pane_index = self.find_window_list_and_pane_index( + existing_pane + ) if window_list: window_list.remove_pane(existing_pane) # Reset focus if this list is empty @@ -853,7 +905,8 @@ class WindowManager: window_list.reset_pane_sizes() def _remove_panes_from_layout( - self, pane_titles: Iterable[str]) -> Dict[str, Any]: + self, pane_titles: Iterable[str] + ) -> Dict[str, Any]: # Gather pane objects and remove them from the window layout. collected_panes = {} @@ -862,11 +915,14 @@ class WindowManager: # iterating. for pane in copy.copy(window_list.active_panes): if pane.pane_title() in pane_titles: - collected_panes[pane.pane_title()] = ( - window_list.remove_pane_no_checks(pane)) + collected_panes[ + pane.pane_title() + ] = window_list.remove_pane_no_checks(pane) return collected_panes - def _set_pane_options(self, pane, options: dict) -> None: # pylint: disable=no-self-use + def _set_pane_options( # pylint: disable=no-self-use + self, pane, options: dict + ) -> None: if options.get('hidden', False): # Hide this pane pane.show_pane = False @@ -884,17 +940,19 @@ class WindowManager: mode = DisplayMode.TABBED self.window_lists[column_index].set_display_mode(mode) - def _create_new_log_pane_with_loggers(self, window_title, window_options, - existing_pane_titles) -> LogPane: + def _create_new_log_pane_with_loggers( + self, window_title, window_options, existing_pane_titles + ) -> LogPane: if 'loggers' not in window_options: error_unknown_window(window_title, existing_pane_titles) - new_pane = LogPane(application=self.application, - pane_title=window_title) + new_pane = LogPane( + application=self.application, pane_title=window_title + ) # Add logger handlers - for logger_name, logger_options in window_options.get('loggers', - {}).items(): - + for logger_name, logger_options in window_options.get( + 'loggers', {} + ).items(): log_level_name = logger_options.get('level', None) new_pane.add_log_handler(logger_name, level_name=log_level_name) return new_pane @@ -908,14 +966,17 @@ class WindowManager: unique_titles = prefs.unique_window_titles collected_panes = self._remove_panes_from_layout(unique_titles) existing_pane_titles = [ - p.pane_title() for p in collected_panes.values() + p.pane_title() + for p in collected_panes.values() if isinstance(p, LogPane) ] # Keep track of original non-duplicated pane titles already_added_panes = [] - for column_index, column in enumerate(prefs.windows.items()): # pylint: disable=too-many-nested-blocks + for column_index, column in enumerate( + prefs.windows.items() + ): # pylint: disable=too-many-nested-blocks _column_type, windows = column # Add a new window_list if needed if column_index >= len(self.window_lists): @@ -934,11 +995,14 @@ class WindowManager: # Check if this pane is brand new, ready to be added, or should # be duplicated. - if (window_title not in already_added_panes - and window_title not in collected_panes): + if ( + window_title not in already_added_panes + and window_title not in collected_panes + ): # New pane entirely new_pane = self._create_new_log_pane_with_loggers( - window_title, window_options, existing_pane_titles) + window_title, window_options, existing_pane_titles + ) elif window_title not in already_added_panes: # First time adding this pane @@ -956,11 +1020,13 @@ class WindowManager: # Set window size and visibility self._set_pane_options(new_pane, window_options) # Add the new pane - self.window_lists[column_index].add_pane_no_checks( - new_pane) - # Apply log filters + self.window_lists[column_index].add_pane_no_checks(new_pane) + # Apply log pane options if isinstance(new_pane, LogPane): new_pane.apply_filters_from_config(window_options) + # Auto-start the websocket log server if requested. + if window_options.get('view_in_web', False): + new_pane.toggle_websocket_server() # Update column display modes. self._set_window_list_display_modes(prefs) @@ -974,7 +1040,7 @@ class WindowManager: # Focus on the first visible pane. self.focus_first_visible_pane() - def create_window_menu(self): + def create_window_menu_items(self) -> List[MenuItem]: """Build the [Window] menu for the current set of window lists.""" root_menu_items = [] for window_list_index, window_list in enumerate(self.window_lists): @@ -982,21 +1048,25 @@ class WindowManager: menu_items.append( MenuItem( 'Column {index} View Modes'.format( - index=window_list_index + 1), + index=window_list_index + 1 + ), children=[ MenuItem( '{check} {display_mode} Windows'.format( display_mode=display_mode.value, - check=pw_console.widgets.checkbox. - to_checkbox_text( + check=to_checkbox_text( window_list.display_mode == display_mode, end='', - )), + ), + ), handler=functools.partial( - window_list.set_display_mode, display_mode), - ) for display_mode in DisplayMode + window_list.set_display_mode, display_mode + ), + ) + for display_mode in DisplayMode ], - )) + ) + ) menu_items.extend( MenuItem( '{index}: {title}'.format( @@ -1006,25 +1076,25 @@ class WindowManager: children=[ MenuItem( '{check} Show/Hide Window'.format( - check=pw_console.widgets.checkbox. - to_checkbox_text(pane.show_pane, end='')), + check=to_checkbox_text(pane.show_pane, end='') + ), handler=functools.partial(self.toggle_pane, pane), ), - ] + [ - MenuItem(text, - handler=functools.partial( - self.application.run_pane_menu_option, - handler)) - for text, handler in pane.get_all_menu_options() + ] + + [ + MenuItem( + text, + handler=functools.partial( + self.application.run_pane_menu_option, handler + ), + ) + for text, handler in pane.get_window_menu_options() ], - ) for pane_index, pane in enumerate(window_list.active_panes)) + ) + for pane_index, pane in enumerate(window_list.active_panes) + ) if window_list_index + 1 < len(self.window_lists): menu_items.append(MenuItem('-')) root_menu_items.extend(menu_items) - menu = MenuItem( - '[Windows]', - children=root_menu_items, - ) - - return [menu] + return root_menu_items diff --git a/pw_console/py/pw_console/yaml_config_loader_mixin.py b/pw_console/py/pw_console/yaml_config_loader_mixin.py deleted file mode 100644 index ff009a0f9..000000000 --- a/pw_console/py/pw_console/yaml_config_loader_mixin.py +++ /dev/null @@ -1,154 +0,0 @@ -# Copyright 2022 The Pigweed Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may not -# use this file except in compliance with the License. You may obtain a copy of -# the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations under -# the License. -"""Yaml config file loader mixin.""" - -import os -import logging -from pathlib import Path -from typing import Any, Dict, Optional, Union - -import yaml - -_LOG = logging.getLogger(__package__) - - -class MissingConfigTitle(Exception): - """Exception for when an existing YAML file is missing config_title.""" - - -class YamlConfigLoaderMixin: - """Yaml Config file loader mixin. - - Use this mixin to load yaml file settings and save them into - ``self._config``. For example: - - :: - - class ConsolePrefs(YamlConfigLoaderMixin): - def __init__( - self.config_init( - config_section_title='pw_console', - project_file=Path('project_file.yaml'), - project_user_file=Path('project_user_file.yaml'), - user_file=Path('~/user_file.yaml'), - default_config={}, - environment_var='PW_CONSOLE_CONFIG_FILE', - ) - - """ - def config_init( - self, - config_section_title: str, - project_file: Union[Path, bool] = None, - project_user_file: Union[Path, bool] = None, - user_file: Union[Path, bool] = None, - default_config: Optional[Dict[Any, Any]] = None, - environment_var: Optional[str] = None, - ) -> None: - """Call this to load YAML config files in order of precedence. - - The following files are loaded in this order: - 1. project_file - 2. project_user_file - 3. user_file - - Lastly, if a valid file path is specified at - ``os.environ[environment_var]`` then load that file overriding all - config options. - - Args: - config_section_title: String name of this config section. For - example: ``pw_console`` or ``pw_watch``. In the YAML file this - is represented by a ``config_title`` key. - - :: - - --- - config_title: pw_console - - project_file: Project level config file. This is intended to be a - file living somewhere under a project folder and is checked into - the repo. It serves as a base config all developers can inherit - from. - project_user_file: User's personal config file for a specific - project. This can be a file that lives in a project folder that - is git-ignored and not checked into the repo. - user_file: A global user based config file. This is typically a file - in the users home directory and settings here apply to all - projects. - default_config: A Python dict representing the base default - config. This dict will be applied as a starting point before - loading any yaml files. - environment_var: The name of an environment variable to check for a - config file. If a config file exists there it will be loaded on - top of the default_config ignoring project and user files. - """ - - self._config_section_title: str = config_section_title - self.default_config = default_config if default_config else {} - self.reset_config() - - if project_file and isinstance(project_file, Path): - self.project_file = Path( - os.path.expandvars(str(project_file.expanduser()))) - self.load_config_file(self.project_file) - - if project_user_file and isinstance(project_user_file, Path): - self.project_user_file = Path( - os.path.expandvars(str(project_user_file.expanduser()))) - self.load_config_file(self.project_user_file) - - if user_file and isinstance(user_file, Path): - self.user_file = Path( - os.path.expandvars(str(user_file.expanduser()))) - self.load_config_file(self.user_file) - - # Check for a config file specified by an environment variable. - if environment_var is None: - return - environment_config = os.environ.get(environment_var, None) - if environment_config: - env_file_path = Path(environment_config) - if not env_file_path.is_file(): - raise FileNotFoundError( - f'Cannot load config file: {env_file_path}') - self.reset_config() - self.load_config_file(env_file_path) - - def _update_config(self, cfg: Dict[Any, Any]) -> None: - if cfg is None: - cfg = {} - self._config.update(cfg) - - def reset_config(self) -> None: - self._config: Dict[Any, Any] = {} - self._update_config(self.default_config) - - def load_config_file(self, file_path: Path) -> None: - if not file_path.is_file(): - return - - cfgs = yaml.safe_load_all(file_path.read_text()) - - for cfg in cfgs: - if self._config_section_title in cfg: - self._update_config(cfg[self._config_section_title]) - - elif cfg.get('config_title', False) == self._config_section_title: - self._update_config(cfg) - else: - raise MissingConfigTitle( - '\n\nThe YAML config file "{}" is missing the expected ' - '"config_title: {}" setting.'.format( - str(file_path), self._config_section_title)) diff --git a/pw_console/py/repl_pane_test.py b/pw_console/py/repl_pane_test.py index 86f41cea7..db325b6ce 100644 --- a/pw_console/py/repl_pane_test.py +++ b/pw_console/py/repl_pane_test.py @@ -44,6 +44,7 @@ if _PYTHON_3_8: class TestReplPane(IsolatedAsyncioTestCase): """Tests for ReplPane.""" + def setUp(self): # pylint: disable=invalid-name self.maxDiff = None # pylint: disable=invalid-name @@ -62,7 +63,8 @@ if _PYTHON_3_8: pw_ptpython_repl = PwPtPythonRepl( get_globals=lambda: global_vars, get_locals=lambda: global_vars, - color_depth=ColorDepth.DEPTH_8_BIT) + color_depth=ColorDepth.DEPTH_8_BIT, + ) repl_pane = ReplPane( application=app, python_repl=pw_ptpython_repl, @@ -71,53 +73,60 @@ if _PYTHON_3_8: self.assertEqual(repl_pane, pw_ptpython_repl.repl_pane) # Define a function, should return nothing. - code = inspect.cleandoc(""" + code = inspect.cleandoc( + """ def run(): print('The answer is ', end='') return 1+1+4+16+20 - """) + """ + ) temp_stdout = io.StringIO() temp_stderr = io.StringIO() # pylint: disable=protected-access result = asyncio.run( - pw_ptpython_repl._run_user_code(code, temp_stdout, - temp_stderr)) - self.assertEqual(result, { - 'stdout': '', - 'stderr': '', - 'result': None - }) + pw_ptpython_repl._run_user_code(code, temp_stdout, temp_stderr) + ) + self.assertEqual( + result, {'stdout': '', 'stderr': '', 'result': None} + ) temp_stdout = io.StringIO() temp_stderr = io.StringIO() # Check stdout and return value result = asyncio.run( - pw_ptpython_repl._run_user_code('run()', temp_stdout, - temp_stderr)) - self.assertEqual(result, { - 'stdout': 'The answer is ', - 'stderr': '', - 'result': 42 - }) + pw_ptpython_repl._run_user_code( + 'run()', temp_stdout, temp_stderr + ) + ) + self.assertEqual( + result, {'stdout': 'The answer is ', 'stderr': '', 'result': 42} + ) temp_stdout = io.StringIO() temp_stderr = io.StringIO() # Check for repl exception result = asyncio.run( - pw_ptpython_repl._run_user_code('return "blah"', temp_stdout, - temp_stderr)) - self.assertIn("SyntaxError: 'return' outside function", - pw_ptpython_repl._last_exception) # type: ignore + pw_ptpython_repl._run_user_code( + 'return "blah"', temp_stdout, temp_stderr + ) + ) + self.assertIn( + "SyntaxError: 'return' outside function", + pw_ptpython_repl._last_exception, # type: ignore + ) async def test_user_thread(self) -> None: """Test user code thread.""" with create_app_session(output=FakeOutput()): # Setup Mocks - app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, - prefs=ConsolePrefs(project_file=False, - project_user_file=False, - user_file=False)) + prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) + prefs.set_code_theme('default') + app = ConsoleApp( + color_depth=ColorDepth.DEPTH_8_BIT, prefs=prefs + ) app.start_user_code_thread() @@ -125,18 +134,23 @@ if _PYTHON_3_8: repl_pane = app.repl_pane # Mock update_output_buffer to track number of update calls - repl_pane.update_output_buffer = MagicMock( - wraps=repl_pane.update_output_buffer) + repl_pane.update_output_buffer = MagicMock( # type: ignore + wraps=repl_pane.update_output_buffer + ) # Mock complete callback - pw_ptpython_repl.user_code_complete_callback = MagicMock( - wraps=pw_ptpython_repl.user_code_complete_callback) + pw_ptpython_repl.user_code_complete_callback = ( # type: ignore + MagicMock( + wraps=pw_ptpython_repl.user_code_complete_callback + ) + ) # Repl done flag for tests user_code_done = threading.Event() # Run some code - code = inspect.cleandoc(""" + code = inspect.cleandoc( + """ import time def run(): for i in range(2): @@ -144,20 +158,24 @@ if _PYTHON_3_8: print(i) print('The answer is ', end='') return 1+1+4+16+20 - """) + """ + ) input_buffer = MagicMock(text=code) - pw_ptpython_repl._accept_handler(input_buffer) # pylint: disable=protected-access + # pylint: disable=protected-access + pw_ptpython_repl._accept_handler(input_buffer) + # pylint: enable=protected-access # Get last executed code object. user_code1 = repl_pane.executed_code[-1] # Wait for repl code to finish. user_code1.future.add_done_callback( - lambda future: user_code_done.set()) + lambda future: user_code_done.set() + ) # Wait for stdout monitoring to complete. if user_code1.stdout_check_task: await user_code1.stdout_check_task # Wait for test done callback. - user_code_done.wait(timeout=3) + user_code_done.wait() # Check user_code1 results # NOTE: Avoid using assert_has_calls. Thread timing can make the @@ -171,11 +189,14 @@ if _PYTHON_3_8: call('pw_ptpython_repl.user_code_complete_callback'), ] for expected_call in expected_calls: - self.assertIn(expected_call, - repl_pane.update_output_buffer.mock_calls) + self.assertIn( + expected_call, repl_pane.update_output_buffer.mock_calls + ) - pw_ptpython_repl.user_code_complete_callback.assert_called_once( + user_code_complete_callback = ( + pw_ptpython_repl.user_code_complete_callback ) + user_code_complete_callback.assert_called_once() self.assertIsNotNone(user_code1) self.assertTrue(user_code1.future.done()) @@ -192,18 +213,21 @@ if _PYTHON_3_8: # Run some code input_buffer = MagicMock(text='run()') - pw_ptpython_repl._accept_handler(input_buffer) # pylint: disable=protected-access + # pylint: disable=protected-access + pw_ptpython_repl._accept_handler(input_buffer) + # pylint: enable=protected-access # Get last executed code object. user_code2 = repl_pane.executed_code[-1] # Wait for repl code to finish. user_code2.future.add_done_callback( - lambda future: user_code_done.set()) + lambda future: user_code_done.set() + ) # Wait for stdout monitoring to complete. if user_code2.stdout_check_task: await user_code2.stdout_check_task # Wait for test done callback. - user_code_done.wait(timeout=3) + user_code_done.wait() # Check user_code2 results # NOTE: Avoid using assert_has_calls. Thread timing can make the @@ -226,11 +250,13 @@ if _PYTHON_3_8: call('repl_pane.periodic_check'), ] for expected_call in expected_calls: - self.assertIn(expected_call, - repl_pane.update_output_buffer.mock_calls) + self.assertIn( + expected_call, repl_pane.update_output_buffer.mock_calls + ) - pw_ptpython_repl.user_code_complete_callback.assert_called_once( - ) + # pylint: disable=line-too-long + pw_ptpython_repl.user_code_complete_callback.assert_called_once() + # pylint: enable=line-too-long self.assertIsNotNone(user_code2) self.assertTrue(user_code2.future.done()) self.assertEqual(user_code2.input, 'run()') diff --git a/pw_console/py/setup.cfg b/pw_console/py/setup.cfg index 8d3ce30ab..60021a431 100644 --- a/pw_console/py/setup.cfg +++ b/pw_console/py/setup.cfg @@ -24,27 +24,26 @@ zip_safe = False install_requires = ipython jinja2 - prompt_toolkit>=3.0.26 + prompt-toolkit>=3.0.26 ptpython>=3.0.20 - pw_cli - pw_tokenizer pygments - pygments-style-dracula - pygments-style-tomorrow pyperclip + pyserial>=3.5,<4.0 pyyaml types-pygments - types-PyYAML + types-pyserial>=3.5,<4.0 + types-pyyaml + websockets [options.entry_points] console_scripts = pw-console = pw_console.__main__:main -pygments.styles = - pigweed-code = pw_console.pigweed_code_style:PigweedCodeStyle - pigweed-code-light = pw_console.pigweed_code_style:PigweedCodeLightStyle [options.package_data] pw_console = docs/user_guide.rst + html/index.html + html/main.js + html/style.css py.typed templates/keybind_list.jinja templates/repl_output.jinja diff --git a/pw_console/py/table_test.py b/pw_console/py/table_test.py index d8896c926..6714c230f 100644 --- a/pw_console/py/table_test.py +++ b/pw_console/py/table_test.py @@ -39,72 +39,83 @@ formatter = logging.Formatter( '%(levelname)s' '\x1b[0m' ' ' - '%(message)s', _TIMESTAMP_FORMAT) + '%(message)s', + _TIMESTAMP_FORMAT, +) def make_log(**kwargs): """Create a LogLine instance.""" # Construct a LogRecord - attributes = dict(name='testlogger', - levelno=logging.INFO, - levelname='INF', - msg='[%s] %.3f %s', - args=('MOD1', 3.14159, 'Real message here'), - created=_TIMESTAMP_SAMPLE.timestamp(), - filename='test.py', - lineno=42, - pathname='/home/user/test.py') + attributes = dict( + name='testlogger', + levelno=logging.INFO, + levelname='INF', + msg='[%s] %.3f %s', + args=('MOD1', 3.14159, 'Real message here'), + created=_TIMESTAMP_SAMPLE.timestamp(), + filename='test.py', + lineno=42, + pathname='/home/user/test.py', + ) # Override any above attrs that are passed in. attributes.update(kwargs) # Create the record record = logging.makeLogRecord(dict(attributes)) # Format formatted_message = formatter.format(record) - log_line = LogLine(record=record, - formatted_log=formatted_message, - ansi_stripped_log='') + log_line = LogLine( + record=record, formatted_log=formatted_message, ansi_stripped_log='' + ) log_line.update_metadata() return log_line class TestTableView(unittest.TestCase): """Tests for rendering log lines into tables.""" + def setUp(self): # Show large diffs self.maxDiff = None # pylint: disable=invalid-name - self.prefs = ConsolePrefs(project_file=False, - project_user_file=False, - user_file=False) + self.prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) self.prefs.reset_config() - @parameterized.expand([ - ( - 'Correct column widths with all fields set', - [ - make_log( - args=('M1', 1.2345, 'Something happened'), - extra_metadata_fields=dict(module='M1', anumber=12)), - - make_log( - args=('MD2', 567.5, 'Another cool event'), - extra_metadata_fields=dict(module='MD2', anumber=123)), - ], - dict(module=len('MD2'), anumber=len('123')), - ), - ( - 'Missing metadata fields on some rows', - [ - make_log( - args=('M1', 54321.2, 'Something happened'), - extra_metadata_fields=dict(module='M1', anumber=54321.2)), - - make_log( - args=('MOD2', 567.5, 'Another cool event'), - extra_metadata_fields=dict(module='MOD2')), - ], - dict(module=len('MOD2'), anumber=len('54321.200')), - ), - ]) # yapf: disable + @parameterized.expand( + [ + ( + 'Correct column widths with all fields set', + [ + make_log( + args=('M1', 1.2345, 'Something happened'), + extra_metadata_fields=dict(module='M1', anumber=12), + ), + make_log( + args=('MD2', 567.5, 'Another cool event'), + extra_metadata_fields=dict(module='MD2', anumber=123), + ), + ], + dict(module=len('MD2'), anumber=len('123')), + ), + ( + 'Missing metadata fields on some rows', + [ + make_log( + args=('M1', 54321.2, 'Something happened'), + extra_metadata_fields=dict( + module='M1', anumber=54321.2 + ), + ), + make_log( + args=('MOD2', 567.5, 'Another cool event'), + extra_metadata_fields=dict(module='MOD2'), + ), + ], + dict(module=len('MOD2'), anumber=len('54321.200')), + ), + ] + ) def test_column_widths(self, _name, logs, expected_widths) -> None: """Test colum widths calculation.""" table = TableView(self.prefs) @@ -125,44 +136,53 @@ class TestTableView(unittest.TestCase): } self.assertCountEqual(expected_widths, results) - @parameterized.expand([ - ( - 'Build header adding fields incrementally', - [ - make_log( - args=('MODULE2', 567.5, 'Another cool event'), - extra_metadata_fields=dict( - # timestamp missing - module='MODULE2')), - - make_log( - args=('MODULE1', 54321.2, 'Something happened'), - extra_metadata_fields=dict( + @parameterized.expand( + [ + ( + 'Build header adding fields incrementally', + [ + make_log( + args=('MODULE2', 567.5, 'Another cool event'), + extra_metadata_fields=dict( + # timestamp missing + module='MODULE2' + ), + ), + make_log( + args=('MODULE1', 54321.2, 'Something happened'), + extra_metadata_fields=dict( + # timestamp added in + module='MODULE1', + timestamp=54321.2, + ), + ), + ], + [ + [ + ('bold', 'Time '), + _TABLE_PADDING_FRAGMENT, + ('bold', 'Lev'), + _TABLE_PADDING_FRAGMENT, + ('bold', 'Module '), + _TABLE_PADDING_FRAGMENT, + ('bold', 'Message'), + ], + [ + ('bold', 'Time '), + _TABLE_PADDING_FRAGMENT, + ('bold', 'Lev'), + _TABLE_PADDING_FRAGMENT, + ('bold', 'Module '), + _TABLE_PADDING_FRAGMENT, # timestamp added in - module='MODULE1', timestamp=54321.2)), - ], - [ - [('bold', 'Time '), - _TABLE_PADDING_FRAGMENT, - ('bold', 'Lev'), - _TABLE_PADDING_FRAGMENT, - ('bold', 'Module '), - _TABLE_PADDING_FRAGMENT, - ('bold', 'Message')], - - [('bold', 'Time '), - _TABLE_PADDING_FRAGMENT, - ('bold', 'Lev'), - _TABLE_PADDING_FRAGMENT, - ('bold', 'Module '), - _TABLE_PADDING_FRAGMENT, - # timestamp added in - ('bold', 'Timestamp'), - _TABLE_PADDING_FRAGMENT, - ('bold', 'Message')], - ], - ), - ]) # yapf: disable + ('bold', 'Timestamp'), + _TABLE_PADDING_FRAGMENT, + ('bold', 'Message'), + ], + ], + ), + ] + ) def test_formatted_header(self, _name, logs, expected_headers) -> None: """Test colum widths calculation.""" table = TableView(self.prefs) @@ -171,68 +191,73 @@ class TestTableView(unittest.TestCase): table.update_metadata_column_widths(log) self.assertEqual(table.formatted_header(), header) - @parameterized.expand([ - ( - 'Build rows adding fields incrementally', - [ - make_log( - args=('MODULE2', 567.5, 'Another cool event'), - extra_metadata_fields=dict( - # timestamp missing - module='MODULE2', - msg='Another cool event')), - - make_log( - args=('MODULE2', 567.5, 'Another cool event'), - extra_metadata_fields=dict( - # timestamp and msg missing - module='MODULE2')), - - make_log( - args=('MODULE1', 54321.2, 'Something happened'), - extra_metadata_fields=dict( - # timestamp added in - module='MODULE1', timestamp=54321.2, - msg='Something happened')), - ], - [ + @parameterized.expand( + [ + ( + 'Build rows adding fields incrementally', [ - ('class:log-time', _TIMESTAMP_SAMPLE_STRING), - _TABLE_PADDING_FRAGMENT, - ('class:log-level-20', 'INF'), - _TABLE_PADDING_FRAGMENT, - ('class:log-table-column-0', 'MODULE2'), - _TABLE_PADDING_FRAGMENT, - ('', 'Another cool event'), - ('', '\n') + make_log( + args=('MODULE2', 567.5, 'Another cool event'), + extra_metadata_fields=dict( + # timestamp missing + module='MODULE2', + msg='Another cool event', + ), + ), + make_log( + args=('MODULE2', 567.5, 'Another cool event'), + extra_metadata_fields=dict( + # timestamp and msg missing + module='MODULE2' + ), + ), + make_log( + args=('MODULE1', 54321.2, 'Something happened'), + extra_metadata_fields=dict( + # timestamp added in + module='MODULE1', + timestamp=54321.2, + msg='Something happened', + ), + ), ], - - [ - ('class:log-time', _TIMESTAMP_SAMPLE_STRING), - _TABLE_PADDING_FRAGMENT, - ('class:log-level-20', 'INF'), - _TABLE_PADDING_FRAGMENT, - ('class:log-table-column-0', 'MODULE2'), - _TABLE_PADDING_FRAGMENT, - ('', '[MODULE2] 567.500 Another cool event'), - ('', '\n') - ], - [ - ('class:log-time', _TIMESTAMP_SAMPLE_STRING), - _TABLE_PADDING_FRAGMENT, - ('class:log-level-20', 'INF'), - _TABLE_PADDING_FRAGMENT, - ('class:log-table-column-0', 'MODULE1'), - _TABLE_PADDING_FRAGMENT, - ('class:log-table-column-1', '54321.200'), - _TABLE_PADDING_FRAGMENT, - ('', 'Something happened'), - ('', '\n') + [ + ('class:log-time', _TIMESTAMP_SAMPLE_STRING), + _TABLE_PADDING_FRAGMENT, + ('class:log-level-20', 'INF'), + _TABLE_PADDING_FRAGMENT, + ('class:log-table-column-0', 'MODULE2'), + _TABLE_PADDING_FRAGMENT, + ('', 'Another cool event'), + ('', '\n'), + ], + [ + ('class:log-time', _TIMESTAMP_SAMPLE_STRING), + _TABLE_PADDING_FRAGMENT, + ('class:log-level-20', 'INF'), + _TABLE_PADDING_FRAGMENT, + ('class:log-table-column-0', 'MODULE2'), + _TABLE_PADDING_FRAGMENT, + ('', '[MODULE2] 567.500 Another cool event'), + ('', '\n'), + ], + [ + ('class:log-time', _TIMESTAMP_SAMPLE_STRING), + _TABLE_PADDING_FRAGMENT, + ('class:log-level-20', 'INF'), + _TABLE_PADDING_FRAGMENT, + ('class:log-table-column-0', 'MODULE1'), + _TABLE_PADDING_FRAGMENT, + ('class:log-table-column-1', '54321.200'), + _TABLE_PADDING_FRAGMENT, + ('', 'Something happened'), + ('', '\n'), + ], ], - ], - ), - ]) # yapf: disable + ), + ] + ) def test_formatted_rows(self, _name, logs, expected_log_format) -> None: """Test colum widths calculation.""" table = TableView(self.prefs) diff --git a/pw_console/py/text_formatting_test.py b/pw_console/py/text_formatting_test.py index 340cc5816..c1af2ccc5 100644 --- a/pw_console/py/text_formatting_test.py +++ b/pw_console/py/text_formatting_test.py @@ -27,160 +27,176 @@ from pw_console.text_formatting import ( class TestTextFormatting(unittest.TestCase): """Tests for manipulating prompt_toolkit formatted text tuples.""" + def setUp(self): self.maxDiff = None # pylint: disable=invalid-name - @parameterized.expand([ - ( - 'with short prefix height 2', - len('LINE that should be wrapped'), # text_width - len('| |'), # screen_width - len('--->'), # prefix_width - ( 'LINE that should b\n' - '--->e wrapped \n').count('\n'), # expected_height - len( '_____'), # expected_trailing_characters - ), - ( - 'with short prefix height 3', - len('LINE that should be wrapped three times.'), # text_width - len('| |'), # screen_width - len('--->'), # prefix_width - ( 'LINE that should b\n' - '--->e wrapped thre\n' - '--->e times. \n').count('\n'), # expected_height - len( '______'), # expected_trailing_characters - ), - ( - 'with short prefix height 4', - len('LINE that should be wrapped even more times, say four.'), - len('| |'), # screen_width - len('--->'), # prefix_width - ( 'LINE that should b\n' - '--->e wrapped even\n' - '---> more times, s\n' - '--->ay four. \n').count('\n'), # expected_height - len( '______'), # expected_trailing_characters - ), - ( - 'no wrapping needed', - len('LINE wrapped'), # text_width - len('| |'), # screen_width - len('--->'), # prefix_width - ( 'LINE wrapped \n').count('\n'), # expected_height - len( '______'), # expected_trailing_characters - ), - ( - 'prefix is > screen width', - len('LINE that should be wrapped'), # text_width - len('| |'), # screen_width - len('------------------>'), # prefix_width - ( 'LINE that should b\n' - 'e wrapped \n').count('\n'), # expected_height - len( '_________'), # expected_trailing_characters - ), - ( - 'prefix is == screen width', - len('LINE that should be wrapped'), # text_width - len('| |'), # screen_width - len('----------------->'), # prefix_width - ( 'LINE that should b\n' - 'e wrapped \n').count('\n'), # expected_height - len( '_________'), # expected_trailing_characters - ), - ]) # yapf: disable - - def test_get_line_height(self, _name, text_width, screen_width, - prefix_width, expected_height, - expected_trailing_characters) -> None: + @parameterized.expand( + [ + ( + 'with short prefix height 2', + len('LINE that should be wrapped'), # text_width + len('| |'), # screen_width + len('--->'), # prefix_width + ('LINE that should b\n' '--->e wrapped \n').count( + '\n' + ), # expected_height + len('_____'), # expected_trailing_characters + ), + ( + 'with short prefix height 3', + len('LINE that should be wrapped three times.'), # text_width + len('| |'), # screen_width + len('--->'), # prefix_width + ( + 'LINE that should b\n' + '--->e wrapped thre\n' + '--->e times. \n' + ).count( + '\n' + ), # expected_height + len('______'), # expected_trailing_characters + ), + ( + 'with short prefix height 4', + len('LINE that should be wrapped even more times, say four.'), + len('| |'), # screen_width + len('--->'), # prefix_width + ( + 'LINE that should b\n' + '--->e wrapped even\n' + '---> more times, s\n' + '--->ay four. \n' + ).count( + '\n' + ), # expected_height + len('______'), # expected_trailing_characters + ), + ( + 'no wrapping needed', + len('LINE wrapped'), # text_width + len('| |'), # screen_width + len('--->'), # prefix_width + ('LINE wrapped \n').count('\n'), # expected_height + len('______'), # expected_trailing_characters + ), + ( + 'prefix is > screen width', + len('LINE that should be wrapped'), # text_width + len('| |'), # screen_width + len('------------------>'), # prefix_width + ('LINE that should b\n' 'e wrapped \n').count( + '\n' + ), # expected_height + len('_________'), # expected_trailing_characters + ), + ( + 'prefix is == screen width', + len('LINE that should be wrapped'), # text_width + len('| |'), # screen_width + len('----------------->'), # prefix_width + ('LINE that should b\n' 'e wrapped \n').count( + '\n' + ), # expected_height + len('_________'), # expected_trailing_characters + ), + ] + ) + def test_get_line_height( + self, + _name, + text_width, + screen_width, + prefix_width, + expected_height, + expected_trailing_characters, + ) -> None: """Test line height calculations.""" - height, remaining_width = get_line_height(text_width, screen_width, - prefix_width) + height, remaining_width = get_line_height( + text_width, screen_width, prefix_width + ) self.assertEqual(height, expected_height) self.assertEqual(remaining_width, expected_trailing_characters) # pylint: disable=line-too-long - @parameterized.expand([ - ( - 'One line with ANSI escapes and no included breaks', - 12, # screen_width - False, # truncate_long_lines - 'Lorem ipsum \x1b[34m\x1b[1mdolor sit amet\x1b[0m, consectetur adipiscing elit.', # message - ANSI( - # Line 1 - 'Lorem ipsum \n' - # Line 2 - '\x1b[34m\x1b[1m' # zero width - 'dolor sit am\n' - # Line 3 - 'et' - '\x1b[0m' # zero width - ', consecte\n' - # Line 4 - 'tur adipisci\n' - # Line 5 - 'ng elit.\n').__pt_formatted_text__(), - 5, # expected_height - ), - ( - 'One line with ANSI escapes and included breaks', - 12, # screen_width - False, # truncate_long_lines - 'Lorem\n ipsum \x1b[34m\x1b[1mdolor sit amet\x1b[0m, consectetur adipiscing elit.', # message - ANSI( - # Line 1 - 'Lorem\n' - # Line 2 - ' ipsum \x1b[34m\x1b[1mdolor\n' - # Line 3 - ' sit amet\x1b[0m, c\n' - # Line 4 - 'onsectetur a\n' - # Line 5 - 'dipiscing el\n' - # Line 6 - 'it.\n' - ).__pt_formatted_text__(), - 6, # expected_height - ), - ( - 'One line with ANSI escapes and included breaks; truncate lines enabled', - 12, # screen_width - True, # truncate_long_lines - 'Lorem\n ipsum dolor sit amet, consectetur adipiscing \nelit.\n', # message - ANSI( - # Line 1 - 'Lorem\n' - # Line 2 - ' ipsum dolor\n' - # Line 3 - 'elit.\n' + @parameterized.expand( + [ + ( + 'One line with ANSI escapes and no included breaks', + 12, # screen_width + False, # truncate_long_lines + 'Lorem ipsum \x1b[34m\x1b[1mdolor sit amet\x1b[0m, consectetur adipiscing elit.', # message + ANSI( + # Line 1 + 'Lorem ipsum \n' + # Line 2 + '\x1b[34m\x1b[1m' # zero width + 'dolor sit am\n' + # Line 3 + 'et' + '\x1b[0m' # zero width + ', consecte\n' + # Line 4 + 'tur adipisci\n' + # Line 5 + 'ng elit.\n' ).__pt_formatted_text__(), - 3, # expected_height - ), - ( - 'wrapping enabled with a line break just after screen_width', - 10, # screen_width - False, # truncate_long_lines - '01234567890\nTest Log\n', # message - ANSI( - '0123456789\n' - '0\n' - 'Test Log\n' + 5, # expected_height + ), + ( + 'One line with ANSI escapes and included breaks', + 12, # screen_width + False, # truncate_long_lines + 'Lorem\n ipsum \x1b[34m\x1b[1mdolor sit amet\x1b[0m, consectetur adipiscing elit.', # message + ANSI( + # Line 1 + 'Lorem\n' + # Line 2 + ' ipsum \x1b[34m\x1b[1mdolor\n' + # Line 3 + ' sit amet\x1b[0m, c\n' + # Line 4 + 'onsectetur a\n' + # Line 5 + 'dipiscing el\n' + # Line 6 + 'it.\n' ).__pt_formatted_text__(), - 3, # expected_height - ), - ( - 'log message with a line break at screen_width', - 10, # screen_width - True, # truncate_long_lines - '0123456789\nTest Log\n', # message - ANSI( - '0123456789\n' - 'Test Log\n' + 6, # expected_height + ), + ( + 'One line with ANSI escapes and included breaks; truncate lines enabled', + 12, # screen_width + True, # truncate_long_lines + 'Lorem\n ipsum dolor sit amet, consectetur adipiscing \nelit.\n', # message + ANSI( + # Line 1 + 'Lorem\n' + # Line 2 + ' ipsum dolor\n' + # Line 3 + 'elit.\n' ).__pt_formatted_text__(), - 2, # expected_height - ), - ]) # yapf: disable + 3, # expected_height + ), + ( + 'wrapping enabled with a line break just after screen_width', + 10, # screen_width + False, # truncate_long_lines + '01234567890\nTest Log\n', # message + ANSI('0123456789\n' '0\n' 'Test Log\n').__pt_formatted_text__(), + 3, # expected_height + ), + ( + 'log message with a line break at screen_width', + 10, # screen_width + True, # truncate_long_lines + '0123456789\nTest Log\n', # message + ANSI('0123456789\n' 'Test Log\n').__pt_formatted_text__(), + 2, # expected_height + ), + ] + ) # pylint: enable=line-too-long def test_insert_linebreaks( self, @@ -198,56 +214,58 @@ class TestTextFormatting(unittest.TestCase): fragments, line_height = insert_linebreaks( formatted_text, max_line_width=screen_width, - truncate_long_lines=truncate_long_lines) + truncate_long_lines=truncate_long_lines, + ) self.assertEqual(fragments, expected_fragments) self.assertEqual(line_height, expected_height) - @parameterized.expand([ - ( - 'flattened split', - ANSI( - 'Lorem\n' - ' ipsum dolor\n' - 'elit.\n' - ).__pt_formatted_text__(), - [ - ANSI('Lorem').__pt_formatted_text__(), - ANSI(' ipsum dolor').__pt_formatted_text__(), - ANSI('elit.').__pt_formatted_text__(), - ], # expected_lines - ), - ( - 'split fragments from insert_linebreaks', - insert_linebreaks( + @parameterized.expand( + [ + ( + 'flattened split', ANSI( - 'Lorem\n ipsum dolor sit amet, consectetur adipiscing elit.' + 'Lorem\n' ' ipsum dolor\n' 'elit.\n' ).__pt_formatted_text__(), - max_line_width=15, - # [0] for the fragments, [1] is line_height - truncate_long_lines=False)[0], - [ - ANSI('Lorem').__pt_formatted_text__(), - ANSI(' ipsum dolor si').__pt_formatted_text__(), - ANSI('t amet, consect').__pt_formatted_text__(), - ANSI('etur adipiscing').__pt_formatted_text__(), - ANSI(' elit.').__pt_formatted_text__(), - ], - ), - ( - 'empty lines', - # Each line should have at least one StyleAndTextTuple but without - # an ending line break. - [ - ('', '\n'), - ('', '\n'), - ], - [ - [('', '')], - [('', '')], - ], - ) - ]) # yapf: disable + [ + ANSI('Lorem').__pt_formatted_text__(), + ANSI(' ipsum dolor').__pt_formatted_text__(), + ANSI('elit.').__pt_formatted_text__(), + ], # expected_lines + ), + ( + 'split fragments from insert_linebreaks', + insert_linebreaks( + ANSI( + 'Lorem\n ipsum dolor sit amet, consectetur adipiscing elit.' + ).__pt_formatted_text__(), + max_line_width=15, + # [0] for the fragments, [1] is line_height + truncate_long_lines=False, + )[0], + [ + ANSI('Lorem').__pt_formatted_text__(), + ANSI(' ipsum dolor si').__pt_formatted_text__(), + ANSI('t amet, consect').__pt_formatted_text__(), + ANSI('etur adipiscing').__pt_formatted_text__(), + ANSI(' elit.').__pt_formatted_text__(), + ], + ), + ( + 'empty lines', + # Each line should have at least one StyleAndTextTuple but without + # an ending line break. + [ + ('', '\n'), + ('', '\n'), + ], + [ + [('', '')], + [('', '')], + ], + ), + ] + ) def test_split_lines( self, _name, diff --git a/pw_console/py/window_manager_test.py b/pw_console/py/window_manager_test.py index 676d07cb4..f1b778e88 100644 --- a/pw_console/py/window_manager_test.py +++ b/pw_console/py/window_manager_test.py @@ -19,6 +19,7 @@ from unittest.mock import MagicMock from prompt_toolkit.application import create_app_session from prompt_toolkit.output import ColorDepth + # inclusive-language: ignore from prompt_toolkit.output import DummyOutput as FakeOutput @@ -29,17 +30,16 @@ from pw_console.window_list import _WINDOW_HEIGHT_ADJUST, DisplayMode def _create_console_app(logger_count=2): - console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, - prefs=ConsolePrefs(project_file=False, - project_user_file=False, - user_file=False)) + prefs = ConsolePrefs( + project_file=False, project_user_file=False, user_file=False + ) + prefs.set_code_theme('default') + console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, prefs=prefs) console_app.focus_on_container = MagicMock() loggers = {} for i in range(logger_count): - loggers['Log{}'.format(i)] = [ - logging.getLogger('test_log{}'.format(i)) - ] + loggers['Log{}'.format(i)] = [logging.getLogger('test_log{}'.format(i))] for window_title, logger_instances in loggers.items(): console_app.add_log_handler(window_title, logger_instances) return console_app @@ -52,8 +52,9 @@ _DEFAULT_WINDOW_HEIGHT = 10 def _window_list_widths(window_manager): - window_manager.update_window_manager_size(_WINDOW_MANAGER_WIDTH, - _WINDOW_MANAGER_HEIGHT) + window_manager.update_window_manager_size( + _WINDOW_MANAGER_WIDTH, _WINDOW_MANAGER_HEIGHT + ) return [ window_list.width.preferred @@ -62,8 +63,9 @@ def _window_list_widths(window_manager): def _window_list_heights(window_manager): - window_manager.update_window_manager_size(_WINDOW_MANAGER_WIDTH, - _WINDOW_MANAGER_HEIGHT) + window_manager.update_window_manager_size( + _WINDOW_MANAGER_WIDTH, _WINDOW_MANAGER_HEIGHT + ) return [ window_list.height.preferred @@ -72,8 +74,9 @@ def _window_list_heights(window_manager): def _window_pane_widths(window_manager, window_list_index=0): - window_manager.update_window_manager_size(_WINDOW_MANAGER_WIDTH, - _WINDOW_MANAGER_HEIGHT) + window_manager.update_window_manager_size( + _WINDOW_MANAGER_WIDTH, _WINDOW_MANAGER_HEIGHT + ) return [ pane.width.preferred @@ -82,8 +85,9 @@ def _window_pane_widths(window_manager, window_list_index=0): def _window_pane_heights(window_manager, window_list_index=0): - window_manager.update_window_manager_size(_WINDOW_MANAGER_WIDTH, - _WINDOW_MANAGER_HEIGHT) + window_manager.update_window_manager_size( + _WINDOW_MANAGER_WIDTH, _WINDOW_MANAGER_HEIGHT + ) return [ pane.height.preferred @@ -99,27 +103,33 @@ def _window_pane_counts(window_manager): def window_pane_titles(window_manager): - return [[ - pane.pane_title() + ' - ' + pane.pane_subtitle() - for pane in window_list.active_panes - ] for window_list in window_manager.window_lists] + return [ + [ + pane.pane_title() + ' - ' + pane.pane_subtitle() + for pane in window_list.active_panes + ] + for window_list in window_manager.window_lists + ] def target_list_and_pane(window_manager, list_index, pane_index): # pylint: disable=protected-access # Bypass prompt_toolkit has_focus() - window_manager._get_active_window_list_and_pane = ( - MagicMock( # type: ignore - return_value=( - window_manager.window_lists[list_index], - window_manager.window_lists[list_index]. - active_panes[pane_index], - ))) + pane = window_manager.window_lists[list_index].active_panes[pane_index] + # If the pane is in focus it will be visible. + pane.show_pane = True + window_manager._get_active_window_list_and_pane = MagicMock( # type: ignore + return_value=( + window_manager.window_lists[list_index], + window_manager.window_lists[list_index].active_panes[pane_index], + ) + ) class TestWindowManager(unittest.TestCase): # pylint: disable=protected-access """Tests for window management functions.""" + def setUp(self): self.maxDiff = None # pylint: disable=invalid-name @@ -155,8 +165,10 @@ class TestWindowManager(unittest.TestCase): target_pane = window_manager.window_lists[2].active_panes[0] - result_window_list, result_pane_index = ( - window_manager._find_window_list_and_pane_index(target_pane)) + ( + result_window_list, + result_pane_index, + ) = window_manager.find_window_list_and_pane_index(target_pane) self.assertEqual( (result_window_list, result_pane_index), (window_manager.window_lists[2], 0), @@ -181,10 +193,13 @@ class TestWindowManager(unittest.TestCase): # Move one pane to the right, creating a new window_list split. window_manager.move_pane_right() - self.assertEqual(_window_list_widths(window_manager), [ - int(_WINDOW_MANAGER_WIDTH / 2), - int(_WINDOW_MANAGER_WIDTH / 2), - ]) + self.assertEqual( + _window_list_widths(window_manager), + [ + int(_WINDOW_MANAGER_WIDTH / 2), + int(_WINDOW_MANAGER_WIDTH / 2), + ], + ) # Move another pane to the right twice, creating a third # window_list split. @@ -217,10 +232,8 @@ class TestWindowManager(unittest.TestCase): _window_list_widths(window_manager), [ int(_WINDOW_MANAGER_WIDTH / 3), - int(_WINDOW_MANAGER_WIDTH / 3) - - (2 * _WINDOW_SPLIT_ADJUST), - int(_WINDOW_MANAGER_WIDTH / 3) + - (2 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) - (2 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) + (2 * _WINDOW_SPLIT_ADJUST), ], ) @@ -232,10 +245,8 @@ class TestWindowManager(unittest.TestCase): self.assertEqual( _window_list_widths(window_manager), [ - int(_WINDOW_MANAGER_WIDTH / 3) - - (1 * _WINDOW_SPLIT_ADJUST), - int(_WINDOW_MANAGER_WIDTH / 3) + - (1 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) - (1 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) + (1 * _WINDOW_SPLIT_ADJUST), int(_WINDOW_MANAGER_WIDTH / 3), ], ) @@ -249,10 +260,8 @@ class TestWindowManager(unittest.TestCase): _window_list_widths(window_manager), [ int(_WINDOW_MANAGER_WIDTH / 3), - int(_WINDOW_MANAGER_WIDTH / 3) + - (1 * _WINDOW_SPLIT_ADJUST), - int(_WINDOW_MANAGER_WIDTH / 3) - - (1 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) + (1 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) - (1 * _WINDOW_SPLIT_ADJUST), ], ) @@ -265,10 +274,8 @@ class TestWindowManager(unittest.TestCase): _window_list_widths(window_manager), [ int(_WINDOW_MANAGER_WIDTH / 3), - int(_WINDOW_MANAGER_WIDTH / 3) - - (3 * _WINDOW_SPLIT_ADJUST), - int(_WINDOW_MANAGER_WIDTH / 3) + - (3 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) - (3 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 3) + (3 * _WINDOW_SPLIT_ADJUST), ], ) @@ -282,11 +289,9 @@ class TestWindowManager(unittest.TestCase): self.assertEqual( _window_list_widths(window_manager), [ - int(_WINDOW_MANAGER_WIDTH / 2) - - (3 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 2) - (3 * _WINDOW_SPLIT_ADJUST), # This split is removed - int(_WINDOW_MANAGER_WIDTH / 2) + - (2 * _WINDOW_SPLIT_ADJUST), + int(_WINDOW_MANAGER_WIDTH / 2) + (2 * _WINDOW_SPLIT_ADJUST), ], ) @@ -313,11 +318,17 @@ class TestWindowManager(unittest.TestCase): ] self.assertEqual( list_pane_titles[0], - [('', ' '), ('class:window-tab-inactive', ' Log2 test_log2 '), - ('', ' '), ('class:window-tab-inactive', ' Log1 test_log1 '), - ('', ' '), ('class:window-tab-inactive', ' Log0 test_log0 '), - ('', ' '), ('class:window-tab-inactive', ' Python Repl '), - ('', ' ')], + [ + ('', ' '), + ('class:window-tab-inactive', ' Log2 test_log2 '), + ('', ' '), + ('class:window-tab-inactive', ' Log1 test_log1 '), + ('', ' '), + ('class:window-tab-inactive', ' Log0 test_log0 '), + ('', ' '), + ('class:window-tab-inactive', ' Python Repl '), + ('', ' '), + ], ) def test_window_pane_movement_resizing(self) -> None: @@ -329,7 +340,8 @@ class TestWindowManager(unittest.TestCase): # 4 panes, 3 for the loggers and 1 for the repl. self.assertEqual( - len(window_manager.first_window_list().active_panes), 4) + len(window_manager.first_window_list().active_panes), 4 + ) def target_window_pane(index: int): # Bypass prompt_toolkit has_focus() @@ -338,11 +350,13 @@ class TestWindowManager(unittest.TestCase): return_value=( window_manager.window_lists[0], window_manager.window_lists[0].active_panes[index], - ))) + ) + ) + ) window_list = console_app.window_manager.first_window_list() - window_list.get_current_active_pane = ( - MagicMock( # type: ignore - return_value=window_list.active_panes[index])) + window_list.get_current_active_pane = MagicMock( # type: ignore + return_value=window_list.active_panes[index] + ) # Target the first window pane target_window_pane(0) @@ -361,7 +375,8 @@ class TestWindowManager(unittest.TestCase): # Reset pane sizes window_manager.window_lists[0].current_window_list_height = ( - 4 * _DEFAULT_WINDOW_HEIGHT) + 4 * _DEFAULT_WINDOW_HEIGHT + ) window_manager.reset_pane_sizes() self.assertEqual( _window_pane_heights(window_manager), @@ -464,6 +479,7 @@ class TestWindowManager(unittest.TestCase): console_app = _create_console_app(logger_count=4) window_manager = console_app.window_manager + window_manager.window_lists[0].set_display_mode(DisplayMode.STACK) self.assertEqual( window_pane_titles(window_manager), [ @@ -485,7 +501,8 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_next_pane() # Pane index 1 should now be focused. console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[1]) + window_manager.window_lists[0].active_panes[1] + ) console_app.focus_on_container.reset_mock() # Set the first pane in focus. @@ -495,7 +512,8 @@ class TestWindowManager(unittest.TestCase): # Previous pane should wrap around to the last pane in the first # window_list. console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[-1]) + window_manager.window_lists[0].active_panes[-1] + ) console_app.focus_on_container.reset_mock() # Set the last pane in focus. @@ -505,7 +523,8 @@ class TestWindowManager(unittest.TestCase): # Next pane should wrap around to the first pane in the first # window_list. console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[0]) + window_manager.window_lists[0].active_panes[0] + ) console_app.focus_on_container.reset_mock() # Scenario: Move between panes with a single tabbed window list. @@ -517,7 +536,8 @@ class TestWindowManager(unittest.TestCase): # Setup the switch_to_tab mock window_manager.window_lists[0].switch_to_tab = MagicMock( - wraps=window_manager.window_lists[0].switch_to_tab) + wraps=window_manager.window_lists[0].switch_to_tab + ) # Set the first pane/tab in focus. target_list_and_pane(window_manager, 0, 0) @@ -525,10 +545,12 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_next_pane() # Check switch_to_tab is called window_manager.window_lists[ - 0].switch_to_tab.assert_called_once_with(1) + 0 + ].switch_to_tab.assert_called_once_with(1) # And that focus_on_container is called only once console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[1]) + window_manager.window_lists[0].active_panes[1] + ) console_app.focus_on_container.reset_mock() window_manager.window_lists[0].switch_to_tab.reset_mock() @@ -538,10 +560,12 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_next_pane() # Check switch_to_tab is called window_manager.window_lists[ - 0].switch_to_tab.assert_called_once_with(0) + 0 + ].switch_to_tab.assert_called_once_with(0) # And that focus_on_container is called only once console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[0]) + window_manager.window_lists[0].active_panes[0] + ) console_app.focus_on_container.reset_mock() window_manager.window_lists[0].switch_to_tab.reset_mock() @@ -551,10 +575,12 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_previous_pane() # Check switch_to_tab is called window_manager.window_lists[ - 0].switch_to_tab.assert_called_once_with(4) + 0 + ].switch_to_tab.assert_called_once_with(4) # And that focus_on_container is called only once console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[4]) + window_manager.window_lists[0].active_panes[4] + ) console_app.focus_on_container.reset_mock() window_manager.window_lists[0].switch_to_tab.reset_mock() @@ -584,14 +610,16 @@ class TestWindowManager(unittest.TestCase): # Setup the switch_to_tab mock on the second window_list window_manager.window_lists[1].switch_to_tab = MagicMock( - wraps=window_manager.window_lists[1].switch_to_tab) + wraps=window_manager.window_lists[1].switch_to_tab + ) # Set Log1 in focus target_list_and_pane(window_manager, 0, 2) window_manager.focus_next_pane() # Log0 should now have focus console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[1].active_panes[0]) + window_manager.window_lists[1].active_panes[0] + ) console_app.focus_on_container.reset_mock() # Set Log0 in focus @@ -599,11 +627,13 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_previous_pane() # Log1 should now have focus console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[2]) + window_manager.window_lists[0].active_panes[2] + ) # The first window list is in tabbed mode so switch_to_tab should be # called once. window_manager.window_lists[ - 0].switch_to_tab.assert_called_once_with(2) + 0 + ].switch_to_tab.assert_called_once_with(2) # Reset window_manager.window_lists[0].switch_to_tab.reset_mock() console_app.focus_on_container.reset_mock() @@ -613,9 +643,11 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_next_pane() # Log3 should now have focus console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[0]) + window_manager.window_lists[0].active_panes[0] + ) window_manager.window_lists[ - 0].switch_to_tab.assert_called_once_with(0) + 0 + ].switch_to_tab.assert_called_once_with(0) # Reset window_manager.window_lists[0].switch_to_tab.reset_mock() console_app.focus_on_container.reset_mock() @@ -625,9 +657,11 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_next_pane() # Log2 should now have focus console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[0].active_panes[1]) + window_manager.window_lists[0].active_panes[1] + ) window_manager.window_lists[ - 0].switch_to_tab.assert_called_once_with(1) + 0 + ].switch_to_tab.assert_called_once_with(1) # Reset window_manager.window_lists[0].switch_to_tab.reset_mock() console_app.focus_on_container.reset_mock() @@ -637,7 +671,8 @@ class TestWindowManager(unittest.TestCase): window_manager.focus_previous_pane() # Log0 should now have focus console_app.focus_on_container.assert_called_once_with( - window_manager.window_lists[1].active_panes[0]) + window_manager.window_lists[1].active_panes[0] + ) # The second window list is in stacked mode so switch_to_tab should # not be called. window_manager.window_lists[1].switch_to_tab.assert_not_called() @@ -652,8 +687,9 @@ class TestWindowManager(unittest.TestCase): window_manager = console_app.window_manager # Required before moving windows - window_manager.update_window_manager_size(_WINDOW_MANAGER_WIDTH, - _WINDOW_MANAGER_HEIGHT) + window_manager.update_window_manager_size( + _WINDOW_MANAGER_WIDTH, _WINDOW_MANAGER_HEIGHT + ) window_manager.create_root_container() # Vertical split by default @@ -694,8 +730,7 @@ class TestWindowManager(unittest.TestCase): self.assertEqual(_window_list_widths(window_manager), widths) # Decrease size of first split - window_manager.adjust_split_size(window_manager.window_lists[0], - -4) + window_manager.adjust_split_size(window_manager.window_lists[0], -4) widths = [ widths[0] - (4 * _WINDOW_SPLIT_ADJUST), widths[1] + (4 * _WINDOW_SPLIT_ADJUST), @@ -728,13 +763,15 @@ class TestWindowManager(unittest.TestCase): window_manager = console_app.window_manager # We want horizontal window splits - window_manager.vertical_window_list_spliting = (MagicMock( - return_value=False)) + window_manager.vertical_window_list_spliting = MagicMock( + return_value=False + ) self.assertFalse(window_manager.vertical_window_list_spliting()) # Required before moving windows - window_manager.update_window_manager_size(_WINDOW_MANAGER_WIDTH, - _WINDOW_MANAGER_HEIGHT) + window_manager.update_window_manager_size( + _WINDOW_MANAGER_WIDTH, _WINDOW_MANAGER_HEIGHT + ) window_manager.create_root_container() # Move windows to create 3 splits @@ -772,8 +809,7 @@ class TestWindowManager(unittest.TestCase): self.assertEqual(_window_list_heights(window_manager), heights) # Decrease size of first split - window_manager.adjust_split_size(window_manager.window_lists[0], - -4) + window_manager.adjust_split_size(window_manager.window_lists[0], -4) heights = [ heights[0] - (4 * _WINDOW_SPLIT_ADJUST), heights[1] + (4 * _WINDOW_SPLIT_ADJUST), |