aboutsummaryrefslogtreecommitdiff
path: root/pw_console/py
diff options
context:
space:
mode:
Diffstat (limited to 'pw_console/py')
-rw-r--r--pw_console/py/BUILD.bazel236
-rw-r--r--pw_console/py/BUILD.gn13
-rw-r--r--pw_console/py/command_runner_test.py150
-rw-r--r--pw_console/py/console_app_test.py39
-rw-r--r--pw_console/py/console_prefs_test.py69
-rw-r--r--pw_console/py/help_window_test.py46
-rw-r--r--pw_console/py/log_filter_test.py265
-rw-r--r--pw_console/py/log_store_test.py28
-rw-r--r--pw_console/py/log_view_test.py305
-rw-r--r--pw_console/py/pw_console/__main__.py130
-rw-r--r--pw_console/py/pw_console/command_runner.py126
-rw-r--r--pw_console/py/pw_console/console_app.py863
-rw-r--r--pw_console/py/pw_console/console_log_server.py78
-rw-r--r--pw_console/py/pw_console/console_prefs.py124
-rw-r--r--pw_console/py/pw_console/docs/__init__.py0
-rw-r--r--pw_console/py/pw_console/docs/user_guide.rst32
-rw-r--r--pw_console/py/pw_console/embed.py118
-rw-r--r--pw_console/py/pw_console/filter_toolbar.py64
-rw-r--r--pw_console/py/pw_console/help_window.py167
-rw-r--r--pw_console/py/pw_console/html/__init__.py0
-rw-r--r--pw_console/py/pw_console/html/index.html37
-rw-r--r--pw_console/py/pw_console/html/main.js261
-rw-r--r--pw_console/py/pw_console/html/style.css74
-rw-r--r--pw_console/py/pw_console/key_bindings.py74
-rw-r--r--pw_console/py/pw_console/log_filter.py38
-rw-r--r--pw_console/py/pw_console/log_line.py19
-rw-r--r--pw_console/py/pw_console/log_pane.py426
-rw-r--r--pw_console/py/pw_console/log_pane_saveas_dialog.py119
-rw-r--r--pw_console/py/pw_console/log_pane_selection_dialog.py111
-rw-r--r--pw_console/py/pw_console/log_pane_toolbars.py53
-rw-r--r--pw_console/py/pw_console/log_screen.py90
-rw-r--r--pw_console/py/pw_console/log_store.py83
-rw-r--r--pw_console/py/pw_console/log_view.py224
-rw-r--r--pw_console/py/pw_console/pigweed_code_style.py115
-rw-r--r--pw_console/py/pw_console/plugin_mixin.py4
-rw-r--r--pw_console/py/pw_console/plugins/bandwidth_toolbar.py48
-rw-r--r--pw_console/py/pw_console/plugins/calc_pane.py22
-rw-r--r--pw_console/py/pw_console/plugins/clock_pane.py336
-rw-r--r--pw_console/py/pw_console/plugins/twenty48_pane.py561
-rw-r--r--pw_console/py/pw_console/progress_bar/__init__.py23
-rw-r--r--pw_console/py/pw_console/progress_bar/progress_bar_impl.py30
-rw-r--r--pw_console/py/pw_console/progress_bar/progress_bar_state.py38
-rw-r--r--pw_console/py/pw_console/progress_bar/progress_bar_task_counter.py1
-rw-r--r--pw_console/py/pw_console/pw_ptpython_repl.py152
-rw-r--r--pw_console/py/pw_console/pyserial_wrapper.py153
-rw-r--r--pw_console/py/pw_console/python_logging.py122
-rw-r--r--pw_console/py/pw_console/quit_dialog.py50
-rw-r--r--pw_console/py/pw_console/repl_pane.py202
-rw-r--r--pw_console/py/pw_console/search_toolbar.py210
-rw-r--r--pw_console/py/pw_console/style.py169
-rw-r--r--pw_console/py/pw_console/templates/__init__.py0
-rw-r--r--pw_console/py/pw_console/templates/repl_output.jinja4
-rw-r--r--pw_console/py/pw_console/test_mode.py86
-rw-r--r--pw_console/py/pw_console/text_formatting.py32
-rw-r--r--pw_console/py/pw_console/widgets/__init__.py7
-rw-r--r--pw_console/py/pw_console/widgets/border.py73
-rw-r--r--pw_console/py/pw_console/widgets/checkbox.py33
-rw-r--r--pw_console/py/pw_console/widgets/event_count_history.py16
-rw-r--r--pw_console/py/pw_console/widgets/mouse_handlers.py1
-rw-r--r--pw_console/py/pw_console/widgets/table.py85
-rw-r--r--pw_console/py/pw_console/widgets/window_pane.py90
-rw-r--r--pw_console/py/pw_console/widgets/window_pane_toolbar.py107
-rw-r--r--pw_console/py/pw_console/window_list.py164
-rw-r--r--pw_console/py/pw_console/window_manager.py302
-rw-r--r--pw_console/py/pw_console/yaml_config_loader_mixin.py154
-rw-r--r--pw_console/py/repl_pane_test.py114
-rw-r--r--pw_console/py/setup.cfg17
-rw-r--r--pw_console/py/table_test.py303
-rw-r--r--pw_console/py/text_formatting_test.py392
-rw-r--r--pw_console/py/window_manager_test.py220
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),