From 1c3acd7922702a25f78f7ea8749ed890057467de Mon Sep 17 00:00:00 2001 From: Yoel Bassin Date: Fri, 25 Apr 2025 16:36:21 +0300 Subject: [PATCH] main - feat: copy GRC from latest stable GNURadio --- grc/00-grc-docs.conf.in | 4 + grc/CMakeLists.txt | 194 ++ grc/__init__.py | 0 grc/__main__.py | 9 + grc/blocks/.gitignore | 1 + grc/blocks/CMakeLists.txt | 48 + grc/blocks/grc.tree.yml | 24 + grc/blocks/import.block.yml | 21 + grc/blocks/json_config.block.yml | 58 + grc/blocks/message.domain.yml | 12 + grc/blocks/note.block.yml | 10 + grc/blocks/options.block.yml | 179 ++ grc/blocks/pad_sink.block.yml | 56 + grc/blocks/pad_source.block.yml | 56 + grc/blocks/parameter.block.yml | 60 + grc/blocks/snippet.block.yml | 46 + grc/blocks/stream.domain.yml | 11 + grc/blocks/variable.block.yml | 25 + grc/blocks/variable_config.block.yml | 59 + grc/blocks/variable_function_probe.block.yml | 66 + grc/blocks/variable_struct.block.yml.py | 100 + grc/blocks/yaml_config.block.yml | 58 + grc/compiler.py | 65 + grc/converter/__init__.py | 8 + grc/converter/__main__.py | 8 + grc/converter/block.dtd | 58 + grc/converter/block.py | 219 ++ grc/converter/block_tree.dtd | 15 + grc/converter/block_tree.py | 44 + grc/converter/cheetah_converter.py | 269 +++ grc/converter/flow_graph.dtd | 27 + grc/converter/flow_graph.py | 121 ++ grc/converter/main.py | 160 ++ grc/converter/xml.py | 73 + grc/core/Config.py | 79 + grc/core/Connection.py | 182 ++ grc/core/Constants.py | 147 ++ grc/core/FlowGraph.py | 578 ++++++ grc/core/Messages.py | 147 ++ grc/core/__init__.py | 0 grc/core/base.py | 153 ++ grc/core/blocks/__init__.py | 27 + grc/core/blocks/_build.py | 170 ++ grc/core/blocks/_flags.py | 36 + grc/core/blocks/_templates.py | 79 + grc/core/blocks/block.py | 789 ++++++++ grc/core/blocks/dummy.py | 47 + grc/core/blocks/embedded_python.py | 248 +++ grc/core/blocks/virtual.py | 62 + grc/core/cache.py | 106 + grc/core/default_flow_graph.grc | 32 + grc/core/errors.py | 17 + grc/core/flow_graph.dtd | 27 + grc/core/generator/FlowGraphProxy.py | 191 ++ grc/core/generator/Generator.py | 51 + grc/core/generator/__init__.py | 7 + grc/core/generator/cpp_hier_block.py | 217 ++ .../cpp_templates/CMakeLists.txt.mako | 72 + .../cpp_templates/flow_graph.cpp.mako | 189 ++ .../cpp_templates/flow_graph.hpp.mako | 193 ++ grc/core/generator/cpp_top_block.py | 499 +++++ grc/core/generator/flow_graph.py.mako | 435 ++++ grc/core/generator/hier_block.py | 186 ++ grc/core/generator/top_block.py | 386 ++++ grc/core/io/__init__.py | 5 + grc/core/io/yaml.py | 89 + grc/core/params/__init__.py | 7 + grc/core/params/dtypes.py | 128 ++ grc/core/params/param.py | 484 +++++ grc/core/params/template_arg.py | 37 + grc/core/platform.py | 464 +++++ grc/core/ports/__init__.py | 11 + grc/core/ports/_virtual_connections.py | 119 ++ grc/core/ports/clone.py | 27 + grc/core/ports/port.py | 255 +++ grc/core/schema_checker/__init__.py | 5 + grc/core/schema_checker/block.py | 73 + grc/core/schema_checker/domain.py | 19 + grc/core/schema_checker/flow_graph.py | 24 + grc/core/schema_checker/manifest.py | 18 + grc/core/schema_checker/utils.py | 22 + grc/core/schema_checker/validator.py | 88 + grc/core/utils/__init__.py | 18 + grc/core/utils/descriptors/__init__.py | 15 + grc/core/utils/descriptors/_lazy.py | 63 + grc/core/utils/descriptors/evaluated.py | 105 + grc/core/utils/epy_block_io.py | 129 ++ grc/core/utils/expr_utils.py | 214 ++ grc/core/utils/extract_docs.py | 317 +++ grc/core/utils/flow_graph_complexity.py | 57 + ...hide_bokeh_gui_options_if_not_installed.py | 16 + grc/grc.conf.in | 12 + grc/gui/Actions.py | 742 +++++++ grc/gui/Application.py | 901 +++++++++ grc/gui/Bars.py | 330 +++ grc/gui/BlockTreeWindow.py | 300 +++ grc/gui/Config.py | 186 ++ grc/gui/Console.py | 43 + grc/gui/Constants.py | 123 ++ grc/gui/Dialogs.py | 432 ++++ grc/gui/DrawingArea.py | 262 +++ grc/gui/Executor.py | 129 ++ grc/gui/FileDialogs.py | 197 ++ grc/gui/MainWindow.py | 463 +++++ grc/gui/Notebook.py | 192 ++ grc/gui/ParamWidgets.py | 419 ++++ grc/gui/ParserErrorsDialog.py | 91 + grc/gui/Platform.py | 65 + grc/gui/PropsDialog.py | 325 +++ grc/gui/StateCache.py | 96 + grc/gui/Utils.py | 194 ++ grc/gui/VariableEditor.py | 358 ++++ grc/gui/__init__.py | 0 grc/gui/canvas/__init__.py | 11 + grc/gui/canvas/block.py | 425 ++++ grc/gui/canvas/colors.py | 114 ++ grc/gui/canvas/connection.py | 245 +++ grc/gui/canvas/drawable.py | 169 ++ grc/gui/canvas/flowgraph.py | 870 ++++++++ grc/gui/canvas/param.py | 190 ++ grc/gui/canvas/port.py | 219 ++ grc/gui/external_editor.py | 74 + grc/gui/icon.png | Bin 0 -> 4377 bytes grc/gui_qt/Config.py | 44 + grc/gui_qt/Constants.py | 121 ++ grc/gui_qt/Platform.py | 69 + grc/gui_qt/Utils.py | 121 ++ grc/gui_qt/__init__.py | 0 grc/gui_qt/base.py | 147 ++ grc/gui_qt/components/__init__.py | 11 + grc/gui_qt/components/block_library.py | 358 ++++ grc/gui_qt/components/canvas/__init__.py | 0 grc/gui_qt/components/canvas/block.py | 429 ++++ grc/gui_qt/components/canvas/colors.py | 54 + grc/gui_qt/components/canvas/connection.py | 204 ++ grc/gui_qt/components/canvas/flowgraph.py | 539 +++++ grc/gui_qt/components/canvas/port.py | 173 ++ grc/gui_qt/components/console.py | 276 +++ grc/gui_qt/components/dialogs.py | 298 +++ grc/gui_qt/components/example_browser.py | 317 +++ grc/gui_qt/components/executor.py | 120 ++ grc/gui_qt/components/flowgraph_view.py | 200 ++ grc/gui_qt/components/oot_browser.py | 113 ++ grc/gui_qt/components/preferences.py | 172 ++ grc/gui_qt/components/undoable_actions.py | 235 +++ grc/gui_qt/components/variable_editor.py | 296 +++ grc/gui_qt/components/wiki_tab.py | 101 + grc/gui_qt/components/window.py | 1775 +++++++++++++++++ grc/gui_qt/external_editor.py | 74 + grc/gui_qt/grc.py | 167 ++ grc/gui_qt/helpers/__init__.py | 2 + grc/gui_qt/helpers/logging.py | 132 ++ grc/gui_qt/helpers/profiling.py | 25 + grc/gui_qt/helpers/qt.py | 0 grc/gui_qt/properties.py | 264 +++ .../resources/available_preferences.yml | 192 ++ grc/gui_qt/resources/cpp_cmd_fg.png | Bin 0 -> 26946 bytes grc/gui_qt/resources/cpp_fg.png | Bin 0 -> 30471 bytes grc/gui_qt/resources/cpp_qt_fg.png | Bin 0 -> 32309 bytes grc/gui_qt/resources/data/rx_logo.grc | 1711 ++++++++++++++++ grc/gui_qt/resources/example_browser.ui | 184 ++ .../resources/example_browser_widget.ui | 202 ++ .../resources/language/add-translation.txt | 17 + .../language/en_US/LC_MESSAGES/grc.po | 449 +++++ .../logo/gnuradio_logo_icon-square.png | Bin 0 -> 5014 bytes .../resources/manifests/gr-example_haakov.md | 21 + grc/gui_qt/resources/oot_browser.ui | 275 +++ grc/gui_qt/resources/py_cmd_fg.png | Bin 0 -> 25912 bytes grc/gui_qt/resources/py_fg.png | Bin 0 -> 28106 bytes grc/gui_qt/resources/py_qt_fg.png | Bin 0 -> 31327 bytes grc/main.py | 323 +++ grc/scripts/CMakeLists.txt | 14 + grc/scripts/freedesktop/CMakeLists.txt | 28 + grc/scripts/freedesktop/README | 20 + grc/scripts/freedesktop/convert.sh | 14 + grc/scripts/freedesktop/gnuradio-grc.desktop | 11 + grc/scripts/freedesktop/gnuradio-grc.xml | 8 + .../freedesktop/gnuradio_logo_icon-square.svg | 124 ++ grc/scripts/freedesktop/grc-icon-128.png | Bin 0 -> 4758 bytes grc/scripts/freedesktop/grc-icon-16.png | Bin 0 -> 537 bytes grc/scripts/freedesktop/grc-icon-24.png | Bin 0 -> 840 bytes grc/scripts/freedesktop/grc-icon-256.png | Bin 0 -> 9762 bytes grc/scripts/freedesktop/grc-icon-32.png | Bin 0 -> 1148 bytes grc/scripts/freedesktop/grc-icon-48.png | Bin 0 -> 1796 bytes grc/scripts/freedesktop/grc-icon-64.png | Bin 0 -> 2355 bytes .../freedesktop/grc_setup_freedesktop.in | 65 + .../freedesktop/org.gnuradio.grc.metainfo.xml | 59 + grc/scripts/gnuradio-companion | 103 + grc/scripts/grcc | 54 + grc/tests/.gitignore | 3 + grc/tests/CMakeLists.txt | 23 + grc/tests/__init__.py | 0 grc/tests/resources/file1.block.yml | 38 + grc/tests/resources/file2.block.yml | 31 + grc/tests/resources/file3.block.yml | 66 + grc/tests/resources/test_compiler.grc | 253 +++ grc/tests/resources/test_cpp.grc | 88 + grc/tests/test_block_flags.py | 29 + grc/tests/test_block_templates.py | 47 + grc/tests/test_cheetah_converter.py | 134 ++ grc/tests/test_compiler.py | 29 + grc/tests/test_cpp.py | 29 + grc/tests/test_evaled_property.py | 106 + grc/tests/test_examples.py | 106 + grc/tests/test_expr_utils.py | 40 + grc/tests/test_generator.py | 41 + grc/tests/test_qtbot.py | 946 +++++++++ grc/tests/test_xml_parser.py | 29 + grc/tests/test_yaml_checker.py | 74 + 209 files changed, 31325 insertions(+) create mode 100644 grc/00-grc-docs.conf.in create mode 100644 grc/CMakeLists.txt create mode 100644 grc/__init__.py create mode 100644 grc/__main__.py create mode 100644 grc/blocks/.gitignore create mode 100644 grc/blocks/CMakeLists.txt create mode 100644 grc/blocks/grc.tree.yml create mode 100644 grc/blocks/import.block.yml create mode 100644 grc/blocks/json_config.block.yml create mode 100644 grc/blocks/message.domain.yml create mode 100644 grc/blocks/note.block.yml create mode 100644 grc/blocks/options.block.yml create mode 100644 grc/blocks/pad_sink.block.yml create mode 100644 grc/blocks/pad_source.block.yml create mode 100644 grc/blocks/parameter.block.yml create mode 100644 grc/blocks/snippet.block.yml create mode 100644 grc/blocks/stream.domain.yml create mode 100644 grc/blocks/variable.block.yml create mode 100644 grc/blocks/variable_config.block.yml create mode 100644 grc/blocks/variable_function_probe.block.yml create mode 100644 grc/blocks/variable_struct.block.yml.py create mode 100644 grc/blocks/yaml_config.block.yml create mode 100755 grc/compiler.py create mode 100644 grc/converter/__init__.py create mode 100644 grc/converter/__main__.py create mode 100644 grc/converter/block.dtd create mode 100644 grc/converter/block.py create mode 100644 grc/converter/block_tree.dtd create mode 100644 grc/converter/block_tree.py create mode 100644 grc/converter/cheetah_converter.py create mode 100644 grc/converter/flow_graph.dtd create mode 100644 grc/converter/flow_graph.py create mode 100644 grc/converter/main.py create mode 100644 grc/converter/xml.py create mode 100644 grc/core/Config.py create mode 100644 grc/core/Connection.py create mode 100644 grc/core/Constants.py create mode 100644 grc/core/FlowGraph.py create mode 100644 grc/core/Messages.py create mode 100644 grc/core/__init__.py create mode 100644 grc/core/base.py create mode 100644 grc/core/blocks/__init__.py create mode 100644 grc/core/blocks/_build.py create mode 100644 grc/core/blocks/_flags.py create mode 100644 grc/core/blocks/_templates.py create mode 100644 grc/core/blocks/block.py create mode 100644 grc/core/blocks/dummy.py create mode 100644 grc/core/blocks/embedded_python.py create mode 100644 grc/core/blocks/virtual.py create mode 100644 grc/core/cache.py create mode 100644 grc/core/default_flow_graph.grc create mode 100644 grc/core/errors.py create mode 100644 grc/core/flow_graph.dtd create mode 100644 grc/core/generator/FlowGraphProxy.py create mode 100644 grc/core/generator/Generator.py create mode 100644 grc/core/generator/__init__.py create mode 100644 grc/core/generator/cpp_hier_block.py create mode 100644 grc/core/generator/cpp_templates/CMakeLists.txt.mako create mode 100644 grc/core/generator/cpp_templates/flow_graph.cpp.mako create mode 100644 grc/core/generator/cpp_templates/flow_graph.hpp.mako create mode 100644 grc/core/generator/cpp_top_block.py create mode 100644 grc/core/generator/flow_graph.py.mako create mode 100644 grc/core/generator/hier_block.py create mode 100644 grc/core/generator/top_block.py create mode 100644 grc/core/io/__init__.py create mode 100644 grc/core/io/yaml.py create mode 100644 grc/core/params/__init__.py create mode 100644 grc/core/params/dtypes.py create mode 100644 grc/core/params/param.py create mode 100644 grc/core/params/template_arg.py create mode 100644 grc/core/platform.py create mode 100644 grc/core/ports/__init__.py create mode 100644 grc/core/ports/_virtual_connections.py create mode 100644 grc/core/ports/clone.py create mode 100644 grc/core/ports/port.py create mode 100644 grc/core/schema_checker/__init__.py create mode 100644 grc/core/schema_checker/block.py create mode 100644 grc/core/schema_checker/domain.py create mode 100644 grc/core/schema_checker/flow_graph.py create mode 100644 grc/core/schema_checker/manifest.py create mode 100644 grc/core/schema_checker/utils.py create mode 100644 grc/core/schema_checker/validator.py create mode 100644 grc/core/utils/__init__.py create mode 100644 grc/core/utils/descriptors/__init__.py create mode 100644 grc/core/utils/descriptors/_lazy.py create mode 100644 grc/core/utils/descriptors/evaluated.py create mode 100644 grc/core/utils/epy_block_io.py create mode 100644 grc/core/utils/expr_utils.py create mode 100644 grc/core/utils/extract_docs.py create mode 100644 grc/core/utils/flow_graph_complexity.py create mode 100644 grc/core/utils/hide_bokeh_gui_options_if_not_installed.py create mode 100644 grc/grc.conf.in create mode 100644 grc/gui/Actions.py create mode 100644 grc/gui/Application.py create mode 100644 grc/gui/Bars.py create mode 100644 grc/gui/BlockTreeWindow.py create mode 100644 grc/gui/Config.py create mode 100644 grc/gui/Console.py create mode 100644 grc/gui/Constants.py create mode 100644 grc/gui/Dialogs.py create mode 100644 grc/gui/DrawingArea.py create mode 100644 grc/gui/Executor.py create mode 100644 grc/gui/FileDialogs.py create mode 100644 grc/gui/MainWindow.py create mode 100644 grc/gui/Notebook.py create mode 100644 grc/gui/ParamWidgets.py create mode 100644 grc/gui/ParserErrorsDialog.py create mode 100644 grc/gui/Platform.py create mode 100644 grc/gui/PropsDialog.py create mode 100644 grc/gui/StateCache.py create mode 100644 grc/gui/Utils.py create mode 100644 grc/gui/VariableEditor.py create mode 100644 grc/gui/__init__.py create mode 100644 grc/gui/canvas/__init__.py create mode 100644 grc/gui/canvas/block.py create mode 100644 grc/gui/canvas/colors.py create mode 100644 grc/gui/canvas/connection.py create mode 100644 grc/gui/canvas/drawable.py create mode 100644 grc/gui/canvas/flowgraph.py create mode 100644 grc/gui/canvas/param.py create mode 100644 grc/gui/canvas/port.py create mode 100644 grc/gui/external_editor.py create mode 100644 grc/gui/icon.png create mode 100644 grc/gui_qt/Config.py create mode 100644 grc/gui_qt/Constants.py create mode 100644 grc/gui_qt/Platform.py create mode 100644 grc/gui_qt/Utils.py create mode 100644 grc/gui_qt/__init__.py create mode 100644 grc/gui_qt/base.py create mode 100644 grc/gui_qt/components/__init__.py create mode 100644 grc/gui_qt/components/block_library.py create mode 100644 grc/gui_qt/components/canvas/__init__.py create mode 100755 grc/gui_qt/components/canvas/block.py create mode 100755 grc/gui_qt/components/canvas/colors.py create mode 100755 grc/gui_qt/components/canvas/connection.py create mode 100644 grc/gui_qt/components/canvas/flowgraph.py create mode 100644 grc/gui_qt/components/canvas/port.py create mode 100644 grc/gui_qt/components/console.py create mode 100644 grc/gui_qt/components/dialogs.py create mode 100644 grc/gui_qt/components/example_browser.py create mode 100644 grc/gui_qt/components/executor.py create mode 100644 grc/gui_qt/components/flowgraph_view.py create mode 100644 grc/gui_qt/components/oot_browser.py create mode 100644 grc/gui_qt/components/preferences.py create mode 100644 grc/gui_qt/components/undoable_actions.py create mode 100644 grc/gui_qt/components/variable_editor.py create mode 100644 grc/gui_qt/components/wiki_tab.py create mode 100644 grc/gui_qt/components/window.py create mode 100644 grc/gui_qt/external_editor.py create mode 100644 grc/gui_qt/grc.py create mode 100644 grc/gui_qt/helpers/__init__.py create mode 100644 grc/gui_qt/helpers/logging.py create mode 100644 grc/gui_qt/helpers/profiling.py create mode 100644 grc/gui_qt/helpers/qt.py create mode 100644 grc/gui_qt/properties.py create mode 100644 grc/gui_qt/resources/available_preferences.yml create mode 100644 grc/gui_qt/resources/cpp_cmd_fg.png create mode 100644 grc/gui_qt/resources/cpp_fg.png create mode 100644 grc/gui_qt/resources/cpp_qt_fg.png create mode 100755 grc/gui_qt/resources/data/rx_logo.grc create mode 100644 grc/gui_qt/resources/example_browser.ui create mode 100644 grc/gui_qt/resources/example_browser_widget.ui create mode 100644 grc/gui_qt/resources/language/add-translation.txt create mode 100644 grc/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po create mode 100644 grc/gui_qt/resources/logo/gnuradio_logo_icon-square.png create mode 100644 grc/gui_qt/resources/manifests/gr-example_haakov.md create mode 100644 grc/gui_qt/resources/oot_browser.ui create mode 100644 grc/gui_qt/resources/py_cmd_fg.png create mode 100644 grc/gui_qt/resources/py_fg.png create mode 100644 grc/gui_qt/resources/py_qt_fg.png create mode 100755 grc/main.py create mode 100644 grc/scripts/CMakeLists.txt create mode 100644 grc/scripts/freedesktop/CMakeLists.txt create mode 100644 grc/scripts/freedesktop/README create mode 100755 grc/scripts/freedesktop/convert.sh create mode 100644 grc/scripts/freedesktop/gnuradio-grc.desktop create mode 100644 grc/scripts/freedesktop/gnuradio-grc.xml create mode 100644 grc/scripts/freedesktop/gnuradio_logo_icon-square.svg create mode 100644 grc/scripts/freedesktop/grc-icon-128.png create mode 100644 grc/scripts/freedesktop/grc-icon-16.png create mode 100644 grc/scripts/freedesktop/grc-icon-24.png create mode 100644 grc/scripts/freedesktop/grc-icon-256.png create mode 100644 grc/scripts/freedesktop/grc-icon-32.png create mode 100644 grc/scripts/freedesktop/grc-icon-48.png create mode 100644 grc/scripts/freedesktop/grc-icon-64.png create mode 100644 grc/scripts/freedesktop/grc_setup_freedesktop.in create mode 100644 grc/scripts/freedesktop/org.gnuradio.grc.metainfo.xml create mode 100755 grc/scripts/gnuradio-companion create mode 100755 grc/scripts/grcc create mode 100644 grc/tests/.gitignore create mode 100644 grc/tests/CMakeLists.txt create mode 100644 grc/tests/__init__.py create mode 100644 grc/tests/resources/file1.block.yml create mode 100644 grc/tests/resources/file2.block.yml create mode 100644 grc/tests/resources/file3.block.yml create mode 100644 grc/tests/resources/test_compiler.grc create mode 100644 grc/tests/resources/test_cpp.grc create mode 100644 grc/tests/test_block_flags.py create mode 100644 grc/tests/test_block_templates.py create mode 100644 grc/tests/test_cheetah_converter.py create mode 100644 grc/tests/test_compiler.py create mode 100644 grc/tests/test_cpp.py create mode 100644 grc/tests/test_evaled_property.py create mode 100644 grc/tests/test_examples.py create mode 100644 grc/tests/test_expr_utils.py create mode 100644 grc/tests/test_generator.py create mode 100644 grc/tests/test_qtbot.py create mode 100644 grc/tests/test_xml_parser.py create mode 100644 grc/tests/test_yaml_checker.py diff --git a/grc/00-grc-docs.conf.in b/grc/00-grc-docs.conf.in new file mode 100644 index 0000000..ff7a7c4 --- /dev/null +++ b/grc/00-grc-docs.conf.in @@ -0,0 +1,4 @@ +# This should link to the wiki page-per-block documentation, when the block label (e.g., FFT) is appended to the end of the string + +[grc-docs] +wiki_block_docs_url_prefix = @GRC_DOCS_URL_PREFIX@ diff --git a/grc/CMakeLists.txt b/grc/CMakeLists.txt new file mode 100644 index 0000000..43d433c --- /dev/null +++ b/grc/CMakeLists.txt @@ -0,0 +1,194 @@ +# Copyright 2011,2013,2017 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +######################################################################## +# Setup dependencies +######################################################################## +include(GrPython) + +message(STATUS "") + +GR_PYTHON_CHECK_MODULE_RAW( + "PyYAML >= 3.11" + "import yaml; assert yaml.__version__ >= '3.11'" + PYYAML_FOUND +) + +GR_PYTHON_CHECK_MODULE( + "mako >= ${GR_MAKO_MIN_VERSION}" + mako + "LooseVersion(mako.__version__) >= LooseVersion('${GR_MAKO_MIN_VERSION}')" + MAKO_FOUND +) + +GR_PYTHON_CHECK_MODULE_RAW( + "pygobject >= 2.28.6" + "import gi; assert gi.version_info >= (2, 28, 6)" + PYGI_FOUND +) + +GR_PYTHON_CHECK_MODULE_RAW( + "Gtk (GI) >= 3.10.8" + "import gi; gi.require_version('Gtk', '3.0'); \ + from gi.repository import Gtk; Gtk.check_version(3, 10, 8)" + GTK_GI_FOUND +) + +GR_PYTHON_CHECK_MODULE_RAW( + "Cairo (GI) >= 1.0" + "import gi; gi.require_foreign('cairo', 'Context')" # Cairo 1.13.0 + CAIRO_GI_FOUND +) + +GR_PYTHON_CHECK_MODULE_RAW( + "PangoCairo (GI) >= 1.0" + "import gi; gi.require_version('PangoCairo', '1.0')" # pangocairo 1.36.3 + PANGOCAIRO_GI_FOUND +) + +GR_PYTHON_CHECK_MODULE_RAW( + "numpy" + "import numpy" + NUMPY_FOUND +) + +GR_PYTHON_CHECK_MODULE_RAW( + "jsonschema" + "import jsonschema" + JSONSCHEMA_FOUND +) + +######################################################################## +# Register component +######################################################################## +include(GrComponent) +if(NOT CMAKE_CROSSCOMPILING) + set(grc_python_deps + PYYAML_FOUND + MAKO_FOUND + PYGI_FOUND + GTK_GI_FOUND + CAIRO_GI_FOUND + PANGOCAIRO_GI_FOUND + NUMPY_FOUND + ) +endif(NOT CMAKE_CROSSCOMPILING) + +GR_REGISTER_COMPONENT("gnuradio-companion" ENABLE_GRC + ENABLE_GNURADIO_RUNTIME + ENABLE_PYTHON + ${grc_python_deps} +) + +if(ENABLE_GRC) + + GR_REGISTER_COMPONENT("JSON/YAML config blocks" ENABLE_JSONYAML_BLOCKS + ENABLE_GRC + JSONSCHEMA_FOUND + ) + +######################################################################## +# Create and install the grc and grc-docs conf file +######################################################################## +file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${GRC_BLOCKS_DIR} blocksdir) +file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${GRC_EXAMPLES_DIR} examplesdir) +if(CMAKE_INSTALL_PREFIX STREQUAL "/usr") + # linux binary installs: append blocks and examples dir with prefix /usr/local + set(blocksdir ${blocksdir}:/usr/local/${GRC_BLOCKS_DIR}) + set(examplesdir ${examplesdir}:/usr/local/${GRC_EXAMPLES_DIR}) +endif(CMAKE_INSTALL_PREFIX STREQUAL "/usr") + +if(UNIX) + find_program(GRC_XTERM_EXE + NAMES x-terminal-emulator gnome-terminal konsole xfce4-terminal urxvt xterm foot + HINTS ENV PATH + DOC "graphical terminal emulator used in GRC's no-gui-mode" + ) + if(NOT GRC_XTERM_EXE) + set(GRC_XTERM_EXE "x-terminal-emulator") + endif() +else() # APPLE CYGWIN + set(GRC_XTERM_EXE "xterm") +endif() + +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/grc.conf.in + ${CMAKE_CURRENT_BINARY_DIR}/grc.conf +@ONLY) + +install( + FILES ${CMAKE_CURRENT_BINARY_DIR}/grc.conf + DESTINATION ${GR_PREFSDIR} +) + +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/00-grc-docs.conf.in + ${CMAKE_CURRENT_BINARY_DIR}/00-grc-docs.conf +@ONLY) + +install( + FILES ${CMAKE_CURRENT_BINARY_DIR}/00-grc-docs.conf + DESTINATION ${GR_PREFSDIR} +) + +######################################################################## +# Install (+ compile) python sources and data files +######################################################################## +file(GLOB py_files "*.py") +GR_PYTHON_INSTALL( + FILES ${py_files} + DESTINATION "${GR_PYTHON_DIR}/gnuradio/grc" +) + +GR_PYTHON_INSTALL( + DIRECTORY core + DESTINATION "${GR_PYTHON_DIR}/gnuradio/grc" + FILES_MATCHING REGEX "\\.(py|dtd|grc|tmpl|png|mako)$" +) + +GR_PYTHON_INSTALL( + DIRECTORY gui + DESTINATION "${GR_PYTHON_DIR}/gnuradio/grc" + FILES_MATCHING REGEX "\\.(py|dtd|grc|tmpl|png|mako)$" +) + +GR_PYTHON_INSTALL( + DIRECTORY gui_qt + DESTINATION "${GR_PYTHON_DIR}/gnuradio/grc" + FILES_MATCHING REGEX "\\.(py|yml|grc|mo|png|ui)$" +) + +GR_PYTHON_INSTALL( + DIRECTORY converter + DESTINATION "${GR_PYTHON_DIR}/gnuradio/grc" + FILES_MATCHING REGEX "\\.(py|dtd|grc|tmpl|png|mako)$" +) + +######################################################################## +# Append NSIS commands to set environment variables +######################################################################## +if(WIN32) + +file(TO_NATIVE_PATH ${GR_PKG_DOC_DIR} GR_DOC_DIR) +string(REPLACE "\\" "\\\\" GR_DOC_DIR ${GR_DOC_DIR}) + +file(TO_NATIVE_PATH ${GRC_BLOCKS_DIR} GRC_BLOCKS_PATH) +string(REPLACE "\\" "\\\\" GRC_BLOCKS_PATH ${GRC_BLOCKS_PATH}) + +file(TO_NATIVE_PATH ${GR_PYTHON_DIR} GR_PYTHON_POSTFIX) +string(REPLACE "\\" "\\\\" GR_PYTHON_POSTFIX ${GR_PYTHON_POSTFIX}) + +endif(WIN32) + +######################################################################## +# Add subdirectories +######################################################################## +add_subdirectory(blocks) +add_subdirectory(scripts) +add_subdirectory(tests) + +endif(ENABLE_GRC) diff --git a/grc/__init__.py b/grc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grc/__main__.py b/grc/__main__.py new file mode 100644 index 0000000..237b216 --- /dev/null +++ b/grc/__main__.py @@ -0,0 +1,9 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +from .main import main + +main() diff --git a/grc/blocks/.gitignore b/grc/blocks/.gitignore new file mode 100644 index 0000000..2ac4a79 --- /dev/null +++ b/grc/blocks/.gitignore @@ -0,0 +1 @@ +variable_struct.xml diff --git a/grc/blocks/CMakeLists.txt b/grc/blocks/CMakeLists.txt new file mode 100644 index 0000000..3c5a62a --- /dev/null +++ b/grc/blocks/CMakeLists.txt @@ -0,0 +1,48 @@ +# Copyright 2011,2018 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +######################################################################## +include(GrPython) + +file(GLOB yml_files "*.yml") + +macro(REPLACE_IN_FILE _yml_block match replace) + set(yml_block_src "${CMAKE_CURRENT_SOURCE_DIR}/${_yml_block}") + set(yml_block "${CMAKE_CURRENT_BINARY_DIR}/${_yml_block}") + + list(REMOVE_ITEM yml_files "${yml_block_src}") + file(READ "${yml_block_src}" yml_block_src_text) + string(REPLACE "${match}" "${replace}" + yml_block_text "${yml_block_src_text}") + file(WRITE "${yml_block}" "${yml_block_text}") + + list(APPEND generated_yml_files "${yml_block}") +endmacro() + +macro(GEN_BLOCK_YML _generator _yml_block) + set(generator ${CMAKE_CURRENT_SOURCE_DIR}/${_generator}) + set(yml_block ${CMAKE_CURRENT_BINARY_DIR}/${_yml_block}) + list(APPEND generated_yml_files ${yml_block}) + add_custom_command( + DEPENDS ${generator} OUTPUT ${yml_block} + COMMAND ${PYTHON_EXECUTABLE} ${generator} ${yml_block} + ) +endmacro() + +GEN_BLOCK_YML(variable_struct.block.yml.py variable_struct.block.yml) + +add_custom_target(grc_generated_yml ALL DEPENDS ${generated_yml_files}) + +if(NOT ENABLE_JSONYAML_BLOCKS) + list(REMOVE_ITEM yml_files "json_config.block.yml") + list(REMOVE_ITEM yml_files "yaml_config.block.yml") +endif() + +install( + FILES ${yml_files} ${generated_yml_files} + DESTINATION ${GRC_BLOCKS_DIR} +) diff --git a/grc/blocks/grc.tree.yml b/grc/blocks/grc.tree.yml new file mode 100644 index 0000000..0d9560d --- /dev/null +++ b/grc/blocks/grc.tree.yml @@ -0,0 +1,24 @@ +'[Core]': +- Misc: + - options + - pad_source + - pad_sink + - virtual_source + - virtual_sink + - bus_sink + - bus_source + - bus_structure_sink + - bus_structure_source + - epy_block + - epy_module + - note + - import + - snippet +- Variables: + - variable + - variable_struct + - variable_config + - variable_function_probe + - json_config + - yaml_config + - parameter diff --git a/grc/blocks/import.block.yml b/grc/blocks/import.block.yml new file mode 100644 index 0000000..ad133af --- /dev/null +++ b/grc/blocks/import.block.yml @@ -0,0 +1,21 @@ +id: import_ +label: Import +flags: [ python ] + +parameters: +- id: imports + label: Import + dtype: import + +templates: + imports: ${imports} + +documentation: |- + Import additional python modules into the namespace. + + Examples: + from gnuradio.filter import firdes + import math,cmath + from math import pi + +file_format: 1 diff --git a/grc/blocks/json_config.block.yml b/grc/blocks/json_config.block.yml new file mode 100644 index 0000000..07b9143 --- /dev/null +++ b/grc/blocks/json_config.block.yml @@ -0,0 +1,58 @@ +id: json_config +label: JSON Config +flags: [ show_id, python ] + +parameters: +- id: config_file + label: Config File + dtype: file_open + default: "" +- id: schema_file + label: Config Schema + dtype: file_open + default: "" +# Not my favorite thing because it doesn't explicitly close the file, but +# it should be okay. The garbage collector will take care of it. +value: "${ json.load(open(config_file)) }" + +asserts: +- ${ schema_file == "" or validate(json.load(open(config_file)), json.load(open(schema_file))) is None } + +templates: + imports: |- + import json + from jsonschema import validate + # Note that yaml makes it really hard to insert spaces at the beginning of the line. + # The "\" char below is used to escape the space at the beginning of a line. + var_make: "with open(${config_file}) as fid:\n\ + \ self.${id} = ${id} = json.load(fid)\n\ + self.${id}_schema = ${id}_schema = ${schema_file}\n\ + if ${id}_schema:\n\ + \ with open(${id}_schema) as fid:\n\ + \ validate(${id}, json.load(fid))" + +documentation: |- + This block represents a json config file that is read in as a dictionary. + + The values can be used directly when instantiating blocks. For example, + Sample Rate: yaml_config["samp_rate"] + + Optionally, a json schema can be specified to validate the configuration. + + For example, you could have a json file that contains: + { + "samp_rate": 1e6, + } + And a schema that contains + { + "type": "object", + "properties": { + "samp_rate": {"type": "number", "exclusiveMinimum": 0} + } + } + + If the id of this block is json_config_0, then you can access the samp rate + in other blocks as json_config_0["samp_rate"] + + +file_format: 1 diff --git a/grc/blocks/message.domain.yml b/grc/blocks/message.domain.yml new file mode 100644 index 0000000..d4b79d0 --- /dev/null +++ b/grc/blocks/message.domain.yml @@ -0,0 +1,12 @@ +id: message +label: Message +color: "#FFFFFF" + +multiple_connections_per_input: true +multiple_connections_per_output: true + +templates: +- type: [message, message] + connect: self.msg_connect(${ make_port_sig(source) }, ${ make_port_sig(sink) }) + cpp_connect: hier_block2::msg_connect(${ make_port_sig(source) }, ${ make_port_sig(sink) }) + diff --git a/grc/blocks/note.block.yml b/grc/blocks/note.block.yml new file mode 100644 index 0000000..aeff390 --- /dev/null +++ b/grc/blocks/note.block.yml @@ -0,0 +1,10 @@ +id: note +label: Note +flags: [ python, cpp ] + +parameters: +- id: note + label: Note + dtype: string + +file_format: 1 diff --git a/grc/blocks/options.block.yml b/grc/blocks/options.block.yml new file mode 100644 index 0000000..eb59482 --- /dev/null +++ b/grc/blocks/options.block.yml @@ -0,0 +1,179 @@ +id: options +label: Options +flags: ['python', 'cpp'] + +parameters: +- id: title + label: Title + dtype: string + hide: ${ ('none' if title else 'part') } +- id: author + label: Author + dtype: string + hide: ${ ('none' if author else 'part') } +- id: copyright + label: Copyright + dtype: string + hide: ${ ('none' if copyright else 'part') } +- id: description + label: Description + dtype: string + hide: ${ ('none' if description else 'part') } +- id: output_language + label: Output Language + dtype: enum + default: python + options: [python, cpp] + option_labels: [Python, C++] +- id: generate_options + label: Generate Options + dtype: enum + default: qt_gui + options: [qt_gui, bokeh_gui, no_gui, hb, hb_qt_gui] + option_labels: [QT GUI, Bokeh GUI, No GUI, Hier Block, Hier Block (QT GUI)] +- id: gen_linking + label: Linking + dtype: enum + default: dynamic + options: [dynamic, static] + option_labels: [ Dynamic, Static ] + hide: 'all' +- id: gen_cmake + label: Generate CMakeLists.txt + dtype: enum + default: 'On' + options: ['On', 'Off'] + hide: ${ ('part' if output_language == 'cpp' else 'all') } +- id: cmake_opt + label: CMake options + dtype: string + default: '' + hide: ${ ('part' if output_language == 'cpp' else 'all') } +- id: category + label: Category + dtype: string + default: '[GRC Hier Blocks]' + hide: ${ ('none' if generate_options.startswith('hb') else 'all') } +- id: run_options + label: Run Options + dtype: enum + default: prompt + options: [run, prompt] + option_labels: [Run to Completion, Prompt for Exit] + hide: ${ ('none' if generate_options == 'no_gui' else 'all') } +- id: placement + label: Widget Placement + dtype: int_vector + default: (0,0) + hide: ${ ('part' if generate_options == 'bokeh_gui' else 'all') } +- id: window_size + label: Window size + dtype: int_vector + default: (1000,1000) + hide: ${ ('part' if generate_options == 'bokeh_gui' else 'all') } +- id: sizing_mode + label: Sizing Mode + dtype: enum + default: fixed + options: [fixed, stretch_both, scale_width, scale_height, scale_both] + option_labels: [Fixed, Stretch Both, Scale Width, Scale Height, Scale Both] + hide: ${ ('part' if generate_options == 'bokeh_gui' else 'all') } +- id: run + label: Run + dtype: bool + default: 'True' + options: ['True', 'False'] + option_labels: [Autostart, 'Off'] + hide: ${ ('all' if generate_options not in ('qt_gui', 'bokeh_gui') else ('part' + if run else 'none')) } +- id: max_nouts + label: Max Number of Output + dtype: int + default: '0' + hide: ${ ('all' if generate_options.startswith('hb') else ('none' if max_nouts + else 'part')) } +- id: realtime_scheduling + label: Realtime Scheduling + dtype: enum + options: ['', '1'] + option_labels: ['Off', 'On'] + hide: ${ ('all' if generate_options.startswith('hb') else ('none' if realtime_scheduling + else 'part')) } +- id: qt_qss_theme + label: QSS Theme + dtype: file_open + hide: ${ ('all' if generate_options != 'qt_gui' else ('none' if qt_qss_theme else + 'part')) } +- id: thread_safe_setters + label: Thread-safe setters + category: Advanced + dtype: enum + options: ['', '1'] + option_labels: ['Off', 'On'] + hide: part +- id: catch_exceptions + label: Catch Block Exceptions + category: Advanced + dtype: enum + options: ['False', 'True'] + option_labels: ['Off', 'On'] + default: 'True' + hide: part +- id: run_command + label: Run Command + category: Advanced + dtype: string + default: '{python} -u {filename}' + hide: ${ ('all' if generate_options.startswith('hb') else 'part') } +- id: hier_block_src_path + label: Hier Block Source Path + category: Advanced + dtype: string + default: '.:' + hide: part + +asserts: +- ${ len(placement) == 4 or len(placement) == 2 } +- ${ all(i >= 0 for i in placement) } + +templates: + imports: |- + from gnuradio import gr + from gnuradio.filter import firdes + from gnuradio.fft import window + import sys + import signal + % if generate_options == 'qt_gui': + from PyQt5 import Qt + % endif + % if not generate_options.startswith('hb'): + from argparse import ArgumentParser + from gnuradio.eng_arg import eng_float, intx + from gnuradio import eng_notation + % endif + callbacks: + - 'if ${run}: self.start() + + else: self.stop(); self.wait()' + +cpp_templates: + includes: ['#include '] + +documentation: |- + The options block sets special parameters for the flow graph. Only one option block is allowed per flow graph. + + Title, author, and description parameters are for identification purposes. + + The window size controls the dimensions of the flow graph editor. The window size (width, height) must be between (300, 300) and (4096, 4096). + + The generate options controls the type of code generated. Non-graphical flow graphs should avoid using graphical sinks or graphical variable controls. + + In a graphical application, run can be controlled by a variable to start and stop the flowgraph at runtime. + + The id of this block determines the name of the generated file and the name of the class. For example, an id of my_block will generate the file my_block.py and class my_block(gr.... + + The category parameter determines the placement of the block in the block selection window. The category only applies when creating hier blocks. To put hier blocks into the root category, enter / for the category. + + The Max Number of Output is the maximum number of output items allowed for any block in the flowgraph; to disable this set the max_nouts equal to 0.Use this to adjust the maximum latency a flowgraph can exhibit. + +file_format: 1 diff --git a/grc/blocks/pad_sink.block.yml b/grc/blocks/pad_sink.block.yml new file mode 100644 index 0000000..88c2676 --- /dev/null +++ b/grc/blocks/pad_sink.block.yml @@ -0,0 +1,56 @@ +id: pad_sink +label: Pad Sink +flags: [ python, cpp ] + +parameters: +- id: label + label: Label + dtype: string + default: out +- id: type + label: Input Type + dtype: enum + options: [complex, float, int, short, byte, bit, message, ''] + option_labels: [Complex, Float, Int, Short, Byte, Bits, Message, Wildcard] + option_attributes: + size: [gr.sizeof_gr_complex, gr.sizeof_float, gr.sizeof_int, gr.sizeof_short, + gr.sizeof_char, gr.sizeof_char, '0', '0'] + cpp_size: [sizeof(gr_complex), sizeof(float), sizeof(int), sizeof(short), + sizeof(char), sizeof(char), '0', '0'] + hide: part +- id: vlen + label: Vector Length + dtype: int + default: '1' + hide: ${ 'part' if vlen == 1 else 'none' } +- id: num_streams + label: Num Streams + dtype: int + default: '1' + hide: part +- id: optional + label: Optional + dtype: bool + default: 'False' + options: ['True', 'False'] + option_labels: [Optional, Required] + hide: part + +inputs: +- domain: stream + dtype: ${ type } + vlen: ${ vlen } + multiplicity: ${ num_streams } + optional: ${optional} + + +asserts: +- ${ vlen > 0 } +- ${ num_streams > 0 } + +documentation: |- + The inputs of this block will become the outputs to this flow graph when it is instantiated as a hierarchical block. + + Pad sink will be ordered alphabetically by their ids. The first pad sink will have an index of 0. + +file_format: 1 diff --git a/grc/blocks/pad_source.block.yml b/grc/blocks/pad_source.block.yml new file mode 100644 index 0000000..2025c0c --- /dev/null +++ b/grc/blocks/pad_source.block.yml @@ -0,0 +1,56 @@ +id: pad_source +label: Pad Source +flags: [ python, cpp ] + +parameters: +- id: label + label: Label + dtype: string + default: in +- id: type + label: Output Type + dtype: enum + options: [complex, float, int, short, byte, bit, message, ''] + option_labels: [Complex, Float, Int, Short, Byte, Bits, Message, Wildcard] + option_attributes: + size: [gr.sizeof_gr_complex, gr.sizeof_float, gr.sizeof_int, gr.sizeof_short, + gr.sizeof_char, gr.sizeof_char, '0', '0'] + cpp_size: [sizeof(gr_complex), sizeof(float), sizeof(int), sizeof(short), + sizeof(char), sizeof(char), '0', '0'] + hide: part +- id: vlen + label: Vector Length + dtype: int + default: '1' + hide: ${ 'part' if vlen == 1 else 'none' } +- id: num_streams + label: Num Streams + dtype: int + default: '1' + hide: part +- id: optional + label: Optional + dtype: bool + default: 'False' + options: ['True', 'False'] + option_labels: [Optional, Required] + hide: part + +outputs: +- domain: stream + dtype: ${ type } + vlen: ${ vlen } + multiplicity: ${ num_streams } + optional: ${optional} + + +asserts: +- ${ vlen > 0 } +- ${ num_streams > 0 } + +documentation: |- + The outputs of this block will become the inputs to this flow graph when it is instantiated as a hierarchical block. + + Pad sources will be ordered alphabetically by their ids. The first pad source will have an index of 0. + +file_format: 1 diff --git a/grc/blocks/parameter.block.yml b/grc/blocks/parameter.block.yml new file mode 100644 index 0000000..6c654af --- /dev/null +++ b/grc/blocks/parameter.block.yml @@ -0,0 +1,60 @@ +id: parameter +label: Parameter +flags: [ show_id, python, cpp ] + +parameters: +- id: label + label: Label + dtype: string + hide: ${ ('none' if label else 'part') } +- id: type + label: Type + dtype: enum + options: ['', complex, eng_float, intx, long, str] + option_labels: [None, Complex, Float, Int, Long, String] + option_attributes: + type: [raw, complex, real, int, int, string] + hide: ${ ('none' if type else 'part') } +- id: value + label: Value + dtype: ${ type.type } + default: '0' +- id: short_id + label: Short ID + dtype: string + hide: ${ 'all' if not type else ('none' if short_id else 'part') } +- id: hide + label: Show + dtype: enum + options: [none, part] + option_labels: [Always, Only in Properties] + hide: part + +asserts: +- ${ len(short_id) in (0, 1) } +- ${ short_id == '' or short_id.isalpha() } + +templates: + var_make: self.${id} = ${id} + make: ${value} + +cpp_templates: + var_make: ${id} = ${value}; + make: ${value} + +documentation: |- + This block represents a parameter to the flow graph. A parameter can be used to pass command line arguments into a top block. Or, parameters can pass arguments into an instantiated hierarchical block. + + The parameter value cannot depend on any variables. + + Leave the label blank to use the parameter id as the label. + + When type is not None, this parameter also becomes a command line option of the form: + + -[short_id] --[id] [value] + + The Short ID field may be left blank. + + To disable showing the parameter on the hierarchical block in GRC, use Only in Properties option in the Show field. + +file_format: 1 diff --git a/grc/blocks/snippet.block.yml b/grc/blocks/snippet.block.yml new file mode 100644 index 0000000..ef83dc6 --- /dev/null +++ b/grc/blocks/snippet.block.yml @@ -0,0 +1,46 @@ +id: snippet +label: Python Snippet +flags: [ python ] + +parameters: +- id: section + label: Section of Flowgraph + dtype: string + options: ['main_after_init', 'main_after_start', 'main_after_stop', 'init_before_blocks' ] + option_labels: ['Main - After Init', 'Main - After Start', 'Main - After Stop', 'Init - Before Blocks'] +- id: priority + label: Priority + dtype: int + default: "0" + hide: ${'part' if priority <= 0 else 'none'} +- id: code + label: Code Snippet + dtype: _multiline + +templates: + var_make: ${code} + +documentation: |- + CAUTION: This is an ADVANCED feature and can lead to unintended consequences in the rendering of a flowgraph. Use at your own risk. + + Insert a snippet of Python code directly into the flowgraph at the end of the specified section. \ + For each snippet a function is generated with the block name of the snippet (use GRC Show Block IDs option to modify). These functions are\ + then grouped into their respective sections in the rendered flowgraph. + + The purpose of the python snippets is to be able to exercise features from within GRC that are not entirely supported by the block callbacks, \ + methods and mechanisms to generate the code. One example of this would be calling UHD timed commands before starting the flowgraph + + Indents will be handled upon insertion into the python flowgraph + + Example 1: + epy_mod_0.some_function(self.some_block.some_property) + + Will place the function call in the generated .py file using the name of the appropriate embedded python block in the proper scope + The scope is relative to the blocks in the flowgraph, e.g. to reference a block, it should be identified as self.block + + Example 2: + print('The flowgraph has been stopped') + + With section selected as 'Main - After Stop', will place the print statement after the flowgraph has been stopped. + +file_format: 1 diff --git a/grc/blocks/stream.domain.yml b/grc/blocks/stream.domain.yml new file mode 100644 index 0000000..0a679ca --- /dev/null +++ b/grc/blocks/stream.domain.yml @@ -0,0 +1,11 @@ +id: stream +label: Stream +color: "#000000" + +multiple_connections_per_input: false +multiple_connections_per_output: true + +templates: +- type: [stream, stream] + connect: self.connect(${ make_port_sig(source) }, ${ make_port_sig(sink) }) + cpp_connect: hier_block2::connect(${ make_port_sig(source) }, ${ make_port_sig(sink) }) diff --git a/grc/blocks/variable.block.yml b/grc/blocks/variable.block.yml new file mode 100644 index 0000000..2952b82 --- /dev/null +++ b/grc/blocks/variable.block.yml @@ -0,0 +1,25 @@ +id: variable +label: Variable +flags: [ show_id, python, cpp ] + +parameters: +- id: value + label: Value + dtype: raw + default: '0' +value: ${ value } + +templates: + var_make: self.${id} = ${id} = ${value} + callbacks: + - self.set_${id}(${value}) + +cpp_templates: + var_make: ${id} = ${value}; + callbacks: + - this->set_${id}(${value}) + +documentation: |- + This block maps a value to a unique variable. This variable block has no graphical representation. + +file_format: 1 diff --git a/grc/blocks/variable_config.block.yml b/grc/blocks/variable_config.block.yml new file mode 100644 index 0000000..04a60e4 --- /dev/null +++ b/grc/blocks/variable_config.block.yml @@ -0,0 +1,59 @@ +id: variable_config +label: Variable Config +flags: [ show_id, python ] + +parameters: +- id: value + label: Default Value + dtype: ${ type } + default: '0' +- id: type + label: Type + dtype: enum + default: real + options: [real, int, bool, string] + option_labels: [Float, Int, Bool, String] + option_attributes: + get: [getfloat, getint, getboolean, get] +- id: config_file + label: Config File + dtype: file_open + default: default +- id: section + label: Section + dtype: string + default: main +- id: option + label: Option + dtype: string + default: key +- id: writeback + label: WriteBack + dtype: raw + default: None +value: ${ value } + +templates: + imports: import configparser + var_make: 'self._${id}_config = configparser.ConfigParser() + + self._${id}_config.read(${config_file}) + + try: ${id} = self._${id}_config.${type.get}(${section}, ${option}) + + except: ${id} = ${value} + + self.${id} = ${id}' + callbacks: + - self.set_${id}(${value}) + - "self._${id}_config = configparser.ConfigParser()\nself._${id}_config.read(${config_file})\n\ + if not self._${id}_config.has_section(${section}):\n\tself._${id}_config.add_section(${section})\n\ + self._${id}_config.set(${section}, ${option}, str(${writeback}))\nself._${id}_config.write(open(${config_file},\ + \ 'w'))" + +documentation: |- + This block represents a variable that can be read from a config file. + + To save the value back into the config file: enter the name of another variable into the writeback param. When the other variable is changed at runtime, the config file will be re-written. + +file_format: 1 diff --git a/grc/blocks/variable_function_probe.block.yml b/grc/blocks/variable_function_probe.block.yml new file mode 100644 index 0000000..0af7a11 --- /dev/null +++ b/grc/blocks/variable_function_probe.block.yml @@ -0,0 +1,66 @@ +id: variable_function_probe +label: Function Probe +flags: [ show_id, python ] + +parameters: +- id: block_id + label: Block ID + dtype: name + default: my_block_0 +- id: function_name + label: Function Name + dtype: name + default: get_number +- id: function_args + label: Function Args + dtype: raw + hide: ${ ('none' if function_args else 'part') } +- id: poll_rate + label: Poll Rate (Hz) + dtype: real + default: '10' +- id: value + label: Initial Value + dtype: raw + default: '0' + hide: part +value: ${ value } + +templates: + imports: |- + import time + import threading + var_make: self.${id} = ${id} = ${value} + make: |+ + def _${id}_probe(): + self.flowgraph_started.wait() + while True: + <% obj = 'self' + ('.' + block_id if block_id else '') %> + val = ${obj}.${function_name}(${function_args}) + try: + try: + self.doc.add_next_tick_callback(functools.partial(self.set_${id},val)) + except AttributeError: + self.set_${id}(val) + except AttributeError: + pass + time.sleep(1.0 / (${poll_rate})) + _${id}_thread = threading.Thread(target=_${id}_probe) + _${id}_thread.daemon = True + _${id}_thread.start() + callbacks: + - self.set_${id}(${value}) + +documentation: |- + Periodically probe a function and set its value to this variable. + + Set the values for block ID, function name, and function args appropriately: Block ID should be the ID of another block in this flow graph. An empty Block ID references the flow graph itself. Function name should be the name of a class method on that block. Function args are the parameters passed into that function. For a function with no arguments, leave function args blank. When passing a string for the function arguments, quote the string literal: '"arg"'. + + The values will used literally, and generated into the following form: + self.block_id.function_name(function_args) + or, if the Block ID is empty, + self.function_name(function_args) + + To poll a stream for a level, use this with the probe signal block. + +file_format: 1 diff --git a/grc/blocks/variable_struct.block.yml.py b/grc/blocks/variable_struct.block.yml.py new file mode 100644 index 0000000..9e81c16 --- /dev/null +++ b/grc/blocks/variable_struct.block.yml.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python + +MAX_NUM_FIELDS = 20 + +HEADER = """\ +id: variable_struct +label: Struct Variable +flags: [ show_id ] + +parameters: +""" + +FUNCTIONCALL = """\ +field{0}:value{0},\ +""" + +TEMPLATES = """\ + +templates: + imports: "def struct(data): return type('Struct', (object,), data)()" + var_make: |- + self.${{id}} = ${{id}} = struct({{ + % for i in range({0}): + <% + field = context.get('field' + str(i)) + value = context.get('value' + str(i)) + %> + % if len(str(field)) > 2: + ${{field}}: ${{value}}, + % endif + % endfor + }}) +""" + +FIELD0 = """\ +- id: field0 + label: Field 0 + category: Fields + dtype: string + default: field0 + hide: part +""" + +FIELDS = """\ +- id: field{0} + label: Field {0} + category: Fields + dtype: string + hide: part +""" + +VALUES = """\ +- id: value{0} + label: ${{field{0}}} + dtype: raw + default: '0' + hide: ${{ 'none' if field{0} else 'all' }} +""" + +ASSERTS = """\ +- ${{ (str(field{0}) or "a")[0].isalpha() }} +- ${{ (str(field{0}) or "a").isalnum() }} +""" + +FOOTER = """\ + +documentation: |- + This is a simple struct/record like variable. + + Attribute/field names can be specified in the tab 'Fields'. + For each non-empty field a parameter with type raw is shown. + Value access via the dot operator, e.g. "variable_struct_0.field0" + +file_format: 1 +""" + + +def make_yml(num_fields): + return ''.join(( + HEADER.format(num_fields), + FIELD0, ''.join(FIELDS.format(i) for i in range(1, num_fields)), + ''.join(VALUES.format(i) for i in range(num_fields)),'value: ${struct({', + ''.join(FUNCTIONCALL.format(i) for i in range(num_fields)),'})}\n\nasserts:\n', + ''.join(ASSERTS.format(i) for i in range(num_fields)), + ''.join(TEMPLATES.format(num_fields)), + FOOTER + )) + + +if __name__ == '__main__': + import sys + try: + filename = sys.argv[1] + except IndexError: + filename = __file__[:-3] + + data = make_yml(MAX_NUM_FIELDS) + + with open(filename, 'wb') as fp: + fp.write(data.encode()) diff --git a/grc/blocks/yaml_config.block.yml b/grc/blocks/yaml_config.block.yml new file mode 100644 index 0000000..eda26b0 --- /dev/null +++ b/grc/blocks/yaml_config.block.yml @@ -0,0 +1,58 @@ +id: yaml_config +label: YAML Config +flags: [ show_id, python ] + +parameters: +- id: config_file + label: Config File + dtype: file_open + default: "" +- id: schema_file + label: Config Schema + dtype: file_open + default: "" +# Not my favorite thing because it doesn't explicitly close the file, but +# it should be okay. The garbage collector will take care of it. +value: "${ yaml.safe_load(open(config_file)) }" + +asserts: +- ${ schema_file == "" or validate(yaml.safe_load(open(config_file)), json.load(open(schema_file))) is None } + +templates: + imports: |- + import json + import yaml + from jsonschema import validate + var_make: "with open(${config_file}) as fid:\n\ + \ self.${id} = ${id} = yaml.safe_load(fid)\n\ + self.${id}_schema = ${id}_schema = ${schema_file}\n\ + if ${id}_schema:\n\ + \ with open(${id}_schema) as fid:\n\ + \ validate(${id}, json.load(fid))" + +documentation: |- + This block represents a yaml config file that is read in as a dictionary. + + The values can be used directly when instantiating blocks. For example, + Sample Rate: yaml_config["samp_rate"] + + Optionally, a json schema can be specified to validate the configuration. + It may sound odd to use a json schema for a yaml file, but it works and + jsonschema is a rich specification. + + For example, you could have a yaml file that contains: + samp_rate: 1e6 + + And a schema that contains + { + "type": "object", + "properties": { + "samp_rate": {"type": "number", "exclusiveMinimum": 0} + } + } + + If the id of this block is yaml_config_0, then you can access the samp rate + in other blocks as yaml_config_0["samp_rate"] + + +file_format: 1 diff --git a/grc/compiler.py b/grc/compiler.py new file mode 100755 index 0000000..135e1ac --- /dev/null +++ b/grc/compiler.py @@ -0,0 +1,65 @@ +# Copyright 2016 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + + +import argparse +import os +import subprocess + +from gnuradio import gr + +from .core import Messages +from .core.platform import Platform + + +def argument_parser(): + parser = argparse.ArgumentParser(description=( + "Compile a GRC file (.grc) into a GNU Radio Python program and run it." + )) + parser.add_argument("-o", "--output", metavar='DIR', default='.', + help="Output directory for compiled program [default=%(default)s]") + parser.add_argument("-u", "--user-lib-dir", action='store_true', default=False, + help="Output to default hier_block library (overwrites -o)") + parser.add_argument("-r", "--run", action="store_true", default=False, + help="Run the program after compiling [default=%(default)s]") + parser.add_argument(metavar="GRC_FILE", dest='grc_files', nargs='+', + help=".grc file to compile") + return parser + + +def main(args=None): + args = args or argument_parser().parse_args() + + platform = Platform( + name='GNU Radio Companion Compiler', + prefs=gr.prefs(), + version=gr.version(), + version_parts=(gr.major_version(), + gr.api_version(), gr.minor_version()) + ) + platform.build_library() + + output_dir = args.output if not args.user_lib_dir else platform.config.hier_block_lib_dir + try: + # recursive mkdir: os.makedirs doesn't work with .. paths, resolve with realpath + os.makedirs(os.path.realpath(output_dir), exist_ok=True) + except Exception as e: + exit(str(e)) + + Messages.send_init(platform) + flow_graph = file_path = None + for grc_file in args.grc_files: + os.path.exists(grc_file) or exit('Error: missing ' + grc_file) + Messages.send('\n') + platform.config.hier_block_lib_dir = output_dir + flow_graph, file_path = platform.load_and_generate_flow_graph( + os.path.abspath(grc_file), os.path.abspath(output_dir)) + if not file_path: + exit('Compilation error') + if file_path and args.run: + run_command_args = flow_graph.get_run_command(file_path, split=True) + subprocess.call(run_command_args) diff --git a/grc/converter/__init__.py b/grc/converter/__init__.py new file mode 100644 index 0000000..68b0c88 --- /dev/null +++ b/grc/converter/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from .main import Converter diff --git a/grc/converter/__main__.py b/grc/converter/__main__.py new file mode 100644 index 0000000..9286d14 --- /dev/null +++ b/grc/converter/__main__.py @@ -0,0 +1,8 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +# TODO: implement cli diff --git a/grc/converter/block.dtd b/grc/converter/block.dtd new file mode 100644 index 0000000..bb88de9 --- /dev/null +++ b/grc/converter/block.dtd @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/grc/converter/block.py b/grc/converter/block.py new file mode 100644 index 0000000..ceddfc7 --- /dev/null +++ b/grc/converter/block.py @@ -0,0 +1,219 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +""" +Converter for legacy block definitions in XML format + +- Cheetah expressions that can not be converted are passed to Cheetah for now +- Instead of generating a Block subclass directly a string representation is + used and evaluated. This is slower / lamer but allows us to show the user + how a converted definition would look like +""" + + +from collections import OrderedDict, defaultdict +from itertools import chain + +from ..core.io import yaml +from . import cheetah_converter, xml + +current_file_format = 1 +reserved_block_keys = ('import', ) # todo: add more keys + + +def from_xml(filename): + """Load block description from xml file""" + element, version_info = xml.load(filename, 'block.dtd') + + try: + data = convert_block_xml(element) + except NameError: + raise ValueError('Conversion failed', filename) + + return data + + +def dump(data, stream): + out = yaml.dump(data) + + replace = [ + ('parameters:', '\nparameters:'), + ('inputs:', '\ninputs:'), + ('outputs:', '\noutputs:'), + ('templates:', '\ntemplates:'), + ('documentation:', '\ndocumentation:'), + ('file_format:', '\nfile_format:'), + ] + for r in replace: + out = out.replace(*r) + prefix = '# auto-generated by grc.converter\n\n' + stream.write(prefix + out) + + +no_value = object() +dummy = cheetah_converter.DummyConverter() + + +def convert_block_xml(node): + converter = cheetah_converter.Converter(names={ + param_node.findtext('key'): { + opt_node.text.split(':')[0] + for opt_node in next(param_node.iterfind('option'), param_node).iterfind('opt') + } for param_node in node.iterfind('param') + }) + + block_id = node.findtext('key') + if block_id in reserved_block_keys: + block_id += '_' + + data = OrderedDict() + data['id'] = block_id + data['label'] = node.findtext('name') or no_value + data['category'] = node.findtext('category') or no_value + data['flags'] = [n.text for n in node.findall('flags')] + data['flags'] += ['show_id'] if block_id.startswith('variable') else [] + if not data['flags']: + data['flags'] = no_value + + data['parameters'] = [convert_param_xml(param_node, converter.to_python_dec) + for param_node in node.iterfind('param')] or no_value + # data['params'] = {p.pop('key'): p for p in data['params']} + + data['inputs'] = [convert_port_xml(port_node, converter.to_python_dec) + for port_node in node.iterfind('sink')] or no_value + + data['outputs'] = [convert_port_xml(port_node, converter.to_python_dec) + for port_node in node.iterfind('source')] or no_value + data['value'] = ( + converter.to_python_dec(node.findtext('var_value')) or + ('${ value }' if block_id.startswith('variable') else no_value) + ) + + data['asserts'] = [converter.to_python_dec(check_node.text) + for check_node in node.iterfind('check')] or no_value + + data['templates'] = convert_templates( + node, converter.to_mako, block_id) or no_value + + docs = node.findtext('doc') + if docs: + docs = docs.strip().replace('\\\n', '') + data['documentation'] = yaml.MultiLineString(docs) + + data['file_format'] = current_file_format + + data = OrderedDict((key, value) + for key, value in data.items() if value is not no_value) + auto_hide_params_for_item_sizes(data) + + return data + + +def auto_hide_params_for_item_sizes(data): + item_size_templates = [] + vlen_templates = [] + for port in chain(*[data.get(direction, []) for direction in ['inputs', 'outputs']]): + for key in ['dtype', 'multiplicity']: + item_size_templates.append(str(port.get(key, ''))) + vlen_templates.append(str(port.get('vlen', ''))) + item_size_templates = ' '.join( + value for value in item_size_templates if '${' in value) + vlen_templates = ' '.join( + value for value in vlen_templates if '${' in value) + + for param in data.get('parameters', []): + if param['id'] in item_size_templates: + param.setdefault('hide', 'part') + if param['id'] in vlen_templates: + param.setdefault('hide', "${ 'part' if vlen == 1 else 'none' }") + + +def convert_templates(node, convert, block_id=''): + templates = OrderedDict() + + imports = '\n'.join(convert(import_node.text) + for import_node in node.iterfind('import')) + if '\n' in imports: + imports = yaml.MultiLineString(imports) + templates['imports'] = imports or no_value + + templates['var_make'] = convert( + node.findtext('var_make') or '') or no_value + + make = convert(node.findtext('make') or '') + if make: + check_mako_template(block_id, make) + if '\n' in make: + make = yaml.MultiLineString(make) + templates['make'] = make or no_value + + templates['callbacks'] = [ + convert(cb_node.text) for cb_node in node.iterfind('callback') + ] or no_value + + return OrderedDict((key, value) for key, value in templates.items() if value is not no_value) + + +def convert_param_xml(node, convert): + param = OrderedDict() + param['id'] = node.findtext('key').strip() + param['label'] = node.findtext('name').strip() + param['category'] = node.findtext('tab') or no_value + + param['dtype'] = convert(node.findtext('type') or '') + param['default'] = node.findtext('value') or no_value + + options = yaml.ListFlowing(on.findtext('key') + for on in node.iterfind('option')) + option_labels = yaml.ListFlowing(on.findtext( + 'name') for on in node.iterfind('option')) + param['options'] = options or no_value + if not all(str(o).title() == l for o, l in zip(options, option_labels)): + param['option_labels'] = option_labels + + attributes = defaultdict(yaml.ListFlowing) + for option_n in node.iterfind('option'): + for opt_n in option_n.iterfind('opt'): + key, value = opt_n.text.split(':', 2) + attributes[key].append(value) + param['option_attributes'] = dict(attributes) or no_value + + param['hide'] = convert(node.findtext('hide')) or no_value + + return OrderedDict((key, value) for key, value in param.items() if value is not no_value) + + +def convert_port_xml(node, convert): + port = OrderedDict() + label = node.findtext('name') + # default values: + port['label'] = label if label not in ('in', 'out') else no_value + + dtype = convert(node.findtext('type')) + # TODO: detect dyn message ports + port['domain'] = domain = 'message' if dtype == 'message' else 'stream' + if domain == 'message': + port['id'], port['label'] = label, no_value + else: + port['dtype'] = dtype + vlen = node.findtext('vlen') + port['vlen'] = int(vlen) if vlen and vlen.isdigit( + ) else convert(vlen) or no_value + + port['multiplicity'] = convert(node.findtext('nports')) or no_value + port['optional'] = bool(node.findtext('optional')) or no_value + port['hide'] = convert(node.findtext('hide')) or no_value + + return OrderedDict((key, value) for key, value in port.items() if value is not no_value) + + +def check_mako_template(block_id, expr): + import sys + from mako.template import Template + try: + Template(expr) + except Exception as error: + print(block_id, expr, type(error), error, + '', sep='\n', file=sys.stderr) diff --git a/grc/converter/block_tree.dtd b/grc/converter/block_tree.dtd new file mode 100644 index 0000000..bae9b87 --- /dev/null +++ b/grc/converter/block_tree.dtd @@ -0,0 +1,15 @@ + + + + + diff --git a/grc/converter/block_tree.py b/grc/converter/block_tree.py new file mode 100644 index 0000000..407444a --- /dev/null +++ b/grc/converter/block_tree.py @@ -0,0 +1,44 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +""" +Converter for legacy block tree definitions in XML format +""" + + +from ..core.io import yaml +from . import xml + + +def from_xml(filename): + """Load block tree description from xml file""" + element, version_info = xml.load(filename, 'block_tree.dtd') + + try: + data = convert_category_node(element) + except NameError: + raise ValueError('Conversion failed', filename) + + return data + + +def dump(data, stream): + out = yaml.dump(data, indent=2) + prefix = '# auto-generated by grc.converter\n\n' + stream.write(prefix + out) + + +def convert_category_node(node): + """convert nested tags to nested lists dicts""" + assert node.tag == 'cat' + name, elements = '', [] + for child in node: + if child.tag == 'name': + name = child.text.strip() + elif child.tag == 'block': + elements.append(child.text.strip()) + elif child.tag == 'cat': + elements.append(convert_category_node(child)) + return {name: elements} diff --git a/grc/converter/cheetah_converter.py b/grc/converter/cheetah_converter.py new file mode 100644 index 0000000..778b9c9 --- /dev/null +++ b/grc/converter/cheetah_converter.py @@ -0,0 +1,269 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import collections +import re +import string + +delims = {'(': ')', '[': ']', '{': '}', '': ', #\\*:'} +identifier_start = '_' + string.ascii_letters + ''.join(delims.keys()) +string_delims = '"\'' + +cheetah_substitution = re.compile( + r'^\$((?P\()|(?P\{)|(?P\[)|)' + r'(?P[_a-zA-Z][_a-zA-Z0-9]*(?:\.[_a-zA-Z][_a-zA-Z0-9]*)?)(?P\(\))?' + r'(?(d1)\)|(?(d2)\}|(?(d3)\]|)))$' +) +cheetah_inline_if = re.compile( + r'#if (?P.*) then (?P.*?) ?else (?P.*?) ?(#|$)') + + +class Python(object): + start = '' + end = '' + nested_start = '' + nested_end = '' + eval = '' + type = str # yaml_output.Eval + + +class FormatString(Python): + start = '{' + end = '}' + nested_start = '{' + nested_end = '}' + eval = ':eval' + type = str + + +class Mako(Python): + start = '${' + end = '}' + nested_start = '' + nested_end = '' + type = str + + +class Converter(object): + + def __init__(self, names): + self.stats = collections.defaultdict(int) + self.names = set(names) + self.extended = set(self._iter_identifiers(names)) + + @staticmethod + def _iter_identifiers(names): + if not isinstance(names, dict): + names = {name: {} for name in names} + for key, sub_keys in names.items(): + yield key + for sub_key in sub_keys: + yield '{}.{}'.format(key, sub_key) + + def to_python(self, expr): + return self.convert(expr=expr, spec=Python) + + def to_python_dec(self, expr): + converted = self.convert(expr=expr, spec=Python) + if converted and converted != expr: + converted = '${ ' + converted.strip() + ' }' + return converted + + def to_format_string(self, expr): + return self.convert(expr=expr, spec=FormatString) + + def to_mako(self, expr): + return self.convert(expr=expr, spec=Mako) + + def convert(self, expr, spec=Python): + if not expr: + return '' + + elif '$' not in expr: + return expr + + try: + return self.convert_simple(expr, spec) + except ValueError: + pass + + try: + if '#if' in expr and '\n' not in expr: + expr = self.convert_inline_conditional(expr, spec) + return self.convert_hard(expr, spec) + except ValueError: + return 'Cheetah! ' + expr + + def convert_simple(self, expr, spec=Python): + match = cheetah_substitution.match(expr) + if not match: + raise ValueError('Not a simple substitution: ' + expr) + + identifier = match.group('arg') + if identifier not in self.extended: + raise NameError('Unknown substitution {!r}'.format(identifier)) + if match.group('eval'): + identifier += spec.eval + + out = spec.start + identifier + spec.end + if '$' in out or '#' in out: + raise ValueError('Failed to convert: ' + expr) + + self.stats['simple'] += 1 + return spec.type(out) + + def convert_hard(self, expr, spec=Python): + lines = '\n'.join(self.convert_hard_line(line, spec) + for line in expr.split('\n')) + if spec == Mako: + # no line-continuation before a mako control structure + lines = re.sub(r'\\\n(\s*%)', r'\n\1', lines) + return lines + + def convert_hard_line(self, expr, spec=Python): + if spec == Mako: + if '#set' in expr: + ws, set_, statement = expr.partition('#set ') + return ws + '<% ' + self.to_python(statement) + ' %>' + + if '#if' in expr: + ws, if_, condition = expr.partition('#if ') + return ws + '% if ' + self.to_python(condition) + ':' + if '#else if' in expr: + ws, elif_, condition = expr.partition('#else if ') + return ws + '% elif ' + self.to_python(condition) + ':' + if '#else' in expr: + return expr.replace('#else', '% else:') + if '#end if' in expr: + return expr.replace('#end if', '% endif') + + if '#slurp' in expr: + expr = expr.split('#slurp', 1)[0] + '\\' + return self.convert_hard_replace(expr, spec) + + def convert_hard_replace(self, expr, spec=Python): + counts = collections.Counter() + + def all_delims_closed(): + for opener_, closer_ in delims.items(): + if counts[opener_] != counts[closer_]: + return False + return True + + def extra_close(): + for opener_, closer_ in delims.items(): + if counts[opener_] < counts[closer_]: + return True + return False + + out = [] + delim_to_find = False + + pos = 0 + char = '' + in_string = None + while pos < len(expr): + prev, char = char, expr[pos] + counts.update(char) + + if char in string_delims: + if not in_string: + in_string = char + elif char == in_string: + in_string = None + out.append(char) + pos += 1 + continue + if in_string: + out.append(char) + pos += 1 + continue + + if char == '$': + pass # no output + + elif prev == '$': + if char not in identifier_start: # not a substitution + out.append('$' + char) # now print the $ we skipped over + + elif not delim_to_find: # start of a substitution + try: + delim_to_find = delims[char] + out.append(spec.start) + except KeyError: + if char in identifier_start: + delim_to_find = delims[''] + out.append(spec.start) + out.append(char) + + counts.clear() + counts.update(char) + + else: # nested substitution: simply match known variable names + found = False + for known_identifier in self.names: + if expr[pos:].startswith(known_identifier): + found = True + break + if found: + out.append(spec.nested_start) + out.append(known_identifier) + out.append(spec.nested_end) + pos += len(known_identifier) + continue + + elif delim_to_find and char in delim_to_find and all_delims_closed(): # end of substitution + out.append(spec.end) + if char in delims['']: + out.append(char) + delim_to_find = False + + # end of substitution + elif delim_to_find and char in ')]}' and extra_close(): + out.append(spec.end) + out.append(char) + delim_to_find = False + + else: + out.append(char) + + pos += 1 + + if delim_to_find == delims['']: + out.append(spec.end) + + out = ''.join(out) + # fix: eval stuff + out = re.sub(r'(?P' + r'|'.join(self.extended) + + r')\(\)', r'\g', out) + + self.stats['hard'] += 1 + return spec.type(out) + + def convert_inline_conditional(self, expr, spec=Python): + if spec == FormatString: + raise ValueError('No conditionals in format strings: ' + expr) + matcher = r'\g if \g else \g' + if spec == Python: + matcher = '(' + matcher + ')' + expr = cheetah_inline_if.sub(matcher, expr) + return spec.type(self.convert_hard(expr, spec)) + + +class DummyConverter(object): + + def __init__(self, names={}): + pass + + def to_python(self, expr): + return expr + + def to_format_string(self, expr): + return expr + + def to_mako(self, expr): + return expr diff --git a/grc/converter/flow_graph.dtd b/grc/converter/flow_graph.dtd new file mode 100644 index 0000000..eae755c --- /dev/null +++ b/grc/converter/flow_graph.dtd @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/grc/converter/flow_graph.py b/grc/converter/flow_graph.py new file mode 100644 index 0000000..c03802a --- /dev/null +++ b/grc/converter/flow_graph.py @@ -0,0 +1,121 @@ +# Copyright 2017,2018 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import ast +from collections import OrderedDict + +from ..core.io import yaml +from . import xml + + +def from_xml(filename): + """Load flow graph from xml file""" + element, version_info = xml.load(filename, 'flow_graph.dtd') + + data = convert_flow_graph_xml(element) + try: + file_format = int(version_info['format']) + except KeyError: + file_format = _guess_file_format_1(data) + + data['metadata'] = {'file_format': file_format} + + return data + + +def dump(data, stream): + out = yaml.dump(data, indent=2) + + replace = [ + ('blocks:', '\nblocks:'), + ('connections:', '\nconnections:'), + ('metadata:', '\nmetadata:'), + ] + for r in replace: + out = out.replace(*r) + prefix = '# auto-generated by grc.converter\n\n' + stream.write(prefix + out) + + +def convert_flow_graph_xml(node): + blocks = [ + convert_block(block_data) + for block_data in node.findall('block') + ] + + options = next(b for b in blocks if b['id'] == 'options') + blocks.remove(options) + options.pop('id') + + connections = [ + convert_connection(connection) + for connection in node.findall('connection') + ] + + flow_graph = OrderedDict() + flow_graph['options'] = options + flow_graph['blocks'] = blocks + flow_graph['connections'] = connections + return flow_graph + + +def convert_block(data): + block_id = data.findtext('key') + + params = OrderedDict(sorted( + (param.findtext('key'), param.findtext('value')) + for param in data.findall('param') + )) + if block_id == "import": + params["imports"] = params.pop("import") + states = OrderedDict() + x, y = ast.literal_eval(params.pop('_coordinate', '(10, 10)')) + states['coordinate'] = yaml.ListFlowing([x, y]) + states['rotation'] = int(params.pop('_rotation', '0')) + enabled = params.pop('_enabled', 'True') + states['state'] = ( + 'enabled' if enabled in ('1', 'True') else + 'bypassed' if enabled == '2' else + 'disabled' + ) + + block = OrderedDict() + if block_id != 'options': + block['name'] = params.pop('id') + block['id'] = block_id + block['parameters'] = params + block['states'] = states + + return block + + +def convert_connection(data): + src_blk_id = data.findtext('source_block_id') + src_port_id = data.findtext('source_key') + snk_blk_id = data.findtext('sink_block_id') + snk_port_id = data.findtext('sink_key') + + if src_port_id.isdigit(): + src_port_id = src_port_id + if snk_port_id.isdigit(): + snk_port_id = snk_port_id + + return yaml.ListFlowing([src_blk_id, src_port_id, snk_blk_id, snk_port_id]) + + +def _guess_file_format_1(data): + """Try to guess the file format for flow-graph files without version tag""" + + def has_numeric_port_ids(src_id, src_port_id, snk_id, snk_port_id): + return src_port_id.isdigit() and snk_port_id.isdigit() + + try: + if any(not has_numeric_port_ids(*con) for con in data['connections']): + return 1 + except (TypeError, KeyError): + pass + return 0 diff --git a/grc/converter/main.py b/grc/converter/main.py new file mode 100644 index 0000000..2467023 --- /dev/null +++ b/grc/converter/main.py @@ -0,0 +1,160 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from codecs import open +import json +import logging +import os + +from ..main import get_state_directory +from ..core import Constants +from . import block_tree, block + +path = os.path +logger = logging.getLogger(__name__) + +excludes = [ + 'qtgui_', + '.grc_gnuradio/', + os.path.join(get_state_directory(), Constants.GRC_SUBDIR), + 'blks2', + 'wxgui', + 'epy_block.xml', + 'virtual_sink.xml', + 'virtual_source.xml', + 'dummy.xml', + 'variable_struct.xml', # todo: re-implement as class + 'digital_constellation', # todo: fix template +] + + +class Converter(object): + + def __init__(self, search_path: str, output_dir: str = os.path.join(get_state_directory(), Constants.GRC_SUBDIR)): + self.search_path = search_path + self.output_dir = os.path.expanduser(output_dir) + logger.info("Saving converted files to {}".format(self.output_dir)) + + self._force = False + + converter_module_path = path.dirname(__file__) + self._converter_mtime = max(path.getmtime(path.join(converter_module_path, module)) + for module in os.listdir(converter_module_path) + if not module.endswith('flow_graph.py')) + + self.cache_file = os.path.join(self.output_dir, '_cache.json') + self.cache = {} + + def run(self, force=False): + self._force = force + + try: + logger.debug( + "Loading block cache from: {}".format(self.cache_file)) + with open(self.cache_file, encoding='utf-8') as cache_file: + self.cache = byteify(json.load(cache_file)) + except (IOError, ValueError): + self.cache = {} + self._force = True + need_cache_write = False + + if not path.isdir(self.output_dir): + os.makedirs(self.output_dir) + if self._force: + for name in os.listdir(self.output_dir): + os.remove(os.path.join(self.output_dir, name)) + + for xml_file in self.iter_files_in_block_path(): + if xml_file.endswith("block_tree.xml"): + changed = self.load_category_tree_xml(xml_file) + elif xml_file.endswith('domain.xml'): + continue + else: + changed = self.load_block_xml(xml_file) + + if changed: + need_cache_write = True + + if need_cache_write: + logger.debug('Saving %d entries to json cache', len(self.cache)) + with open(self.cache_file, 'w', encoding='utf-8') as cache_file: + json.dump(self.cache, cache_file) + + def load_block_xml(self, xml_file, force=False): + """Load block description from xml file""" + if any(part in xml_file for part in excludes) and not force: + logger.warn('Skipping {} because name is blacklisted!' + .format(xml_file)) + return False + elif any(part in xml_file for part in excludes) and force: + logger.warn('Forcing conversion of blacklisted file: {}' + .format(xml_file)) + + block_id_from_xml = path.basename(xml_file)[:-4] + yml_file = path.join(self.output_dir, block_id_from_xml + '.block.yml') + + if not self.needs_conversion(xml_file, yml_file): + return # yml file up-to-date + + logger.info('Converting block %s', path.basename(xml_file)) + data = block.from_xml(xml_file) + if block_id_from_xml != data['id']: + logger.warning('block_id and filename differ') + self.cache[yml_file] = data + + with open(yml_file, 'w', encoding='utf-8') as yml_file: + block.dump(data, yml_file) + return True + + def load_category_tree_xml(self, xml_file): + """Validate and parse category tree file and add it to list""" + module_name = path.basename( + xml_file)[:-len('block_tree.xml')].rstrip('._-') + yml_file = path.join(self.output_dir, module_name + '.tree.yml') + + if not self.needs_conversion(xml_file, yml_file): + return # yml file up-to-date + + logger.info('Converting module %s', path.basename(xml_file)) + data = block_tree.from_xml(xml_file) + self.cache[yml_file] = data + + with open(yml_file, 'w', encoding='utf-8') as yml_file: + block_tree.dump(data, yml_file) + return True + + def needs_conversion(self, source, destination): + """Check if source has already been converted and destination is up-to-date""" + if self._force or not path.exists(destination): + return True + xml_time = path.getmtime(source) + yml_time = path.getmtime(destination) + + return yml_time < xml_time or yml_time < self._converter_mtime + + def iter_files_in_block_path(self, suffix='.xml'): + """Iterator for block descriptions and category trees""" + for block_path in self.search_path: + if path.isfile(block_path): + yield block_path + elif path.isdir(block_path): + for root, _, files in os.walk(block_path, followlinks=True): + for name in files: + if name.endswith(suffix): + yield path.join(root, name) + else: + logger.warning( + 'Invalid entry in search path: {}'.format(block_path)) + + +def byteify(data): + if isinstance(data, dict): + return {byteify(key): byteify(value) for key, value in data.items()} + elif isinstance(data, list): + return [byteify(element) for element in data] + else: + return data diff --git a/grc/converter/xml.py b/grc/converter/xml.py new file mode 100644 index 0000000..fab97f9 --- /dev/null +++ b/grc/converter/xml.py @@ -0,0 +1,73 @@ +# Copyright 2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import re +from os import path + +try: + # raise ImportError() + from lxml import etree + HAVE_LXML = True +except ImportError: + import xml.etree.ElementTree as etree + HAVE_LXML = False + + +_validator_cache = {None: lambda xml: True} + + +def _get_validator(dtd=None): + validator = _validator_cache.get(dtd) + if not validator: + if not path.isabs(dtd): + dtd = path.join(path.dirname(__file__), dtd) + validator = _validator_cache[dtd] = etree.DTD(dtd).validate + return validator + + +def load_lxml(filename, document_type_def=None): + """Load block description from xml file""" + + try: + xml_tree = etree.parse(filename) + _get_validator(document_type_def) + element = xml_tree.getroot() + except etree.LxmlError: + raise ValueError("Failed to parse or validate {}".format(filename)) + + version_info = {} + for inst in xml_tree.xpath('/processing-instruction()'): + if inst.target == 'grc': + version_info.update(inst.attrib) + + return element, version_info + + +def load_stdlib(filename, document_type_def=None): + """Load block description from xml file""" + + if isinstance(filename, str): + with open(filename, 'rb') as xml_file: + data = xml_file.read().decode('utf-8') + else: # Already opened + data = filename.read().decode('utf-8') + + try: + element = etree.fromstring(data) + except etree.ParseError: + raise ValueError("Failed to parse {}".format(filename)) + + version_info = {} + for body in re.findall(r'<\?(.*?)\?>', data): + inst = etree.fromstring('<' + body + '/>') + if inst.tag == 'grc': + version_info.update(inst.attrib) + + return element, version_info + + +load = load_lxml if HAVE_LXML else load_stdlib diff --git a/grc/core/Config.py b/grc/core/Config.py new file mode 100644 index 0000000..b7d52e5 --- /dev/null +++ b/grc/core/Config.py @@ -0,0 +1,79 @@ +"""Copyright 2024 The GNU Radio Contributors +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later +""" + + +import os +from os.path import expanduser, normpath, expandvars, exists +from collections import OrderedDict + +from ..main import get_state_directory, get_config_file_path +from . import Constants + + +class Config(object): + name = 'GNU Radio Companion (no gui)' + license = __doc__.strip() + website = 'https://www.gnuradio.org/' + + hier_block_lib_dir = os.environ.get('GRC_HIER_PATH', get_state_directory()) + + def __init__(self, version, version_parts=None, name=None, prefs=None): + self._gr_prefs = prefs if prefs else DummyPrefs() + self.version = version + self.version_parts = version_parts or version[1:].split( + '-', 1)[0].split('.')[:3] + self.enabled_components = self._gr_prefs.get_string( + 'grc', 'enabled_components', '') + if name: + self.name = name + + @property + def block_paths(self): + paths_sources = ( + self.hier_block_lib_dir, + os.environ.get('GRC_BLOCKS_PATH', ''), + self._gr_prefs.get_string('grc', 'local_blocks_path', ''), + self._gr_prefs.get_string('grc', 'global_blocks_path', ''), + ) + + collected_paths = sum((paths.split(os.pathsep) + for paths in paths_sources), []) + + valid_paths = [normpath(expanduser(expandvars(path))) + for path in collected_paths if exists(path)] + # Deduplicate paths to avoid warnings about finding blocks twice, but + # preserve order of paths + valid_paths = list(OrderedDict.fromkeys(valid_paths)) + + return valid_paths + + @property + def example_paths(self): + return [self._gr_prefs.get_string('grc', 'examples_path', '')] + + @property + def default_flow_graph(self): + user_default = ( + os.environ.get('GRC_DEFAULT_FLOW_GRAPH') or + self._gr_prefs.get_string('grc', 'default_flow_graph', '') or + os.path.join(self.hier_block_lib_dir, 'default_flow_graph.grc') + ) + return user_default if exists(user_default) else Constants.DEFAULT_FLOW_GRAPH + + +class DummyPrefs(object): + + def get_string(self, category, item, default): + return str(default) + + def set_string(self, category, item, value): + pass + + def get_long(self, category, item, default): + return int(default) + + def save(self): + pass diff --git a/grc/core/Connection.py b/grc/core/Connection.py new file mode 100644 index 0000000..2db7596 --- /dev/null +++ b/grc/core/Connection.py @@ -0,0 +1,182 @@ +""" +Copyright 2008-2015 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + +import collections + +from .base import Element +from .Constants import ALIASES_OF +from .utils.descriptors import lazy_property + + +class Connection(Element): + """ + Stores information about a connection between two block ports. This class + knows: + - Where the source and sink ports are (on which blocks) + - The domain (message, stream, ...) + - Which parameters are associated with this connection + """ + + is_connection = True + category = [] + documentation = {'': ''} + doc_url = '' + + def __init__(self, parent, source, sink): + """ + Make a new connection given the parent and 2 ports. + + Args: + parent: the parent of this element (a flow graph) + source: a port (any direction) + sink: a port (any direction) + @throws Error cannot make connection + + Returns: + a new connection + """ + Element.__init__(self, parent) + + if not source.is_source: + source, sink = sink, source + if not source.is_source: + raise ValueError('Connection could not isolate source') + if not sink.is_sink: + raise ValueError('Connection could not isolate sink') + + self.source_port = source + self.sink_port = sink + + # Unlike the blocks, connection parameters are defined in the connection + # domain definition files, as all connections within the same domain + # share the same properties. + param_factory = self.parent_platform.make_param + conn_parameters = self.parent_platform.connection_params.get(self.type, {}) + self.params = collections.OrderedDict( + (data['id'], param_factory(parent=self, **data)) + for data in conn_parameters + ) + + def __str__(self): + return 'Connection (\n\t{}\n\t\t{}\n\t{}\n\t\t{}\n)'.format( + self.source_block, self.source_port, self.sink_block, self.sink_port, + ) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self.source_port == other.source_port and self.sink_port == other.sink_port + + def __hash__(self): + return hash((self.source_port, self.sink_port)) + + def __iter__(self): + return iter((self.source_port, self.sink_port)) + + def children(self): + """ This includes the connection parameters """ + return self.params.values() + + @lazy_property + def source_block(self): + return self.source_port.parent_block + + @lazy_property + def sink_block(self): + return self.sink_port.parent_block + + @property + def type(self): + return self.source_port.domain, self.sink_port.domain + + @property + def enabled(self): + """ + Get the enabled state of this connection. + + Returns: + true if source and sink blocks are enabled + """ + return self.source_block.enabled and self.sink_block.enabled + + @property + def label(self): + """ Returns a label for dialogs """ + src_domain, sink_domain = [ + self.parent_platform.domains[d].name for d in self.type] + return f'Connection ({src_domain} → {sink_domain})' + + @property + def namespace_templates(self): + """Returns everything we want to have available in the template rendering""" + return {key: param.template_arg for key, param in self.params.items()} + + def validate(self): + """ + Validate the connections. + The ports must match in io size. + """ + Element.validate(self) + platform = self.parent_platform + + if self.type not in platform.connection_templates: + self.add_error_message('No connection known between domains "{}" and "{}"' + ''.format(*self.type)) + + source_dtype = self.source_port.dtype + sink_dtype = self.sink_port.dtype + if source_dtype != sink_dtype and source_dtype not in ALIASES_OF.get( + sink_dtype, set() + ): + self.add_error_message('Source IO type "{}" does not match sink IO type "{}".'.format( + source_dtype, sink_dtype)) + + source_size = self.source_port.item_size + sink_size = self.sink_port.item_size + if source_size != sink_size: + self.add_error_message( + 'Source IO size "{}" does not match sink IO size "{}".'.format(source_size, sink_size)) + + ############################################## + # Import/Export Methods + ############################################## + def export_data(self): + """ + Export this connection's info. + + Returns: + A tuple with connection info, and parameters. + """ + # See if we need to use file format version 2: + if self.params: + return { + 'src_blk_id': self.source_block.name, + 'src_port_id': self.source_port.key, + 'snk_blk_id': self.sink_block.name, + 'snk_port_id': self.sink_port.key, + 'params': collections.OrderedDict(sorted( + (param_id, param.value) + for param_id, param in self.params.items())), + } + + # If there's no reason to do otherwise, we can export info as + # FLOW_GRAPH_FILE_FORMAT_VERSION 1 format: + return [ + self.source_block.name, self.source_port.key, + self.sink_block.name, self.sink_port.key, + ] + + def import_data(self, params): + """ + Import connection parameters. + """ + for key, value in params.items(): + try: + self.params[key].set_value(value) + except KeyError: + continue diff --git a/grc/core/Constants.py b/grc/core/Constants.py new file mode 100644 index 0000000..2fdabd7 --- /dev/null +++ b/grc/core/Constants.py @@ -0,0 +1,147 @@ +""" +Copyright 2008-2016 Free Software Foundation, Inc. +Copyright 2021 GNU Radio contributors +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import os +import numbers +import stat + +import numpy + + +# Data files +DATA_DIR = os.path.dirname(__file__) +BLOCK_DTD = os.path.join(DATA_DIR, 'block.dtd') +DEFAULT_FLOW_GRAPH = os.path.join(DATA_DIR, 'default_flow_graph.grc') +DEFAULT_FLOW_GRAPH_ID = 'default' + +PROJECT_DEFAULT_DIR = 'gnuradio' +GRC_SUBDIR = 'grc' +CACHE_FILE_NAME = 'cache_v2.json' +FALLBACK_CACHE_FILE = os.path.expanduser(f'~/.cache/{PROJECT_DEFAULT_DIR}/{GRC_SUBDIR}/{CACHE_FILE_NAME}') +EXAMPLE_CACHE_FILE_NAME = 'example_cache.json' +FALLBACK_EXAMPLE_CACHE_FILE = os.path.expanduser(f'~/.cache/{PROJECT_DEFAULT_DIR}/{GRC_SUBDIR}/{EXAMPLE_CACHE_FILE_NAME}') + +BLOCK_DESCRIPTION_FILE_FORMAT_VERSION = 1 +# File format versions: +# This constant is the max known version. If a version higher than this shows +# up, we assume we can't handle it. +# 0: undefined / legacy +# 1: non-numeric message port keys (label is used instead) +# 2: connection info is stored as dictionary +FLOW_GRAPH_FILE_FORMAT_VERSION = 2 + +# Param tabs +DEFAULT_PARAM_TAB = "General" +ADVANCED_PARAM_TAB = "Advanced" +DEFAULT_BLOCK_MODULE_NAME = '(no module specified)' + +# Port domains +GR_STREAM_DOMAIN = "stream" +GR_MESSAGE_DOMAIN = "message" +DEFAULT_DOMAIN = GR_STREAM_DOMAIN + +# File creation modes +TOP_BLOCK_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | \ + stat.S_IWGRP | stat.S_IXGRP | stat.S_IROTH +HIER_BLOCK_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH + +PARAM_TYPE_NAMES = { + 'raw', 'enum', + 'complex', 'real', 'float', 'int', 'short', 'byte', + 'complex_vector', 'real_vector', 'float_vector', 'int_vector', + 'hex', 'string', 'bool', + 'file_open', 'file_save', 'dir_select', '_multiline', '_multiline_python_external', + 'id', 'stream_id', 'name', + 'gui_hint', + 'import', +} + +PARAM_TYPE_MAP = { + 'complex': numbers.Complex, + 'float': numbers.Real, + 'real': numbers.Real, + 'int': numbers.Integral, +} + +# Define types, native python + numpy +VECTOR_TYPES = (tuple, list, set, numpy.ndarray) + +# Updating colors. Using the standard color palette from: +# http://www.google.com/design/spec/style/color.html#color-color-palette +# Most are based on the main, primary color standard. Some are within +# that color's spectrum when it was deemed necessary. +GRC_COLOR_BROWN = '#795548' +GRC_COLOR_BLUE = '#2196F3' +GRC_COLOR_LIGHT_GREEN = '#8BC34A' +GRC_COLOR_GREEN = '#4CAF50' +GRC_COLOR_AMBER = '#FFC107' +GRC_COLOR_PURPLE = '#9C27B0' +GRC_COLOR_CYAN = '#00BCD4' +GRC_COLOR_GR_ORANGE = '#FF6905' +GRC_COLOR_ORANGE = '#F57C00' +GRC_COLOR_LIME = '#CDDC39' +GRC_COLOR_TEAL = '#009688' +GRC_COLOR_YELLOW = '#FFEB3B' +GRC_COLOR_PINK = '#F50057' +GRC_COLOR_PURPLE_A100 = '#EA80FC' +GRC_COLOR_PURPLE_A400 = '#D500F9' +GRC_COLOR_DARK_GREY = '#72706F' +GRC_COLOR_GREY = '#BDBDBD' +GRC_COLOR_WHITE = '#FFFFFF' + +CORE_TYPES = ( # name, key, sizeof, color + ('Complex Float 64', 'fc64', 16, GRC_COLOR_BROWN), + ('Complex Float 32', 'fc32', 8, GRC_COLOR_BLUE), + ('Complex Integer 64', 'sc64', 16, GRC_COLOR_LIGHT_GREEN), + ('Complex Integer 32', 'sc32', 8, GRC_COLOR_GREEN), + ('Complex Integer 16', 'sc16', 4, GRC_COLOR_AMBER), + ('Complex Integer 8', 'sc8', 2, GRC_COLOR_PURPLE), + ('Float 64', 'f64', 8, GRC_COLOR_CYAN), + ('Float 32', 'f32', 4, GRC_COLOR_ORANGE), + ('Integer 64', 's64', 8, GRC_COLOR_LIME), + ('Integer 32', 's32', 4, GRC_COLOR_TEAL), + ('Integer 16', 's16', 2, GRC_COLOR_YELLOW), + ('Integer 8', 's8', 1, GRC_COLOR_PURPLE_A400), + ('Bits (unpacked byte)', 'bit', 1, GRC_COLOR_PURPLE_A100), + ('Async Message', 'message', 0, GRC_COLOR_GREY), + ('Bus Connection', 'bus', 0, GRC_COLOR_WHITE), + ('Wildcard', '', 0, GRC_COLOR_WHITE), +) + +ALIAS_TYPES = { + 'complex': (8, GRC_COLOR_BLUE), + 'float': (4, GRC_COLOR_ORANGE), + 'int': (4, GRC_COLOR_TEAL), + 'short': (2, GRC_COLOR_YELLOW), + 'byte': (1, GRC_COLOR_PURPLE_A400), + 'bits': (1, GRC_COLOR_PURPLE_A100), +} + +ALIASES_OF = { + 'complex': {'fc32'}, + 'float': {'f32'}, + 'int': {'s32'}, + 'short': {'s16', 'sc16'}, + 'byte': {'s8', 'sc8'}, + 'bits': {'bit'}, + + 'fc32': {'complex'}, + 'f32': {'float'}, + 's32': {'int'}, + 's16': {'short'}, + 'sc16': {'short'}, + 's8': {'byte'}, + 'sc8': {'byte'}, + 'bit': {'bits'}, +} + +TYPE_TO_SIZEOF = {key: sizeof for name, key, sizeof, color in CORE_TYPES} +TYPE_TO_SIZEOF.update((key, sizeof) + for key, (sizeof, _) in ALIAS_TYPES.items()) diff --git a/grc/core/FlowGraph.py b/grc/core/FlowGraph.py new file mode 100644 index 0000000..91c512a --- /dev/null +++ b/grc/core/FlowGraph.py @@ -0,0 +1,578 @@ +# Copyright 2008-2015 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import collections +import itertools +import sys +import types +import logging +import shlex +import yaml +from operator import methodcaller, attrgetter +from typing import (List, Set, Optional, Iterator, Iterable, Tuple, Union, OrderedDict) + +from . import Messages +from .base import Element +from .blocks import Block +from .params import Param +from .utils import expr_utils + +log = logging.getLogger(__name__) + + +class FlowGraph(Element): + + is_flow_graph = True + + def __init__(self, parent: Element): + """ + Make a flow graph from the arguments. + + Args: + parent: a platforms with blocks and element factories + + Returns: + the flow graph object + """ + Element.__init__(self, parent) + self.options_block: Block = self.parent_platform.make_block(self, 'options') + + self.blocks = [self.options_block] + self.connections = set() + + self._eval_cache = {} + self.namespace = {} + self.imported_names = [] + + self.grc_file_path = '' + + def __str__(self) -> str: + return f"FlowGraph - {self.get_option('title')}({self.get_option('id')})" + + def imports(self) -> List[str]: + """ + Get a set of all import statements (Python) in this flow graph namespace. + + Returns: + a list of import statements + """ + return [block.templates.render('imports') for block in self.iter_enabled_blocks()] + + def get_variables(self) -> List[str]: + """ + Get a list of all variables (Python) in this flow graph namespace. + Exclude parameterized variables. + + Returns: + a sorted list of variable blocks in order of dependency (indep -> dep) + """ + variables = [block for block in self.iter_enabled_blocks() + if block.is_variable] + return expr_utils.sort_objects(variables, attrgetter('name'), methodcaller('get_var_make')) + + def get_parameters(self) -> List[Element]: + """ + Get a list of all parameterized variables in this flow graph namespace. + + Returns: + a list of parameterized variables + """ + parameters = [b for b in self.iter_enabled_blocks() + if b.key == 'parameter'] + return parameters + + def _get_snippets(self) -> List[Element]: + """ + Get a set of all code snippets (Python) in this flow graph namespace. + + Returns: + a list of code snippets + """ + return [b for b in self.iter_enabled_blocks() if b.key == 'snippet'] + + def get_snippets_dict(self, section=None) -> List[dict]: + """ + Get a dictionary of code snippet information for a particular section. + + Args: + section: string specifier of section of snippets to return, section=None returns all + + Returns: + a list of code snippets dicts + """ + snippets = self._get_snippets() + if not snippets: + return [] + + output = [] + for snip in snippets: + d = {} + sect = snip.params['section'].value + d['section'] = sect + d['priority'] = snip.params['priority'].value + d['lines'] = snip.params['code'].value.splitlines() + d['def'] = 'def snipfcn_{}(self):'.format(snip.name) + d['call'] = 'snipfcn_{}(tb)'.format(snip.name) + if not len(d['lines']): + Messages.send_warning("Ignoring empty snippet from canvas") + else: + if not section or sect == section: + output.append(d) + + # Sort by descending priority + if section: + output = sorted(output, key=lambda x: x['priority'], reverse=True) + + return output + + def get_monitors(self) -> List[Element]: + """ + Get a list of all ControlPort monitors + """ + monitors = [b for b in self.iter_enabled_blocks() + if 'ctrlport_monitor' in b.key] + return monitors + + def get_python_modules(self) -> Iterator[Tuple[str, str]]: + """Iterate over custom code block ID and Source""" + for block in self.iter_enabled_blocks(): + if block.key == 'epy_module': + yield block.name, block.params['source_code'].get_value() + + def iter_enabled_blocks(self) -> Iterator[Element]: + """ + Get an iterator of all blocks that are enabled and not bypassed. + """ + return (block for block in self.blocks if block.enabled) + + def get_enabled_blocks(self) -> List[Element]: + """ + Get a list of all blocks that are enabled and not bypassed. + + Returns: + a list of blocks + """ + return list(self.iter_enabled_blocks()) + + def get_bypassed_blocks(self) -> List[Element]: + """ + Get a list of all blocks that are bypassed. + + Returns: + a list of blocks + """ + return [block for block in self.blocks if block.get_bypassed()] + + def get_enabled_connections(self) -> List[Element]: + """ + Get a list of all connections that are enabled. + + Returns: + a list of connections + """ + return [connection for connection in self.connections if connection.enabled] + + def get_option(self, key) -> Param.EvaluationType: + """ + Get the option for a given key. + The option comes from the special options block. + + Args: + key: the param key for the options block + + Returns: + the value held by that param + """ + return self.options_block.params[key].get_evaluated() + + def get_run_command(self, file_path, split=False) -> Union[str, List[str]]: + run_command = self.get_option('run_command') + try: + run_command = run_command.format( + python=shlex.quote(sys.executable), + filename=shlex.quote(file_path)) + return shlex.split(run_command) if split else run_command + except Exception as e: + raise ValueError(f"Can't parse run command {repr(run_command)}: {e}") + + def get_imported_names(self) -> Set[str]: + """ + Get a list of imported names. + These names may not be used as id's + + Returns: + a list of imported names + """ + return self.imported_names + + ############################################## + # Access Elements + ############################################## + def get_block(self, name) -> Block: + for block in self.blocks: + if block.name == name: + return block + raise KeyError(f'No block with name {repr(name)}') + + def get_elements(self) -> List[Element]: + elements = list(self.blocks) + elements.extend(self.connections) + return elements + + def children(self) -> Iterable[Element]: + return itertools.chain(self.blocks, self.connections) + + def rewrite(self): + """ + Flag the namespace to be renewed. + """ + self._renew_namespace() + Element.rewrite(self) + + def _reload_imports(self, namespace: dict) -> dict: + """ + Load imports; be tolerant about import errors + """ + for expr in self.imports(): + try: + exec(expr, namespace) + except ImportError: + # We do not have a good way right now to determine if an import is for a + # hier block, these imports will fail as they are not in the search path + # this is ok behavior, unfortunately we could be hiding other import bugs + pass + except Exception: + log.exception(f"Failed to evaluate import expression \"{expr}\"", exc_info=True) + pass + return namespace + + def _reload_modules(self, namespace: dict) -> dict: + for id, expr in self.get_python_modules(): + try: + module = types.ModuleType(id) + exec(expr, module.__dict__) + namespace[id] = module + except Exception: + log.exception(f'Failed to evaluate expression in module {id}', exc_info=True) + pass + return namespace + + def _reload_parameters(self, namespace: dict) -> dict: + """ + Load parameters. Be tolerant of evaluation failures. + """ + np = {} # params don't know each other + for parameter_block in self.get_parameters(): + try: + value = eval( + parameter_block.params['value'].to_code(), namespace) + np[parameter_block.name] = value + except Exception: + log.exception(f'Failed to evaluate parameter block {parameter_block.name}', exc_info=True) + pass + namespace.update(np) # Merge param namespace + return namespace + + def _reload_variables(self, namespace: dict) -> dict: + """ + Load variables. Be tolerant of evaluation failures. + """ + for variable_block in self.get_variables(): + try: + variable_block.rewrite() + value = eval(variable_block.value, namespace, + variable_block.namespace) + namespace[variable_block.name] = value + # rewrite on subsequent blocks depends on an updated self.namespace + self.namespace.update(namespace) + # The following Errors may happen, but that doesn't matter as they are displayed in the gui + except (TypeError, FileNotFoundError, AttributeError, yaml.YAMLError): + pass + except Exception: + log.exception(f'Failed to evaluate variable block {variable_block.name}', exc_info=True) + return namespace + + def _renew_namespace(self) -> None: + # Before renewing the namespace, clear it + # to get rid of entries of blocks that + # are no longer valid ( deleted, disabled, ...) + self.namespace.clear() + + namespace = self._reload_imports({}) + self.imported_names = set(namespace.keys()) + namespace = self._reload_modules(namespace) + namespace = self._reload_parameters(namespace) + + # We need the updated namespace to evaluate the variable blocks + # otherwise sometimes variable_block rewrite / eval fails + self.namespace.update(namespace) + namespace = self._reload_variables(namespace) + self._eval_cache.clear() + + def evaluate(self, expr: str, namespace: Optional[dict] = None, local_namespace: Optional[dict] = None): + """ + Evaluate the expression within the specified global and local namespaces + """ + # Evaluate + if not expr: + raise Exception('Cannot evaluate empty statement.') + if namespace is not None: + return eval(expr, namespace, local_namespace) + else: + return self._eval_cache.setdefault(expr, eval(expr, self.namespace, local_namespace)) + + ############################################## + # Add/remove stuff + ############################################## + + def new_block(self, block_id, **kwargs) -> Block: + """ + Get a new block of the specified key. + Add the block to the list of elements. + + Args: + block_id: the block key + + Returns: + the new block or None if not found + """ + if block_id == 'options': + return self.options_block + try: + block = self.parent_platform.make_block(self, block_id, **kwargs) + self.blocks.append(block) + except KeyError: + block = None + return block + + def connect(self, porta, portb, params=None): + """ + Create a connection between porta and portb. + + Args: + porta: a port + portb: another port + @throw Exception bad connection + + Returns: + the new connection + """ + connection = self.parent_platform.Connection( + parent=self, source=porta, sink=portb) + if params: + connection.import_data(params) + self.connections.add(connection) + + return connection + + def disconnect(self, *ports) -> None: + to_be_removed = [con for con in self.connections + if any(port in con for port in ports)] + for con in to_be_removed: + self.remove_element(con) + + def remove_element(self, element) -> None: + """ + Remove the element from the list of elements. + If the element is a port, remove the whole block. + If the element is a block, remove its connections. + If the element is a connection, just remove the connection. + """ + if element is self.options_block: + return + + if element.is_port: + element = element.parent_block # remove parent block + + if element in self.blocks: + # Remove block, remove all involved connections + self.disconnect(*element.ports()) + self.blocks.remove(element) + + elif element in self.connections: + self.connections.remove(element) + + ############################################## + # Import/Export Methods + ############################################## + def export_data(self) -> OrderedDict[str, str]: + """ + Export this flow graph to nested data. + Export all block and connection data. + + Returns: + a nested data odict + """ + def block_order(b): + return not b.is_variable, b.name # todo: vars still first ?!? + + def get_file_format_version(data) -> int: + """Determine file format version based on available data""" + if any(isinstance(c, dict) for c in data['connections']): + return 2 + return 1 + + def sort_connection_key(connection_info) -> List[str]: + if isinstance(connection_info, dict): + return [connection_info.get(key) for key in ('src_blk_id', 'src_port_id', 'snk_blk_id', 'snk_port_id')] + return connection_info + data = collections.OrderedDict() + data['options'] = self.options_block.export_data() + data['blocks'] = [b.export_data() for b in sorted(self.blocks, key=block_order) + if b is not self.options_block] + data['connections'] = sorted( + (c.export_data() for c in self.connections), + key=sort_connection_key) + + data['metadata'] = { + 'file_format': get_file_format_version(data), + 'grc_version': self.parent_platform.config.version + } + return data + + def _build_depending_hier_block(self, block_id) -> Optional[Block]: + # we're before the initial fg update(), so no evaluated values! + # --> use raw value instead + path_param = self.options_block.params['hier_block_src_path'] + file_path = self.parent_platform.find_file_in_paths( + filename=block_id + '.grc', + paths=path_param.get_value(), + cwd=self.grc_file_path + ) + if file_path: # grc file found. load and get block + self.parent_platform.load_and_generate_flow_graph( + file_path, hier_only=True) + return self.new_block(block_id) # can be None + + def import_data(self, data) -> bool: + """ + Import blocks and connections into this flow graph. + Clear this flow graph of all previous blocks and connections. + Any blocks or connections in error will be ignored. + + Args: + data: the nested data odict + + Returns: + connection_error bool signifying whether a connection error happened. + """ + # Remove previous elements + del self.blocks[:] + self.connections.clear() + + file_format = data['metadata']['file_format'] + + # build the blocks + self.options_block.import_data(name='', **data.get('options', {})) + self.blocks.append(self.options_block) + + for block_data in data.get('blocks', []): + block_id = block_data['id'] + block = ( + self.new_block(block_id) or + self._build_depending_hier_block(block_id) or + self.new_block(block_id='_dummy', + missing_block_id=block_id, **block_data) + ) + + block.import_data(**block_data) + + self.rewrite() + + # build the connections + def verify_and_get_port(key, block, dir): + ports = block.sinks if dir == 'sink' else block.sources + for port in ports: + if key == port.key or key + '0' == port.key: + break + if not key.isdigit() and port.dtype == '' and key == port.name: + break + else: + if block.is_dummy_block: + port = block.add_missing_port(key, dir) + else: + raise LookupError(f"{dir} key {key} not in {dir} block keys") + return port + + had_connect_errors = False + _blocks = {block.name: block for block in self.blocks} + + # TODO: Add better error handling if no connections exist in the flowgraph file. + for connection_info in data.get('connections', []): + # First unpack the connection info, which can be in different formats. + # FLOW_GRAPH_FILE_FORMAT_VERSION 1: Connection info is a 4-tuple + if isinstance(connection_info, (list, tuple)) and len(connection_info) == 4: + src_blk_id, src_port_id, snk_blk_id, snk_port_id = connection_info + conn_params = {} + # FLOW_GRAPH_FILE_FORMAT_VERSION 2: Connection info is a dictionary + elif isinstance(connection_info, dict): + src_blk_id = connection_info.get('src_blk_id') + src_port_id = connection_info.get('src_port_id') + snk_blk_id = connection_info.get('snk_blk_id') + snk_port_id = connection_info.get('snk_port_id') + conn_params = connection_info.get('params', {}) + else: + Messages.send_error_load('Invalid connection format detected!') + had_connect_errors = True + continue + try: + source_block = _blocks[src_blk_id] + sink_block = _blocks[snk_blk_id] + + # fix old, numeric message ports keys + if file_format < 1: + src_port_id, snk_port_id = _update_old_message_port_keys( + src_port_id, snk_port_id, source_block, sink_block) + + # build the connection + source_port = verify_and_get_port( + src_port_id, source_block, 'source') + sink_port = verify_and_get_port( + snk_port_id, sink_block, 'sink') + + self.connect(source_port, sink_port, conn_params) + + except (KeyError, LookupError) as e: + Messages.send_error_load( + f"""Connection between {src_blk_id}({src_port_id}) and {snk_blk_id}({snk_port_id}) could not be made + \t{e}""") + had_connect_errors = True + + for block in self.blocks: + if block.is_dummy_block: + block.rewrite() # Make ports visible + # Flowgraph errors depending on disabled blocks are not displayed + # in the error dialog box + # So put a message into the Property window of the dummy block + block.add_error_message(f'Block id "{block.key}" not found.') + + self.rewrite() # global rewrite + return had_connect_errors + + +def _update_old_message_port_keys(source_key, sink_key, source_block, sink_block) -> Tuple[str, str]: + """ + Backward compatibility for message port keys + + Message ports use their names as key (like in the 'connect' method). + Flowgraph files from former versions still have numeric keys stored for + message connections. These have to be replaced by the name of the + respective port. The correct message port is deduced from the integer + value of the key (assuming the order has not changed). + + The connection ends are updated only if both ends translate into a + message port. + """ + try: + # get ports using the "old way" (assuming linear indexed keys) + source_port = source_block.sources[int(source_key)] + sink_port = sink_block.sinks[int(sink_key)] + if source_port.dtype == "message" and sink_port.dtype == "message": + source_key, sink_key = source_port.key, sink_port.key + except (ValueError, IndexError): + pass + return source_key, sink_key # do nothing diff --git a/grc/core/Messages.py b/grc/core/Messages.py new file mode 100644 index 0000000..c9a4fef --- /dev/null +++ b/grc/core/Messages.py @@ -0,0 +1,147 @@ +# Copyright 2007, 2015 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import traceback +import sys + +# A list of functions that can receive a message. +MESSENGERS_LIST = list() +_indent = '' + +# Global FlowGraph Error and the file that caused it +flowgraph_error = None +flowgraph_error_file = None + + +def register_messenger(messenger): + """ + Append the given messenger to the list of messengers. + + Args: + messenger: a method that takes a string + """ + MESSENGERS_LIST.append(messenger) + + +def set_indent(level=0): + global _indent + _indent = ' ' * level + + +def send(message): + """ + Give the message to each of the messengers. + + Args: + message: a message string + """ + for messenger in MESSENGERS_LIST: + messenger(_indent + message) + + +# register stdout by default +register_messenger(sys.stdout.write) + + +########################################################################### +# Special functions for specific program functionalities +########################################################################### +def send_init(platform): + msg = "<<< Welcome to {config.name} {config.version} >>>\n\n" \ + "Block paths:\n\t{paths}\n" + send(msg.format( + config=platform.config, + paths="\n\t".join(platform.config.block_paths))) + + +def send_xml_errors_if_any(xml_failures): + if xml_failures: + send('\nXML parser: Found {0} erroneous XML file{1} while loading the ' + 'block tree (see "Help/Parser errors" for details)\n'.format( + len(xml_failures), 's' if len(xml_failures) > 1 else '')) + + +def send_start_load(file_path): + send('\nLoading: "%s"\n' % file_path) + + +def send_error_msg_load(error): + send('>>> Error: %s\n' % error) + + +def send_error_load(error): + send_error_msg_load(error) + traceback.print_exc() + + +def send_end_load(): + send('>>> Done\n') + + +def send_fail_load(error): + send('Error: %s\n>>> Failure\n' % error) + traceback.print_exc() + + +def send_start_gen(file_path): + send('\nGenerating: "%s"\n' % file_path) + + +def send_auto_gen(file_path): + send('>>> Generating: "%s"\n' % file_path) + + +def send_fail_gen(error): + send('Generate Error: %s\n>>> Failure\n' % error) + traceback.print_exc() + + +def send_start_exec(file_path): + send('\nExecuting: %s\n' % file_path) + + +def send_verbose_exec(verbose): + send(verbose) + + +def send_end_exec(code=0): + send('\n>>> Done%s\n' % (" (return code %s)" % code if code else "")) + + +def send_fail_save(file_path): + send('>>> Error: Cannot save: %s\n' % file_path) + + +def send_fail_connection(msg=''): + send('>>> Error: Cannot create connection.\n' + + ('\t{}\n'.format(msg) if msg else '')) + + +def send_fail_load_preferences(prefs_file_path): + send('>>> Error: Cannot load preferences file: "%s"\n' % prefs_file_path) + + +def send_fail_save_preferences(prefs_file_path): + send('>>> Error: Cannot save preferences file: "%s"\n' % prefs_file_path) + + +def send_warning(warning): + send('>>> Warning: %s\n' % warning) + + +def send_flowgraph_error_report(flowgraph): + """ verbose error report for flowgraphs """ + error_list = flowgraph.get_error_messages() + if not error_list: + return + + send('*' * 50 + '\n') + summary_msg = '{} errors from flowgraph:\n'.format(len(error_list)) + send(summary_msg) + for err in error_list: + send(err) + send('\n' + '*' * 50 + '\n') diff --git a/grc/core/__init__.py b/grc/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grc/core/base.py b/grc/core/base.py new file mode 100644 index 0000000..8d9a20a --- /dev/null +++ b/grc/core/base.py @@ -0,0 +1,153 @@ +# Copyright 2008, 2009, 2015, 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +import weakref + +from .utils.descriptors import lazy_property + + +class Element(object): + + def __init__(self, parent=None): + self._parent = weakref.ref(parent) if parent else lambda: None + self._error_messages = [] + + ################################################## + # Element Validation API + ################################################## + def validate(self): + """ + Validate this element and call validate on all children. + Call this base method before adding error messages in the subclass. + """ + for child in self.children(): + child.validate() + + def is_valid(self): + """ + Is this element valid? + + Returns: + true when the element is enabled and has no error messages or is bypassed + """ + if not self.enabled or self.get_bypassed(): + return True + return not next(self.iter_error_messages(), False) + + def add_error_message(self, msg): + """ + Add an error message to the list of errors. + + Args: + msg: the error message string + """ + self._error_messages.append(msg) + + def get_error_messages(self): + """ + Get the list of error messages from this element and all of its children. + Do not include the error messages from disabled or bypassed children. + Cleverly indent the children error messages for printing purposes. + + Returns: + a list of error message strings + """ + return [msg if elem is self else "{}:\n\t{}".format(elem, msg.replace("\n", "\n\t")) + for elem, msg in self.iter_error_messages()] + + def iter_error_messages(self): + """ + Iterate over error messages. Yields tuples of (element, message) + """ + for msg in self._error_messages: + yield self, msg + for child in self.children(): + if not child.enabled or child.get_bypassed(): + continue + for element_msg in child.iter_error_messages(): + yield element_msg + + def rewrite(self): + """ + Rewrite this element and call rewrite on all children. + Call this base method before rewriting the element. + """ + del self._error_messages[:] + for child in self.children(): + child.rewrite() + + @property + def enabled(self): + return True + + def get_bypassed(self): + return False + + ############################################## + # Tree-like API + ############################################## + @property + def parent(self): + return self._parent() + + def get_parent_by_type(self, cls): + parent = self.parent + if parent is None: + return None + elif isinstance(parent, cls): + return parent + else: + return parent.get_parent_by_type(cls) + + @lazy_property + def parent_platform(self): + from .platform import Platform + return self.get_parent_by_type(Platform) + + @lazy_property + def parent_flowgraph(self): + from .FlowGraph import FlowGraph + return self.get_parent_by_type(FlowGraph) + + @lazy_property + def parent_block(self): + from .blocks import Block + return self.get_parent_by_type(Block) + + def reset_parents_by_type(self): + """Reset all lazy properties""" + for name, obj in vars(Element): # explicitly only in Element, not subclasses + if isinstance(obj, lazy_property): + delattr(self, name) + + def children(self): + return + yield # empty generator + + ############################################## + # Type testing + ############################################## + is_flow_graph = False + is_block = False + is_dummy_block = False + is_connection = False + is_port = False + is_param = False + is_variable = False + is_import = False + is_snippet = False + + def get_raw(self, name): + descriptor = getattr(self.__class__, name, None) + if not descriptor: + raise ValueError("No evaluated property '{}' found".format(name)) + return getattr(self, descriptor.name_raw, None) or getattr(self, descriptor.name, None) + + def set_evaluated(self, name, value): + descriptor = getattr(self.__class__, name, None) + if not descriptor: + raise ValueError("No evaluated property '{}' found".format(name)) + self.__dict__[descriptor.name] = value diff --git a/grc/core/blocks/__init__.py b/grc/core/blocks/__init__.py new file mode 100644 index 0000000..cc5297e --- /dev/null +++ b/grc/core/blocks/__init__.py @@ -0,0 +1,27 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from ._flags import Flags +from ._templates import MakoTemplates + +from .block import Block + +from ._build import build + + +build_ins = {} + + +def register_build_in(cls): + cls.loaded_from = '(build-in)' + build_ins[cls.key] = cls + return cls + + +from .dummy import DummyBlock +from .embedded_python import EPyBlock, EPyModule +from .virtual import VirtualSink, VirtualSource diff --git a/grc/core/blocks/_build.py b/grc/core/blocks/_build.py new file mode 100644 index 0000000..310d55a --- /dev/null +++ b/grc/core/blocks/_build.py @@ -0,0 +1,170 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import itertools +import re +from typing import Type + +from ..Constants import ADVANCED_PARAM_TAB +from ..utils import to_list +from ..Messages import send_warning + +from .block import Block +from ._flags import Flags +from ._templates import MakoTemplates + + +def build(id, + label='', + category='', + flags='', + documentation='', + value=None, + asserts=None, + parameters=None, + inputs=None, + outputs=None, + templates=None, + cpp_templates=None, + doc_url=None, + **kwargs) -> Type[Block]: + block_id = id + + cls = type(str(block_id), (Block,), {}) + cls.key = block_id + + cls.label = label or block_id.title() + cls.category = [cat.strip() for cat in category.split('/') if cat.strip()] + + cls.flags = Flags(flags) + if re.match(r'options$|variable|virtual', block_id): + cls.flags.set(Flags.NOT_DSP, Flags.DISABLE_BYPASS) + + cls.documentation = {'': documentation.strip('\n\t ').replace('\\\n', '')} + + cls.asserts = [_single_mako_expr(a, block_id) for a in to_list(asserts)] + + cls.inputs_data = build_ports(inputs, 'sink') if inputs else [] + cls.outputs_data = build_ports(outputs, 'source') if outputs else [] + cls.parameters_data = build_params(parameters or [], + bool(cls.inputs_data), bool(cls.outputs_data), cls.flags, block_id) + cls.extra_data = kwargs + + templates = templates or {} + cls.templates = MakoTemplates( + imports=templates.get('imports', ''), + make=templates.get('make', ''), + callbacks=templates.get('callbacks', []), + var_make=templates.get('var_make', ''), + ) + + cpp_templates = cpp_templates or {} + cls.cpp_templates = MakoTemplates( + includes=cpp_templates.get('includes', []), + make=cpp_templates.get('make', ''), + callbacks=cpp_templates.get('callbacks', []), + var_make=cpp_templates.get('var_make', ''), + link=cpp_templates.get('link', []), + packages=cpp_templates.get('packages', []), + translations=cpp_templates.get('translations', []), + declarations=cpp_templates.get('declarations', ''), + ) + + cls.doc_url = doc_url if doc_url else "" + # todo: MakoTemplates.compile() to check for errors + + cls.value = _single_mako_expr(value, block_id) + + return cls + + +def build_ports(ports_raw, direction): + ports = [] + port_ids = set() + stream_port_ids = itertools.count() + + for i, port_params in enumerate(ports_raw): + port = port_params.copy() + port['direction'] = direction + + port_id = port.setdefault('id', str(next(stream_port_ids))) + if port_id in port_ids: + raise Exception( + 'Port id "{}" already exists in {}s'.format(port_id, direction)) + port_ids.add(port_id) + + ports.append(port) + return ports + + +def build_params(params_raw, have_inputs, have_outputs, flags, block_id): + params = [] + + def add_param(**data): + params.append(data) + + if flags.SHOW_ID in flags: + add_param(id='id', name='ID', dtype='id', hide='none') + else: + add_param(id='id', name='ID', dtype='id', hide='all') + + if not flags.not_dsp: + add_param(id='alias', name='Block Alias', dtype='string', + hide='part', category=ADVANCED_PARAM_TAB) + + if have_outputs or have_inputs: + add_param(id='affinity', name='Core Affinity', dtype='int_vector', + hide='part', category=ADVANCED_PARAM_TAB) + + if have_outputs: + add_param(id='minoutbuf', name='Min Output Buffer', dtype='int', + hide='part', default='0', category=ADVANCED_PARAM_TAB) + add_param(id='maxoutbuf', name='Max Output Buffer', dtype='int', + hide='part', default='0', category=ADVANCED_PARAM_TAB) + + base_params_n = {} + for param_data in params_raw: + param_id = param_data['id'] + if param_id in params: + raise Exception('Param id "{}" is not unique'.format(param_id)) + + base_key = param_data.get('base_key', None) + param_data_ext = base_params_n.get(base_key, {}).copy() + param_data_ext.update(param_data) + + if 'option_attributes' in param_data: + _validate_option_attributes(param_data_ext, block_id) + + add_param(**param_data_ext) + base_params_n[param_id] = param_data_ext + + add_param(id='comment', name='Comment', dtype='_multiline', hide='part', + default='', category=ADVANCED_PARAM_TAB) + return params + + +def _single_mako_expr(value, block_id): + if not value: + return None + value = value.strip() + if not (value.startswith('${') and value.endswith('}')): + raise ValueError( + '{} is not a mako substitution in {}'.format(value, block_id)) + return value[2:-1].strip() + + +def _validate_option_attributes(param_data, block_id): + if param_data['dtype'] != 'enum': + send_warning( + '{} - option_attributes are for enums only, ignoring'.format(block_id)) + del param_data['option_attributes'] + else: + for key in list(param_data['option_attributes'].keys()): + if key in dir(str): + del param_data['option_attributes'][key] + send_warning( + '{} - option_attribute "{}" overrides str, ignoring'.format(block_id, key)) diff --git a/grc/core/blocks/_flags.py b/grc/core/blocks/_flags.py new file mode 100644 index 0000000..335b47a --- /dev/null +++ b/grc/core/blocks/_flags.py @@ -0,0 +1,36 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +class Flags(object): + + THROTTLE = 'throttle' + DISABLE_BYPASS = 'disable_bypass' + NEED_QT_GUI = 'need_qt_gui' + DEPRECATED = 'deprecated' + NOT_DSP = 'not_dsp' + SHOW_ID = 'show_id' + HAS_PYTHON = 'python' + HAS_CPP = 'cpp' + + def __init__(self, flags=None): + if flags is None: + flags = set() + if isinstance(flags, str): + flags = (f.strip() for f in flags.replace(',', '').split()) + self.data = set(flags) + + def __getattr__(self, item): + return item in self + + def __contains__(self, item): + return item in self.data + + def __str__(self): + return ', '.join(self.data) + + def set(self, *flags): + self.data.update(flags) diff --git a/grc/core/blocks/_templates.py b/grc/core/blocks/_templates.py new file mode 100644 index 0000000..8ed69c6 --- /dev/null +++ b/grc/core/blocks/_templates.py @@ -0,0 +1,79 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +""" +This dict class holds a (shared) cache of compiled mako templates. +These + +""" + +from mako.template import Template +from mako.exceptions import SyntaxException + +from ..errors import TemplateError + +# The utils dict contains convenience functions +# that can be called from any template + + +def no_quotes(string, fallback=None): + if len(string) > 2: + if str(string)[0] + str(string)[-1] in ("''", '""'): + return str(string)[1:-1] + return str(fallback if fallback else string) + + +utils = {'no_quotes': no_quotes} + + +class MakoTemplates(dict): + + _template_cache = {} + + def __init__(self, _bind_to=None, *args, **kwargs): + self.instance = _bind_to + dict.__init__(self, *args, **kwargs) + + def __get__(self, instance, owner): + if instance is None or self.instance is not None: + return self + copy = self.__class__(_bind_to=instance, **self) + if getattr(instance.__class__, 'templates', None) is self: + setattr(instance, 'templates', copy) + return copy + + @classmethod + def compile(cls, text): + text = str(text) + try: + template = Template(text, strict_undefined=True) + except SyntaxException as error: + raise TemplateError(text, *error.args) + + cls._template_cache[text] = template + return template + + def _get_template(self, text): + try: + return self._template_cache[str(text)] + except KeyError: + return self.compile(text) + + def render(self, item): + text = self.get(item) + if not text: + return '' + namespace = self.instance.namespace_templates + namespace = {**namespace, **utils} + + try: + if isinstance(text, list): + templates = (self._get_template(t) for t in text) + return [template.render(**namespace) for template in templates] + else: + template = self._get_template(text) + return template.render(**namespace) + except Exception as error: + raise TemplateError(error, text) diff --git a/grc/core/blocks/block.py b/grc/core/blocks/block.py new file mode 100644 index 0000000..6c57229 --- /dev/null +++ b/grc/core/blocks/block.py @@ -0,0 +1,789 @@ +""" +Copyright 2008-2020 Free Software Foundation, Inc. +Copyright 2021 GNU Radio contributors +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import collections +import itertools +import copy +import re +import ast +import typing + +from ._templates import MakoTemplates, no_quotes +from ._flags import Flags + +from ..base import Element +from ..params import Param +from ..utils.descriptors import lazy_property + + +def _get_elem(iterable, key): + items = list(iterable) + for item in items: + if item.key == key: + return item + return ValueError('Key "{}" not found in {}.'.format(key, items)) + + +class Block(Element): + + is_block = True + + STATE_LABELS = ['disabled', 'enabled', 'bypassed'] + + key = '' + label = '' + category = [] + vtype = '' # This is only used for variables when we want C++ output + flags = Flags('') + documentation = {'': ''} + doc_url = '' + + value = None + asserts = [] + + templates = MakoTemplates() + parameters_data = [] + inputs_data = [] + outputs_data = [] + + extra_data = {} + loaded_from = '(unknown)' + + def __init__(self, parent): + """Make a new block from nested data.""" + super(Block, self).__init__(parent) + param_factory = self.parent_platform.make_param + port_factory = self.parent_platform.make_port + + self.params: typing.OrderedDict[str, Param] = collections.OrderedDict( + (data['id'], param_factory(parent=self, **data)) for data in self.parameters_data) + if self.key == 'options': + self.params['id'].hide = 'part' + + self.sinks = [port_factory(parent=self, **params) + for params in self.inputs_data] + self.sources = [port_factory(parent=self, **params) + for params in self.outputs_data] + + self.active_sources = [] # on rewrite + self.active_sinks = [] # on rewrite + + self.states = {'state': 'enabled', 'bus_source': False, + 'bus_sink': False, 'bus_structure': None} + self.block_namespace = {} + self.deprecated = self.is_deprecated() + + if Flags.HAS_CPP in self.flags and self.enabled and not (self.is_virtual_source() or self.is_virtual_sink()): + # This is a workaround to allow embedded python blocks/modules to load as there is + # currently 'cpp' in the flags by default caused by the other built-in blocks + if hasattr(self, 'cpp_templates'): + # The original template, in case we have to edit it when transpiling to C++ + self.orig_cpp_templates = self.cpp_templates + + self.current_bus_structure = {'source': None, 'sink': None} + + def get_bus_structure(self, direction): + if direction == 'source': + bus_structure = self.bus_structure_source + else: + bus_structure = self.bus_structure_sink + + if not bus_structure: + return None + + try: + clean_bus_structure = self.evaluate(bus_structure) + return clean_bus_structure + except Exception: + return None + + # region Rewrite_and_Validation + + def rewrite(self): + """ + Add and remove ports to adjust for the nports. + """ + Element.rewrite(self) + + def rekey(ports): + """Renumber non-message/message ports""" + domain_specific_port_index = collections.defaultdict(int) + for port in ports: + if not port.key.isdigit(): + continue + domain = port.domain + port.key = str(domain_specific_port_index[domain]) + domain_specific_port_index[domain] += 1 + + # Adjust nports + for ports in (self.sources, self.sinks): + self._rewrite_nports(ports) + rekey(ports) + + self.update_bus_logic() + # disconnect hidden ports + self.parent_flowgraph.disconnect( + *[p for p in self.ports() if p.hidden]) + + self.active_sources = [p for p in self.sources if not p.hidden] + self.active_sinks = [p for p in self.sinks if not p.hidden] + + # namespaces may have changed, update them + self.block_namespace.clear() + imports = "" + try: + imports = self.templates.render('imports') + exec(imports, self.block_namespace) + except ImportError: + # We do not have a good way right now to determine if an import is for a + # hier block, these imports will fail as they are not in the search path + # this is ok behavior, unfortunately we could be hiding other import bugs + pass + except Exception: + self.add_error_message( + f'Failed to evaluate import expression {imports!r}') + + def update_bus_logic(self): + ############################### + # Bus Logic + ############################### + + for direc in {'source', 'sink'}: + if direc == 'source': + ports = self.sources + ports_gui = self.filter_bus_port(self.sources) + bus_state = self.bus_source + else: + ports = self.sinks + ports_gui = self.filter_bus_port(self.sinks) + bus_state = self.bus_sink + + # Remove the bus ports + removed_bus_ports = [] + removed_bus_connections = [] + if 'bus' in map(lambda a: a.dtype, ports): + for port in ports_gui: + for c in self.parent_flowgraph.connections: + if port is c.source_port or port is c.sink_port: + removed_bus_ports.append(port) + removed_bus_connections.append(c) + ports.remove(port) + + if (bus_state): + struct = self.form_bus_structure(direc) + self.current_bus_structure[direc] = struct + + # Hide ports that are not part of the bus structure + # TODO: Blocks where it is desired to only have a subset + # of ports included in the bus still has some issues + for idx, port in enumerate(ports): + if any([idx in bus for bus in self.current_bus_structure[direc]]): + if (port.stored_hidden_state is None): + port.stored_hidden_state = port.hidden + port.hidden = True + + # Add the Bus Ports to the list of ports + for i in range(len(struct)): + # self.sinks = [port_factory(parent=self, **params) for params in self.inputs_data] + port = self.parent.parent.make_port(self, direction=direc, id=str( + len(ports)), label='bus', dtype='bus', bus_struct=struct[i]) + ports.append(port) + + for (saved_port, connection) in zip(removed_bus_ports, removed_bus_connections): + if port.key == saved_port.key: + self.parent_flowgraph.connections.remove( + connection) + if saved_port.is_source: + connection.source_port = port + if saved_port.is_sink: + connection.sink_port = port + self.parent_flowgraph.connections.add(connection) + + else: + self.current_bus_structure[direc] = None + + # Re-enable the hidden property of the ports + for port in ports: + if (port.stored_hidden_state is not None): + port.hidden = port.stored_hidden_state + port.stored_hidden_state = None + + def _rewrite_nports(self, ports): + for port in ports: + if hasattr(port, 'master_port'): # Not a master port and no left-over clones + port.dtype = port.master_port.dtype + port.vlen = port.master_port.vlen + continue + nports = port.multiplicity + for clone in port.clones[nports - 1:]: + # Remove excess connections + self.parent_flowgraph.disconnect(clone) + port.remove_clone(clone) + ports.remove(clone) + # Add more cloned ports + for j in range(1 + len(port.clones), nports): + clone = port.add_clone() + ports.insert(ports.index(port) + j, clone) + + def validate(self): + """ + Validate this block. + Call the base class validate. + Evaluate the checks: each check must evaluate to True. + """ + Element.validate(self) + self._run_asserts() + self._validate_generate_mode_compat() + self._validate_output_language_compat() + self._validate_var_value() + + def _run_asserts(self): + """Evaluate the checks""" + for expr in self.asserts: + try: + if not self.evaluate(expr): + self.add_error_message( + 'Assertion "{}" failed.'.format(expr)) + except Exception: + self.add_error_message( + 'Assertion "{}" did not evaluate.'.format(expr)) + + def _validate_generate_mode_compat(self): + """check if this is a GUI block and matches the selected generate option""" + current_generate_option = self.parent.get_option('generate_options') + + def check_generate_mode(label, flag, valid_options): + block_requires_mode = ( + flag in self.flags or self.label.upper().startswith(label) + ) + if block_requires_mode and current_generate_option not in valid_options: + self.add_error_message("Can't generate this block in mode: {} ".format( + repr(current_generate_option))) + + check_generate_mode('QT GUI', Flags.NEED_QT_GUI, + ('qt_gui', 'hb_qt_gui')) + + def _validate_output_language_compat(self): + """check if this block supports the selected output language""" + current_output_language = self.parent.get_option('output_language') + + if current_output_language == 'cpp': + if 'cpp' not in self.flags: + self.add_error_message( + "This block does not support C++ output.") + + if self.key == 'parameter': + if not self.params['type'].value: + self.add_error_message( + "C++ output requires you to choose a parameter type.") + + def _validate_var_value(self): + """or variables check the value (only if var_value is used)""" + if self.is_variable and self.value != 'value': + try: + self.parent_flowgraph.evaluate( + self.value, local_namespace=self.namespace) + except Exception as err: + self.add_error_message( + 'Value "{}" cannot be evaluated:\n{}'.format(self.value, err)) + # endregion + + # region Properties + + def __str__(self): + return 'Block - {} - {}({})'.format(self.name, self.label, self.key) + + def __repr__(self): + try: + name = self.name + except Exception: + name = self.key + return 'block[' + name + ']' + + @property + def name(self): + return self.params['id'].value + + @lazy_property + def is_virtual_or_pad(self): + return self.key in ("virtual_source", "virtual_sink", "pad_source", "pad_sink") + + @lazy_property + def is_variable(self): + return bool(self.value) + + @lazy_property + def is_import(self): + return self.key == 'import' + + @lazy_property + def is_snippet(self): + return self.key == 'snippet' + + @property + def comment(self): + return self.params['comment'].value + + @property + def state(self): + """Gets the block's current state.""" + state = self.states['state'] + return state if state in self.STATE_LABELS else 'enabled' + + @state.setter + def state(self, value): + """Sets the state for the block.""" + self.states['state'] = value + + # Enable/Disable Aliases + @property + def enabled(self): + """Get the enabled state of the block""" + return self.state != 'disabled' + + @property + def bus_sink(self): + """Gets the block's current Toggle Bus Sink state.""" + return self.states['bus_sink'] + + @bus_sink.setter + def bus_sink(self, value): + """Sets the Toggle Bus Sink state for the block.""" + self.states['bus_sink'] = value + + @property + def bus_source(self): + """Gets the block's current Toggle Bus Sink state.""" + return self.states['bus_source'] + + @bus_source.setter + def bus_source(self, value): + """Sets the Toggle Bus Source state for the block.""" + self.states['bus_source'] = value + + @property + def bus_structure_source(self): + """Gets the block's current source bus structure.""" + try: + bus_structure = self.params['bus_structure_source'].value or None + except Exception: + bus_structure = None + return bus_structure + + @property + def bus_structure_sink(self): + """Gets the block's current source bus structure.""" + try: + bus_structure = self.params['bus_structure_sink'].value or None + except Exception: + bus_structure = None + return bus_structure + + # endregion + + ############################################## + # Getters (old) + ############################################## + def get_var_make(self): + return self.templates.render('var_make') + + def get_cpp_var_make(self): + return self.cpp_templates.render('var_make') + + def get_var_value(self): + return self.templates.render('var_value') + + def get_callbacks(self): + """ + Get a list of function callbacks for this block. + + Returns: + a list of strings + """ + def make_callback(callback): + if 'self.' in callback: + return callback + return 'self.{}.{}'.format(self.name, callback) + + return [make_callback(c) for c in self.templates.render('callbacks')] + + def get_cpp_callbacks(self): + """ + Get a list of C++ function callbacks for this block. + + Returns: + a list of strings + """ + def make_callback(callback): + if self.is_variable: + return callback + if 'this->' in callback: + return callback + return 'this->{}->{}'.format(self.name, callback) + + return [make_callback(c) for c in self.cpp_templates.render('callbacks')] + + def format_expr(self, py_type): + """ + Evaluate the value of the variable block and decide its type. + + Returns: + None + """ + value = self.params['value'].value + self.cpp_templates = copy.copy(self.orig_cpp_templates) + + # Determine the lvalue type + def get_type(element: str, vtype: typing.Optional[type] = None) -> str: + evaluated = None + try: + evaluated = ast.literal_eval(element) + if vtype is None: + vtype = type(evaluated) + except ValueError or SyntaxError as excp: + if vtype is None: + print(excp) + simple_types = {int: "long", float: "double", bool: "bool", complex: "gr_complex", str: "std::string"} + if vtype in simple_types: + return simple_types[vtype] + + elif vtype == list: + try: + # For container types we must also determine the type of the template parameter(s) + return f"std::vector<{get_type(str(evaluated[0]), type(evaluated[0]))}>" + + except IndexError: # empty list + return 'std::vector' + + elif vtype == dict: + try: + # For container types we must also determine the type of the template parameter(s) + key, val = next(iter(evaluated.entries())) + return f"std::map<{get_type(str(key), type(key))}, {get_type(str(val), type(val))}>" + + except IndexError: # empty dict + return 'std::map' + + # Get the lvalue type + self.vtype = get_type(value, py_type) + + # The r-value for these types must be transformed to create legal C++ syntax. + if self.vtype in ['bool', 'gr_complex'] or 'std::map' in self.vtype or 'std::vector' in self.vtype: + evaluated = ast.literal_eval(value) + self.cpp_templates['var_make'] = self.cpp_templates['var_make'].replace( + '${value}', self.get_cpp_value(evaluated)) + + if 'string' in self.vtype: + self.cpp_templates['includes'].append('#include ') + + def get_cpp_value(self, pyval, vtype: typing.Optional[type] = None) -> str: + """ + Convert an evaluated variable value from Python to C++ with a defined type. + + Returns: + string representation of the C++ value + """ + if vtype is None: + vtype = type(pyval) + else: + assert vtype == type(pyval) + + if vtype == int or vtype == float: + val_str = str(pyval) + + # Check for PI and replace with C++ constant + pi_re = r'^(math|numpy|np|scipy|sp)\.pi$' + if re.match(pi_re, str(pyval)): + val_str = re.sub( + pi_re, 'boost::math::constants::pi()', val_str) + self.cpp_templates['includes'].append( + '#include ') + + return str(pyval) + + elif vtype == bool: + return str(pyval).lower() + + elif vtype == complex: + self.cpp_templates['includes'].append( + '#include ') + evaluated = ast.literal_eval(str(pyval).strip()) + return '{' + str(evaluated.real) + ', ' + str(evaluated.imag) + '}' + + elif vtype == list: + self.cpp_templates['includes'].append('#include ') + if len(pyval) == 0: + return '{}' + + item_type = type(pyval[0]) + elements = [str(self.get_cpp_value(element, item_type)) for element in pyval] + return '{' + ', '.join(elements) + '}' + + elif vtype == dict: + self.cpp_templates['includes'].append('#include ') + key_type, val_type = next(iter(pyval.entries())) + key_type, val_type = type(key_type), type(val_type) + entries = ['{' + self.get_cpp_value(key, key_type) + ', ' + self.get_cpp_value(val, val_type) + '}' + for key, val in pyval.entries()] + return '{' + ', '.join(entries) + '}' + + elif vtype == str: + self.cpp_templates['includes'].append('#include ') + value = pyval.strip() + if value in ['""', "''"]: + return '""' + return f'"{no_quotes(value)}"' + + raise TypeError(f"Unsupported C++ vtype: {vtype}") + + def is_virtual_sink(self): + return self.key == 'virtual_sink' + + def is_virtual_source(self): + return self.key == 'virtual_source' + + def is_deprecated(self): + """ + Check whether the block is deprecated. + + For now, we just check the category name for presence of "deprecated". + + As it might be desirable in the future to have such "tags" be stored + explicitly, we're taking the detour of introducing a property. + """ + if not self.category: + return False + + try: + return (self.flags.deprecated or + any("deprecated".casefold() in cat.casefold() + for cat in self.category)) + except Exception as exception: + print(exception.message) + return False + + # Block bypassing + + def get_bypassed(self): + """ + Check if the block is bypassed + """ + return self.state == 'bypassed' + + def set_bypassed(self): + """ + Bypass the block + + Returns: + True if block changes state + """ + if self.state != 'bypassed' and self.can_bypass(): + self.state = 'bypassed' + return True + return False + + def can_bypass(self): + """ + Check the number of sinks and sources and see if this block can be bypassed + """ + # Check to make sure this is a single path block + # Could possibly support 1 to many blocks + if len(self.sources) != 1 or len(self.sinks) != 1: + return False + if not (self.sources[0].dtype == self.sinks[0].dtype): + return False + if self.flags.disable_bypass: + return False + return True + + def ports(self): + return itertools.chain(self.sources, self.sinks) + + def active_ports(self): + return itertools.chain(self.active_sources, self.active_sinks) + + def children(self): + return itertools.chain(self.params.values(), self.ports()) + + def connections(self): + block_connections = [] + for port in self.ports(): + block_connections = block_connections + list(port.connections()) + return block_connections + + ############################################## + # Access + ############################################## + + def get_sink(self, key): + return _get_elem(self.sinks, key) + + def get_source(self, key): + return _get_elem(self.sources, key) + + ############################################## + # Resolve + ############################################## + @property + def namespace(self): + # update block namespace + self.block_namespace.update({key: param.get_evaluated() for key, param in self.params.items()}) + return self.block_namespace + + @property + def namespace_templates(self): + return {key: param.template_arg for key, param in self.params.items()} + + def evaluate(self, expr): + return self.parent_flowgraph.evaluate(expr, self.namespace) + + ############################################## + # Import/Export Methods + ############################################## + def export_data(self): + """ + Export this block's params to nested data. + + Returns: + a nested data odict + """ + data = collections.OrderedDict() + if self.key != 'options': + data['name'] = self.name + data['id'] = self.key + data['parameters'] = collections.OrderedDict(sorted( + (param_id, param.value) + for param_id, param in self.params.items() + if (param_id != 'id' or self.key == 'options') + )) + data['states'] = collections.OrderedDict(sorted(self.states.items())) + return data + + def import_data(self, name, states, parameters, **_): + """ + Import this block's params from nested data. + Any param keys that do not exist will be ignored. + Since params can be dynamically created based another param, + call rewrite, and repeat the load until the params stick. + """ + self.params['id'].value = name + self.states.update(states) + + def get_hash(): + return hash(tuple(hash(v) for v in self.params.values())) + + pre_rewrite_hash = -1 + while pre_rewrite_hash != get_hash(): + for key, value in parameters.items(): + try: + self.params[key].set_value(value) + except KeyError: + continue + # Store hash and call rewrite + pre_rewrite_hash = get_hash() + self.rewrite() + + ############################################## + # Controller Modify + ############################################## + def filter_bus_port(self, ports): + buslist = [p for p in ports if p.dtype == 'bus'] + return buslist or ports + + def type_controller_modify(self, direction): + """ + Change the type controller. + + Args: + direction: +1 or -1 + + Returns: + true for change + """ + changed = False + type_param = None + for param in filter(lambda p: p.is_enum(), self.get_params()): + children = self.get_ports() + self.get_params() + # Priority to the type controller + if param.get_key() in ' '.join(map(lambda p: p._type, children)): + type_param = param + # Use param if type param is unset + if not type_param: + type_param = param + if type_param: + # Try to increment the enum by direction + try: + keys = type_param.get_option_keys() + old_index = keys.index(type_param.get_value()) + new_index = (old_index + direction + len(keys)) % len(keys) + type_param.set_value(keys[new_index]) + changed = True + except Exception: + pass + return changed + + def form_bus_structure(self, direc): + if direc == 'source': + ports = self.sources + bus_structure = self.get_bus_structure('source') + else: + ports = self.sinks + bus_structure = self.get_bus_structure('sink') + + struct = [range(len(ports))] + # struct = list(range(len(ports))) + # TODO for more complicated port structures, this code is needed but not working yet + if any([p.multiplicity for p in ports]): + structlet = [] + last = 0 + # group the ports with > n inputs together on the bus + cnt = 0 + idx = 0 + for p in ports: + if p.domain == 'message': + continue + if cnt > 0: + cnt -= 1 + continue + + if p.multiplicity > 1: + cnt = p.multiplicity - 1 + structlet.append([idx + j for j in range(p.multiplicity)]) + else: + structlet.append([idx]) + + struct = structlet + if bus_structure: + struct = bus_structure + + self.current_bus_structure[direc] = struct + return struct + + def bussify(self, direc): + if direc == 'source': + ports = self.sources + if ports: + ports_gui = self.filter_bus_port(self.sources) + self.bus_structure = self.get_bus_structure('source') + self.bus_source = not self.bus_source + else: + ports = self.sinks + if ports: + ports_gui = self.filter_bus_port(self.sinks) + self.bus_structure = self.get_bus_structure('sink') + self.bus_sink = not self.bus_sink + + # Disconnect all the connections when toggling the bus state + for port in ports: + l_connections = list(port.connections()) + for connect in l_connections: + self.parent.remove_element(connect) + + self.update_bus_logic() diff --git a/grc/core/blocks/dummy.py b/grc/core/blocks/dummy.py new file mode 100644 index 0000000..8147eed --- /dev/null +++ b/grc/core/blocks/dummy.py @@ -0,0 +1,47 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from . import Block, register_build_in + +from ._build import build_params + + +@register_build_in +class DummyBlock(Block): + + is_dummy_block = True + + label = 'Missing Block' + key = '_dummy' + + def __init__(self, parent, missing_block_id, parameters, **_): + self.key = missing_block_id + self.parameters_data = build_params( + [], False, False, self.flags, self.key) + super(DummyBlock, self).__init__(parent=parent) + + param_factory = self.parent_platform.make_param + for param_id in parameters: + self.params.setdefault(param_id, param_factory( + parent=self, id=param_id, dtype='string')) + + def is_valid(self): + return False + + @property + def enabled(self): + return False + + def add_missing_port(self, port_id, direction): + port = self.parent_platform.make_port( + parent=self, direction=direction, id=port_id, name='?', dtype='', + ) + if port.is_source: + self.sources.append(port) + else: + self.sinks.append(port) + return port diff --git a/grc/core/blocks/embedded_python.py b/grc/core/blocks/embedded_python.py new file mode 100644 index 0000000..893dcda --- /dev/null +++ b/grc/core/blocks/embedded_python.py @@ -0,0 +1,248 @@ +# Copyright 2015-16 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from ast import literal_eval +from textwrap import dedent + +from . import Block, register_build_in +from ._templates import MakoTemplates +from ._flags import Flags + +from .. import utils +from ..base import Element + +from ._build import build_params + + +DEFAULT_CODE = '''\ +""" +Embedded Python Blocks: + +Each time this file is saved, GRC will instantiate the first class it finds +to get ports and parameters of your block. The arguments to __init__ will +be the parameters. All of them are required to have default values! +""" + +import numpy as np +from gnuradio import gr + + +class blk(gr.sync_block): # other base classes are basic_block, decim_block, interp_block + """Embedded Python Block example - a simple multiply const""" + + def __init__(self, example_param=1.0): # only default arguments here + """arguments to this function show up as parameters in GRC""" + gr.sync_block.__init__( + self, + name='Embedded Python Block', # will show up in GRC + in_sig=[np.complex64], + out_sig=[np.complex64] + ) + # if an attribute with the same name as a parameter is found, + # a callback is registered (properties work, too). + self.example_param = example_param + + def work(self, input_items, output_items): + """example: multiply with constant""" + output_items[0][:] = input_items[0] * self.example_param + return len(output_items[0]) +''' + +DOC = """ +This block represents an arbitrary GNU Radio Python Block. + +Its source code can be accessed through the parameter 'Code' which opens your editor. \ +Each time you save changes in the editor, GRC will update the block. \ +This includes the number, names and defaults of the parameters, \ +the ports (stream and message) and the block name and documentation. + +Block Documentation: +(will be replaced the docstring of your block class) +""" + + +@register_build_in +class EPyBlock(Block): + + key = 'epy_block' + label = 'Python Block' + exempt_from_id_validation = True # Exempt epy block from blacklist id validation + documentation = {'': DOC} + + parameters_data = build_params( + params_raw=[ + dict(label='Code', id='_source_code', dtype='_multiline_python_external', + default=DEFAULT_CODE, hide='part') + ], have_inputs=True, have_outputs=True, flags=Block.flags, block_id=key + ) + inputs_data = [] + outputs_data = [] + + def __init__(self, flow_graph, **kwargs): + super(EPyBlock, self).__init__(flow_graph, **kwargs) + self.states['_io_cache'] = '' + + self.module_name = self.name + self._epy_source_hash = -1 + self._epy_reload_error = None + + def rewrite(self): + Element.rewrite(self) + + param_src = self.params['_source_code'] + + src = param_src.get_value() + src_hash = hash((self.name, src)) + if src_hash == self._epy_source_hash: + return + + try: + blk_io = utils.epy_block_io.extract(src) + + except Exception as e: + self._epy_reload_error = ValueError(str(e)) + try: # Load last working block io + blk_io_args = literal_eval(self.states['_io_cache']) + if len(blk_io_args) == 6: + blk_io_args += ([],) # add empty callbacks + blk_io = utils.epy_block_io.BlockIO(*blk_io_args) + except Exception: + return + else: + self._epy_reload_error = None # Clear previous errors + self.states['_io_cache'] = repr(tuple(blk_io)) + + # print "Rewriting embedded python block {!r}".format(self.name) + self._epy_source_hash = src_hash + + self.label = blk_io.name or blk_io.cls + self.documentation = {'': blk_io.doc} + + self.module_name = "{}_{}".format( + self.parent_flowgraph.get_option("id"), self.name) + self.templates['imports'] = 'import {} as {} # embedded python block'.format( + self.module_name, self.name) + self.templates['make'] = '{mod}.{cls}({args})'.format( + mod=self.name, + cls=blk_io.cls, + args=', '.join('{0}=${{ {0} }}'.format(key) for key, _ in blk_io.params)) + self.templates['callbacks'] = [ + '{0} = ${{ {0} }}'.format(attr) for attr in blk_io.callbacks + ] + + self._update_params(blk_io.params) + self._update_ports('in', self.sinks, blk_io.sinks, 'sink') + self._update_ports('out', self.sources, blk_io.sources, 'source') + + super(EPyBlock, self).rewrite() + + def _update_params(self, params_in_src): + param_factory = self.parent_platform.make_param + params = {} + for key, value in self.params.copy().items(): + if hasattr(value, '__epy_param__'): + params[key] = value + del self.params[key] + + for id_, value in params_in_src: + try: + param = params[id_] + if param.default == param.value: + param.set_value(value) + param.default = str(value) + except KeyError: # need to make a new param + param = param_factory( + parent=self, id=id_, dtype='raw', value=value, + name=id_.replace('_', ' ').title(), + ) + setattr(param, '__epy_param__', True) + self.params[id_] = param + + def _update_ports(self, label, ports, port_specs, direction): + port_factory = self.parent_platform.make_port + ports_to_remove = list(ports) + iter_ports = iter(ports) + ports_new = [] + port_current = next(iter_ports, None) + for key, port_type, vlen in port_specs: + reuse_port = ( + port_current is not None and + port_current.dtype == port_type and + port_current.vlen == vlen and + (key.isdigit() or port_current.key == key) + ) + if reuse_port: + ports_to_remove.remove(port_current) + port, port_current = port_current, next(iter_ports, None) + else: + n = dict(name=label + str(key), dtype=port_type, id=key) + if port_type == 'message': + n['name'] = key + n['optional'] = '1' + if vlen > 1: + n['vlen'] = str(vlen) + port = port_factory(self, direction=direction, **n) + ports_new.append(port) + # replace old port list with new one + del ports[:] + ports.extend(ports_new) + # remove excess port connections + self.parent_flowgraph.disconnect(*ports_to_remove) + + def validate(self): + super(EPyBlock, self).validate() + if self._epy_reload_error: + self.params['_source_code'].add_error_message( + str(self._epy_reload_error)) + + +@register_build_in +class EPyModule(Block): + key = 'epy_module' + label = 'Python Module' + exempt_from_id_validation = True # Exempt epy module from blacklist id validation + documentation = {'': dedent(""" + This block lets you embed a python module in your flowgraph. + + Code you put in this module is accessible in other blocks using the ID of this + block. Example: + + If you put + + a = 2 + + def double(arg): + return 2 * arg + + in a Python Module Block with the ID 'stuff' you can use code like + + stuff.a # evals to 2 + stuff.double(3) # evals to 6 + + to set parameters of other blocks in your flowgraph. + """)} + + flags = Flags(Flags.SHOW_ID) + + parameters_data = build_params( + params_raw=[ + dict(label='Code', id='source_code', dtype='_multiline_python_external', + default='# this module will be imported in the into your flowgraph', + hide='part') + ], have_inputs=False, have_outputs=False, flags=flags, block_id=key + ) + + def __init__(self, flow_graph, **kwargs): + super(EPyModule, self).__init__(flow_graph, **kwargs) + self.module_name = self.name + + def rewrite(self): + super(EPyModule, self).rewrite() + self.module_name = "{}_{}".format( + self.parent_flowgraph.get_option("id"), self.name) + self.templates['imports'] = 'import {} as {} # embedded python module'.format( + self.module_name, self.name) diff --git a/grc/core/blocks/virtual.py b/grc/core/blocks/virtual.py new file mode 100644 index 0000000..34e0feb --- /dev/null +++ b/grc/core/blocks/virtual.py @@ -0,0 +1,62 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import itertools + +from . import Block, register_build_in +from ._build import build_params + + +@register_build_in +class VirtualSink(Block): + count = itertools.count() + + key = 'virtual_sink' + label = 'Virtual Sink' + flags = Block.flags + flags.set('cpp') + + parameters_data = build_params( + params_raw=[ + dict(label='Stream ID', id='stream_id', dtype='stream_id')], + have_inputs=False, have_outputs=False, flags=flags, block_id=key + ) + inputs_data = [dict(domain='stream', dtype='', direction='sink', id="0")] + + def __init__(self, parent, **kwargs): + super(VirtualSink, self).__init__(parent, **kwargs) + self.params['id'].hide = 'all' + + @property + def stream_id(self): + return self.params['stream_id'].value + + +@register_build_in +class VirtualSource(Block): + count = itertools.count() + + key = 'virtual_source' + label = 'Virtual Source' + flags = Block.flags + flags.set('cpp') + + parameters_data = build_params( + params_raw=[ + dict(label='Stream ID', id='stream_id', dtype='stream_id')], + have_inputs=False, have_outputs=False, flags=flags, block_id=key + ) + outputs_data = [dict(domain='stream', dtype='', + direction='source', id="0")] + + def __init__(self, parent, **kwargs): + super(VirtualSource, self).__init__(parent, **kwargs) + self.params['id'].hide = 'all' + + @property + def stream_id(self): + return self.params['stream_id'].value diff --git a/grc/core/cache.py b/grc/core/cache.py new file mode 100644 index 0000000..e937e4a --- /dev/null +++ b/grc/core/cache.py @@ -0,0 +1,106 @@ +# Copyright 2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +import json +import logging +import os +import time + +from .io import yaml + +logger = logging.getLogger(__name__) + + +class Cache(object): + + def __init__(self, filename, version=None, log=True): + self.cache_file = filename + self.version = version + self.log = log + self.cache = {} + self._cachetime = None + self.need_cache_write = True + self._accessed_items = set() + try: + os.makedirs(os.path.dirname(filename)) + except OSError: + pass + try: + self._converter_mtime = os.path.getmtime(filename) + except OSError: + self._converter_mtime = -1 + + def load(self): + try: + self.need_cache_write = False + if self.log: + logger.debug(f"Loading cache from: {self.cache_file}") + with open(self.cache_file, encoding='utf-8') as cache_file: + cache = json.load(cache_file) + cacheversion = cache.get("version", None) + if self.log: + logger.debug(f"Cache version {cacheversion}") + self._cachetime = cache.get("cached-at", 0) + if cacheversion == self.version: + if self.log: + logger.debug("Loaded cache") + self.cache = cache["cache"] + else: + if self.log: + logger.info(f"Outdated cache {self.cache_file} found, " + "will be overwritten.") + raise ValueError() + except (IOError, ValueError): + self.need_cache_write = True + + def get_or_load(self, filename): + self._accessed_items.add(filename) + modtime = os.path.getmtime(filename) + if modtime <= self._converter_mtime: + try: + cached = self.cache[filename] + if int(cached["cached-at"] + 0.5) >= modtime: + return cached["data"] + if self.log: + logger.info(f"Cache for {filename} outdated, loading yaml") + except KeyError: + pass + + with open(filename, encoding='utf-8') as fp: + data = yaml.safe_load(fp) + self.cache[filename] = { + "cached-at": int(time.time()), + "data": data + } + self.need_cache_write = True + return data + + def save(self): + if not self.need_cache_write: + return + + if self.log: + logger.debug('Saving %d entries to json cache', len(self.cache)) + # Dumping to binary file is only supported for Python3 >= 3.6 + with open(self.cache_file, 'w', encoding='utf8') as cache_file: + cache_content = { + "version": self.version, + "cached-at": self._cachetime, + "cache": self.cache + } + cache_file.write( + json.dumps(cache_content, ensure_ascii=False)) + + def prune(self): + for filename in (set(self.cache) - self._accessed_items): + del self.cache[filename] + + def __enter__(self): + self.load() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.save() diff --git a/grc/core/default_flow_graph.grc b/grc/core/default_flow_graph.grc new file mode 100644 index 0000000..7654db6 --- /dev/null +++ b/grc/core/default_flow_graph.grc @@ -0,0 +1,32 @@ +################################################### +# Default Flow Graph: +# Include an options block and a variable for sample rate +################################################### + +options: + parameters: + id: 'default' + title: 'Not titled yet' + states: + coordinate: + - 8 + - 8 + rotation: 0 + state: enabled + +blocks: +- name: samp_rate + id: variable + parameters: + comment: '' + value: '32000' + states: + coordinate: + - 200 + - 12 + rotation: 0 + state: enabled + +metadata: + file_format: 1 + grc_version: 3.8.0 diff --git a/grc/core/errors.py b/grc/core/errors.py new file mode 100644 index 0000000..0b9aefc --- /dev/null +++ b/grc/core/errors.py @@ -0,0 +1,17 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +class GRCError(Exception): + """Generic error class""" + + +class BlockLoadError(GRCError): + """Error during block loading""" + + +class TemplateError(BlockLoadError): + """Mako Template Error""" diff --git a/grc/core/flow_graph.dtd b/grc/core/flow_graph.dtd new file mode 100644 index 0000000..eae755c --- /dev/null +++ b/grc/core/flow_graph.dtd @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/grc/core/generator/FlowGraphProxy.py b/grc/core/generator/FlowGraphProxy.py new file mode 100644 index 0000000..dad36c9 --- /dev/null +++ b/grc/core/generator/FlowGraphProxy.py @@ -0,0 +1,191 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from ..utils import expr_utils +from operator import methodcaller, attrgetter + + +class FlowGraphProxy(object): # TODO: move this in a refactored Generator + + def __init__(self, fg): + self.orignal_flowgraph = fg + + def __getattr__(self, item): + return getattr(self.orignal_flowgraph, item) + + def get_hier_block_stream_io(self, direction): + """ + Get a list of stream io signatures for this flow graph. + + Args: + direction: a string of 'in' or 'out' + + Returns: + a list of dicts with: type, label, vlen, size, optional + """ + return [p for p in self.get_hier_block_io(direction) if p['type'] != "message"] + + def get_hier_block_message_io(self, direction): + """ + Get a list of message io signatures for this flow graph. + + Args: + direction: a string of 'in' or 'out' + + Returns: + a list of dicts with: type, label, vlen, size, optional + """ + return [p for p in self.get_hier_block_io(direction) if p['type'] == "message"] + + def get_hier_block_io(self, direction): + """ + Get a list of io ports for this flow graph. + + Args: + direction: a string of 'in' or 'out' + + Returns: + a list of dicts with: type, label, vlen, size, optional + """ + pads = self.get_pad_sources() if direction in ('sink', 'in') else \ + self.get_pad_sinks() if direction in ('source', 'out') else [] + ports = [] + for pad in pads: + type_param = pad.params['type'] + master = { + 'label': str(pad.params['label'].get_evaluated()), + 'type': str(pad.params['type'].get_evaluated()), + 'vlen': str(pad.params['vlen'].get_value()), + 'size': type_param.options.attributes[type_param.get_value()]['size'], + 'cpp_size': type_param.options.attributes[type_param.get_value()]['cpp_size'], + 'optional': bool(pad.params['optional'].get_evaluated()), + } + num_ports = pad.params['num_streams'].get_evaluated() + if num_ports > 1: + for i in range(num_ports): + clone = master.copy() + clone['label'] += str(i) + ports.append(clone) + else: + ports.append(master) + return ports + + def get_pad_sources(self): + """ + Get a list of pad source blocks sorted by id order. + + Returns: + a list of pad source blocks in this flow graph + """ + pads = [b for b in self.get_enabled_blocks() if b.key == 'pad_source'] + return sorted(pads, key=lambda x: x.name) + + def get_pad_sinks(self): + """ + Get a list of pad sink blocks sorted by id order. + + Returns: + a list of pad sink blocks in this flow graph + """ + pads = [b for b in self.get_enabled_blocks() if b.key == 'pad_sink'] + return sorted(pads, key=lambda x: x.name) + + def get_pad_port_global_key(self, port): + """ + Get the key for a port of a pad source/sink to use in connect() + This takes into account that pad blocks may have multiple ports + + Returns: + the key (str) + """ + key_offset = 0 + pads = self.get_pad_sources() if port.is_source else self.get_pad_sinks() + for pad in pads: + # using the block param 'type' instead of the port domain here + # to emphasize that hier block generation is domain agnostic + is_message_pad = pad.params['type'].get_evaluated() == "message" + if port.parent == pad: + if is_message_pad: + key = pad.params['label'].get_value() + else: + key = str(key_offset + int(port.key)) + return key + else: + # assuming we have either only sources or sinks + if not is_message_pad: + key_offset += len(pad.sinks) + len(pad.sources) + return -1 + + def get_cpp_variables(self): + """ + Get a list of all variables (C++) in this flow graph namespace. + Exclude parameterized variables. + + Returns: + a sorted list of variable blocks in order of dependency (indep -> dep) + """ + variables = [block for block in self.iter_enabled_blocks() + if block.is_variable] + return expr_utils.sort_objects(variables, attrgetter('name'), methodcaller('get_cpp_var_make')) + + def includes(self): + """ + Get a set of all include statements (C++) in this flow graph namespace. + + Returns: + a list of #include statements + """ + return [block.cpp_templates.render('includes') for block in self.iter_enabled_blocks() if not (block.is_virtual_sink() or block.is_virtual_source())] + + def links(self): + """ + Get a set of all libraries to link against (C++) in this flow graph namespace. + + Returns: + a list of GNU Radio modules + """ + return [block.cpp_templates.render('link') for block in self.iter_enabled_blocks() if not (block.is_virtual_sink() or block.is_virtual_source())] + + def packages(self): + """ + Get a set of all packages to find (C++) ( especially for oot modules ) in this flow graph namespace. + + Returns: + a list of required packages + """ + return [block.cpp_templates.render('packages') for block in self.iter_enabled_blocks() if not (block.is_virtual_sink() or block.is_virtual_source())] + + +def get_hier_block_io(flow_graph, direction, domain=None): + """ + Get a list of io ports for this flow graph. + + Returns a list of dicts with: type, label, vlen, size, optional + """ + pads = flow_graph.get_pad_sources() if direction in ('sink', 'in') else \ + flow_graph.get_pad_sinks() if direction in ('source', 'out') else [] + ports = [] + for pad in pads: + type_param = pad.params['type'] + master = { + 'label': str(pad.params['label'].get_evaluated()), + 'type': str(pad.params['type'].get_evaluated()), + 'vlen': str(pad.params['vlen'].get_value()), + 'size': type_param.options.attributes[type_param.get_value()]['size'], + 'optional': bool(pad.params['optional'].get_evaluated()), + } + num_ports = pad.params['num_streams'].get_evaluated() + if num_ports > 1: + for i in range(num_ports): + clone = master.copy() + clone['label'] += str(i) + ports.append(clone) + else: + ports.append(master) + if domain is not None: + ports = [p for p in ports if p.domain == domain] + return ports diff --git a/grc/core/generator/Generator.py b/grc/core/generator/Generator.py new file mode 100644 index 0000000..8932419 --- /dev/null +++ b/grc/core/generator/Generator.py @@ -0,0 +1,51 @@ +# Copyright 2008-2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from .hier_block import HierBlockGenerator, QtHierBlockGenerator +from .top_block import TopBlockGenerator +from .cpp_top_block import CppTopBlockGenerator +from .cpp_hier_block import CppHierBlockGenerator + + +class Generator(object): + """Adaptor for various generators (uses generate_options)""" + + def __init__(self, flow_graph, output_dir): + """ + Initialize the generator object. + Determine the file to generate. + + Args: + flow_graph: the flow graph object + output_dir: the output path for generated files + """ + self.generate_options = flow_graph.get_option('generate_options') + self.output_language = flow_graph.get_option('output_language') + + if self.output_language == 'python': + + if self.generate_options == 'hb': + generator_cls = HierBlockGenerator + elif self.generate_options == 'hb_qt_gui': + generator_cls = QtHierBlockGenerator + else: + generator_cls = TopBlockGenerator + + elif self.output_language == 'cpp': + + if self.generate_options == 'hb': + generator_cls = CppHierBlockGenerator + elif self.generate_options == 'hb_qt_gui': + pass + else: + generator_cls = CppTopBlockGenerator + + self._generator = generator_cls(flow_graph, output_dir) + + def __getattr__(self, item): + """get all other attrib from actual generator object""" + return getattr(self._generator, item) diff --git a/grc/core/generator/__init__.py b/grc/core/generator/__init__.py new file mode 100644 index 0000000..53bf046 --- /dev/null +++ b/grc/core/generator/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2008-2015 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +from .Generator import Generator diff --git a/grc/core/generator/cpp_hier_block.py b/grc/core/generator/cpp_hier_block.py new file mode 100644 index 0000000..e278d4b --- /dev/null +++ b/grc/core/generator/cpp_hier_block.py @@ -0,0 +1,217 @@ +import collections +import os + +import codecs + +from .cpp_top_block import CppTopBlockGenerator + +from .. import Constants +from ..io import yaml + + +class CppHierBlockGenerator(CppTopBlockGenerator): + """Extends the top block generator to also generate a block YML file""" + + def __init__(self, flow_graph, output_dir): + """ + Initialize the hier block generator object. + + Args: + flow_graph: the flow graph object + output_dir: the path for written files + """ + platform = flow_graph.parent + if output_dir is None: + output_dir = platform.config.hier_block_lib_dir + if not os.path.exists(output_dir): + os.mkdir(output_dir) + + CppTopBlockGenerator.__init__(self, flow_graph, output_dir) + self._mode = Constants.HIER_BLOCK_FILE_MODE + self.file_path_yml = self.file_path + '.block.yml' + + def write(self): + """generate output and write it to files""" + CppTopBlockGenerator.write(self) + + data = yaml.dump(self._build_block_n_from_flow_graph_io()) + + replace = [ + ('parameters:', '\nparameters:'), + ('inputs:', '\ninputs:'), + ('outputs:', '\noutputs:'), + ('asserts:', '\nasserts:'), + ('\ntemplates:', '\n\ntemplates:'), + ('cpp_templates:', '\ncpp_templates:'), + ('documentation:', '\ndocumentation:'), + ('file_format:', '\nfile_format:'), + ] + for r in replace: + data = data.replace(*r) + + with codecs.open(self.file_path_yml, 'w', encoding='utf-8') as fp: + fp.write(data) + + # Windows only supports S_IREAD and S_IWRITE, other flags are ignored + os.chmod(self.file_path_yml, self._mode) + + def _build_block_n_from_flow_graph_io(self): + """ + Generate a block YML nested data from the flow graph IO + + Returns: + a yml node tree + """ + # Extract info from the flow graph + block_id = self._flow_graph.get_option('id') + parameters = self._flow_graph.get_parameters() + + def var_or_value(name): + if name in (p.name for p in parameters): + return "${" + name + " }" + return name + + # Build the nested data + data = collections.OrderedDict() + data['id'] = block_id + data['label'] = ( + self._flow_graph.get_option('title') or + self._flow_graph.get_option('id').replace('_', ' ').title() + ) + data['category'] = self._flow_graph.get_option('category') + data['flags'] = ['cpp'] + + # Parameters + data['parameters'] = [] + for param_block in parameters: + p = collections.OrderedDict() + p['id'] = param_block.name + p['label'] = param_block.params['label'].get_value() or param_block.name + p['dtype'] = param_block.params['value'].dtype + p['default'] = param_block.params['value'].get_value() + p['hide'] = param_block.params['hide'].get_value() + data['parameters'].append(p) + + # Ports + for direction in ('inputs', 'outputs'): + data[direction] = [] + for port in get_hier_block_io(self._flow_graph, direction): + p = collections.OrderedDict() + if port.domain == Constants.GR_MESSAGE_DOMAIN: + p['id'] = port.id + p['label'] = port.parent.params['label'].value + if port.domain != Constants.DEFAULT_DOMAIN: + p['domain'] = port.domain + p['dtype'] = port.dtype + if port.domain != Constants.GR_MESSAGE_DOMAIN: + p['vlen'] = var_or_value(port.vlen) + if port.optional: + p['optional'] = True + data[direction].append(p) + + t = data['templates'] = collections.OrderedDict() + + t['import'] = "from {0} import {0} # grc-generated hier_block".format( + self._flow_graph.get_option('id')) + # Make data + if parameters: + t['make'] = '{cls}(\n {kwargs},\n)'.format( + cls=block_id, + kwargs=',\n '.join( + '{key}=${{ {key} }}'.format(key=param.name) for param in parameters + ), + ) + else: + t['make'] = '{cls}()'.format(cls=block_id) + # Self-connect if there aren't any ports + if not data['inputs'] and not data['outputs']: + t['make'] += '\nthis->connect(this->${id});' + + # Callback data + t['callbacks'] = [ + 'set_{key}(${{ {key} }})'.format(key=param_block.name) for param_block in parameters + ] + + t_cpp = data['cpp_templates'] = collections.OrderedDict() + + t_cpp['includes'] = [] + t_cpp['includes'].append( + '#include "{id}/{id}.hpp"'.format(id=self._flow_graph.get_option('id'))) + + # Make data + if parameters: + t_cpp['make'] = '{cls}(\n {kwargs},\n)'.format( + cls=block_id, + kwargs=',\n '.join( + '{key}=${{ {key} }}'.format(key=param.name) for param in parameters + ), + ) + else: + t_cpp['make'] = 'this->${{id}} = {cls}_sptr(make_{cls}());'.format( + cls=block_id) + t_cpp['declarations'] = '{cls}_sptr ${{id}};'.format(cls=block_id) + + # Callback data + t_cpp['callbacks'] = [ + 'set_{key}(${{ {key} }})'.format(key=param_block.name) for param_block in parameters + ] + + # Documentation + data['documentation'] = "\n".join(field for field in ( + self._flow_graph.get_option('author'), + self._flow_graph.get_option('description'), + self.file_path + ) if field) + + data['grc_source'] = str(self._flow_graph.grc_file_path) + + data['file_format'] = 1 + + return data + + +class CppQtHierBlockGenerator(CppHierBlockGenerator): + + def _build_block_n_from_flow_graph_io(self): + n = CppHierBlockGenerator._build_block_n_from_flow_graph_io(self) + block_n = collections.OrderedDict() + + # insert flags after category + for key, value in n['block'].items(): + block_n[key] = value + if key == 'category': + block_n['flags'] = 'need_qt_gui' + + if not block_n['name'].upper().startswith('QT GUI'): + block_n['name'] = 'QT GUI ' + block_n['name'] + + gui_hint_param = collections.OrderedDict() + gui_hint_param['name'] = 'GUI Hint' + gui_hint_param['key'] = 'gui_hint' + gui_hint_param['value'] = '' + gui_hint_param['type'] = 'gui_hint' + gui_hint_param['hide'] = 'part' + block_n['param'].append(gui_hint_param) + + block_n['make'] += ( + "\n#set $win = 'self.%s' % $id" + "\n${gui_hint()($win)}" + ) + + return {'block': block_n} + + +def get_hier_block_io(flow_graph, direction, domain=None): + """ + Get a list of io ports for this flow graph. + + Returns a list of dicts with: type, label, vlen, size, optional + """ + pads = flow_graph.get_pad_sources( + ) if direction == 'inputs' else flow_graph.get_pad_sinks() + + for pad in pads: + for port in (pad.sources if direction == 'inputs' else pad.sinks): + if domain and port.domain != domain: + continue + yield port diff --git a/grc/core/generator/cpp_templates/CMakeLists.txt.mako b/grc/core/generator/cpp_templates/CMakeLists.txt.mako new file mode 100644 index 0000000..db6273e --- /dev/null +++ b/grc/core/generator/cpp_templates/CMakeLists.txt.mako @@ -0,0 +1,72 @@ +##################### +# GNU Radio C++ Flow Graph CMakeLists.txt +# +# Title: ${title} +% if flow_graph.get_option('author'): +# Author: ${flow_graph.get_option('author')} +% endif +% if flow_graph.get_option('description'): +# Description: ${flow_graph.get_option('description')} +% endif +# GNU Radio version: ${config.version} +##################### + +<% +class_name = flow_graph.get_option('id') +version_list = config.version_parts +short_version = '.'.join(version_list[0:2]) +%>\ + +cmake_minimum_required(VERSION 3.16) +set(CMAKE_CXX_STANDARD 14) + +project(${class_name}) + +find_package(Gnuradio "${short_version}" COMPONENTS + % for component in config.enabled_components.split(";"): + % if component.startswith("gr-"): + % if not component in ['gr-utils', 'gr-ctrlport']: + ${component.replace("gr-", "")} + % endif + % endif + % endfor +) + +% if generate_options == 'qt_gui': +find_package(Qt5Widgets REQUIRED) +set(CMAKE_AUTOMOC TRUE) +% endif + +% if cmake_tuples: +% for key, val in cmake_tuples: +set(${key} ${val}) +% endfor +% endif + +% if flow_graph.get_option('gen_linking') == 'static': +set(BUILD_SHARED_LIBS false) +set(CMAKE_EXE_LINKER_FLAGS " -static") +set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") +% endif + +% for package in packages: +% if package: +find_package(${package}) +% endif +% endfor +add_executable(${class_name} ${class_name}.cpp) +target_link_libraries(${class_name} + gnuradio::gnuradio-blocks + % if generate_options == 'qt_gui': + gnuradio::gnuradio-qtgui + % endif + % if parameters: + Boost::program_options + % endif + % for link in links: + % if link: + ${link} + % endif + % endfor +) + diff --git a/grc/core/generator/cpp_templates/flow_graph.cpp.mako b/grc/core/generator/cpp_templates/flow_graph.cpp.mako new file mode 100644 index 0000000..54a36fe --- /dev/null +++ b/grc/core/generator/cpp_templates/flow_graph.cpp.mako @@ -0,0 +1,189 @@ +<%def name="doubleindent(code)">${ '\n '.join(str(code).splitlines()) }\ +/******************** +GNU Radio C++ Flow Graph Source File + +Title: ${title} +% if flow_graph.get_option('author'): +Author: ${flow_graph.get_option('author')} +% endif +% if flow_graph.get_option('description'): +Description: ${flow_graph.get_option('description')} +% endif +GNU Radio version: ${config.version} +********************/ + +#include "${flow_graph.get_option('id')}.hpp" +% if flow_graph.get_option('realtime_scheduling'): +#include +% endif + +% if parameters: + +namespace po = boost::program_options; + +% endif +using namespace gr; + +<% +class_name = flow_graph.get_option('id') + ('_' if flow_graph.get_option('id') == 'top_block' else '') + +param_str = ", ".join((param.vtype + " " + param.name) for param in parameters) +param_str_without_types = ", ".join(param.name for param in parameters) +initializer_str = ",\n ".join((param.name + "(" + param.name + ")") for param in parameters) + +if generate_options == 'qt_gui': + initializer_str = 'QWidget()' + (',\n ' if len(parameters) > 0 else '') + initializer_str + +if len(initializer_str) > 0: + initializer_str = '\n: ' + initializer_str +%>\ + +${class_name}::${class_name} (${param_str}) ${initializer_str} { + +% if generate_options == 'qt_gui': + this->setWindowTitle("${title}"); + // check_set_qss + // set icon + this->top_scroll_layout = new QVBoxLayout(); + this->setLayout(this->top_scroll_layout); + this->top_scroll = new QScrollArea(); + this->top_scroll->setFrameStyle(QFrame::NoFrame); + this->top_scroll_layout->addWidget(this->top_scroll); + this->top_scroll->setWidgetResizable(true); + this->top_widget = new QWidget(); + this->top_scroll->setWidget(this->top_widget); + this->top_layout = new QVBoxLayout(this->top_widget); + this->top_grid_layout = new QGridLayout(); + this->top_layout->addLayout(this->top_grid_layout); + + this->settings = new QSettings("gnuradio/flowgraphs", "${class_name}"); + this->restoreGeometry(this->settings->value("geometry").toByteArray()); +% endif + +% if flow_graph.get_option('thread_safe_setters'): +## self._lock = threading.RLock() +% endif + this->tb = gr::make_top_block("${title}"); + +% if blocks: +// Blocks: +% for blk, blk_make, declarations in blocks: + ${doubleindent(blk_make)} +## % if 'alias' in blk.params and blk.params['alias'].get_evaluated(): +## ${blk.name}.set_block_alias("${blk.params['alias'].get_evaluated()}") +## % endif +## % if 'affinity' in blk.params and blk.params['affinity'].get_evaluated(): +## ${blk.name}.set_processor_affinity("${blk.params['affinity'].get_evaluated()}") +## % endif +## % if len(blk.sources) > 0 and 'minoutbuf' in blk.params and int(blk.params['minoutbuf'].get_evaluated()) > 0: +## ${blk.name}.set_min_output_buffer(${blk.params['minoutbuf'].get_evaluated()}) +## % endif +## % if len(blk.sources) > 0 and 'maxoutbuf' in blk.params and int(blk.params['maxoutbuf'].get_evaluated()) > 0: +## ${blk.name}.set_max_output_buffer(${blk.params['maxoutbuf'].get_evaluated()}) +## % endif + +% endfor +% endif + +% if connections: +// Connections: +% for connection in connections: + ${connection.rstrip()}; +% endfor +% endif +} + +${class_name}::~${class_name} () { +} + +// Callbacks: +% for var in parameters + variables: +${var.vtype} ${class_name}::get_${var.name} () const { + return this->${var.name}; +} + + +void ${class_name}::set_${var.name} (${var.vtype} ${var.name}) { +% if flow_graph.get_option('thread_safe_setters'): + ## with self._lock: + return; +% else: + this->${var.name} = ${var.name}; + % for callback in callbacks[var.name]: + ${callback}; + % endfor +% endif +} + +% endfor + +% if generate_options == 'qt_gui': + void ${class_name}::closeEvent(QCloseEvent *event) { + this->settings->setValue("geometry",this->saveGeometry()); + event->accept(); +} +% endif + +int main (int argc, char **argv) { + % if parameters: + % for parameter in parameters: + ${parameter.vtype} ${parameter.name} = ${parameter.cpp_templates.render('make')}; + % endfor + + po::options_description desc("Options"); + desc.add_options() + ("help", "display help") + % for parameter in parameters: + ("${parameter.name}", po::value<${parameter.vtype}>(&${parameter.name}), "${parameter.label}") + % endfor + ; + + po::variables_map vm; + po::store(po::parse_command_line(argc, argv, desc), vm); + po::notify(vm); + + if (vm.count("help")) { + std::cout << desc << std::endl; + return 0; + } + % endif + % if flow_graph.get_option('realtime_scheduling'): + if (enable_realtime_scheduling() != RT_OK) { + std::cout << "Error: failed to enable real-time scheduling." << std::endl; + } + % endif + + % if generate_options == 'no_gui': + ${class_name}* top_block = new ${class_name}(${param_str_without_types}); + ## TODO: params + % if flow_graph.get_option('run_options') == 'prompt': + top_block->tb->start(); + % for m in monitors: + (top_block->${m.name}).start(); + % endfor + std::cout << "Press Enter to quit: "; + std::cin.ignore(); + top_block->tb->stop(); + % elif flow_graph.get_option('run_options') == 'run': + top_block->tb->start(); + % endif + % for m in monitors: + (top_block->${m.name}).start(); + % endfor + top_block->tb->wait(); + % elif generate_options == 'qt_gui': + QApplication app(argc, argv); + + ${class_name}* top_block = new ${class_name}(${param_str_without_types}); + + top_block->tb->start(); + top_block->show(); + app.exec(); + + % endif + + return 0; +} +% if generate_options == 'qt_gui': +#include "moc_${class_name}.cpp" +% endif diff --git a/grc/core/generator/cpp_templates/flow_graph.hpp.mako b/grc/core/generator/cpp_templates/flow_graph.hpp.mako new file mode 100644 index 0000000..024f612 --- /dev/null +++ b/grc/core/generator/cpp_templates/flow_graph.hpp.mako @@ -0,0 +1,193 @@ +<%def name="indent(code)">${ ' ' + '\n '.join(str(code).splitlines()) }\ +<%def name="doubleindent(code)">${ '\n '.join(str(code).splitlines()) }\ +#ifndef ${flow_graph.get_option('id').upper()}_HPP +#define ${flow_graph.get_option('id').upper()}_HPP +/******************** +GNU Radio C++ Flow Graph Header File + +Title: ${title} +% if flow_graph.get_option('author'): +Author: ${flow_graph.get_option('author')} +% endif +% if flow_graph.get_option('description'): +Description: ${flow_graph.get_option('description')} +% endif +GNU Radio version: ${config.version} +********************/ + +/******************** +** Create includes +********************/ +% for inc in includes: +${inc} +% endfor + +% if generate_options == 'qt_gui': +#include +#include +#include +#include +#include +#include +#include +% endif + +% if parameters: +#include +% endif + +using namespace gr; + +<% +class_name = flow_graph.get_option('id') + ('_' if flow_graph.get_option('id') == 'top_block' else '') +param_str = ", ".join((param.vtype + " " + param.name) for param in parameters) +%>\ + +% if generate_options == 'no_gui': +class ${class_name} { +% elif generate_options.startswith('hb'): +class ${class_name} : public hier_block2 { +% elif generate_options == 'qt_gui': +class ${class_name} : public QWidget { + Q_OBJECT +% endif + +private: +% if generate_options == 'qt_gui': + QVBoxLayout *top_scroll_layout; + QScrollArea *top_scroll; + QWidget *top_widget; + QVBoxLayout *top_layout; + QGridLayout *top_grid_layout; + QSettings *settings; + void closeEvent(QCloseEvent *event); +% endif + + +% for block, make, declarations in blocks: +% if declarations: +${indent(declarations)} +% endif +% endfor + +% if parameters: +// Parameters: +% for param in parameters: + ${param.vtype} ${param.cpp_templates.render('var_make')} +% endfor +% endif + +% if variables: +// Variables: +% for var in variables: + ${var.vtype} ${var.cpp_templates.render('var_make')} +% endfor +% endif + +public: +% if generate_options.startswith('hb'): + typedef std::shared_ptr<${class_name}> sptr; + static sptr make(${param_str}); +% else: + top_block_sptr tb; +% endif + ${class_name}(${param_str}); + ~${class_name}(); + +% for var in parameters + variables: + ${var.vtype} get_${var.name} () const; + void set_${var.name}(${var.vtype} ${var.name}); +% endfor + +}; + + +% if generate_options.startswith('hb'): +<% in_sigs = flow_graph.get_hier_block_stream_io('in') %> +<% out_sigs = flow_graph.get_hier_block_stream_io('out') %> + +<%def name="make_io_sig(io_sigs)">\ + <% size_strs = [ '%s*%s'%(io_sig['cpp_size'], io_sig['vlen']) for io_sig in io_sigs] %>\ + % if len(io_sigs) == 0: + gr::io_signature::make(0, 0, 0)\ + % elif len(io_sigs) == 1: + gr::io_signature::make(1, 1, ${size_strs[0]})\ + % else: + gr::io_signaturev(${len(io_sigs)}, ${len(io_sigs)}, [${', '.join(size_strs)}])\ + % endif +\ + +${class_name}::${class_name} (${param_str}) : hier_block2("${title}", + ${make_io_sig(in_sigs)}, + ${make_io_sig(out_sigs)} + ) { +% for pad in flow_graph.get_hier_block_message_io('in'): + message_port_register_hier_in("${pad['label']}") +% endfor +% for pad in flow_graph.get_hier_block_message_io('out'): + message_port_register_hier_out("${pad['label']}") +% endfor + +% if flow_graph.get_option('thread_safe_setters'): +## self._lock = threading.RLock() +% endif + +% if blocks: +// Blocks: +% for blk, blk_make, declarations in blocks: + { + ${doubleindent(blk_make)} +## % if 'alias' in blk.params and blk.params['alias'].get_evaluated(): +## ${blk.name}.set_block_alias("${blk.params['alias'].get_evaluated()}") +## % endif +## % if 'affinity' in blk.params and blk.params['affinity'].get_evaluated(): +## ${blk.name}.set_processor_affinity("${blk.params['affinity'].get_evaluated()}") +## % endif +## % if len(blk.sources) > 0 and 'minoutbuf' in blk.params and int(blk.params['minoutbuf'].get_evaluated()) > 0: +## ${blk.name}.set_min_output_buffer(${blk.params['minoutbuf'].get_evaluated()}) +## % endif +## % if len(blk.sources) > 0 and 'maxoutbuf' in blk.params and int(blk.params['maxoutbuf'].get_evaluated()) > 0: +## ${blk.name}.set_max_output_buffer(${blk.params['maxoutbuf'].get_evaluated()}) +## % endif + } +% endfor +% endif + +% if connections: +// Connections: +% for connection in connections: + ${connection.rstrip()}; +% endfor +% endif +} +${class_name}::~${class_name} () { +} + +// Callbacks: +% for var in parameters + variables: +${var.vtype} ${class_name}::get_${var.name} () const { + return this->${var.name}; +} + +void ${class_name}::set_${var.name} (${var.vtype} ${var.name}) { +% if flow_graph.get_option('thread_safe_setters'): + ## with self._lock: + return; +% else: + this->${var.name} = ${var.name}; + % for callback in callbacks[var.name]: + ${callback}; + % endfor +% endif +} + +% endfor +${class_name}::sptr +${class_name}::make(${param_str}) +{ + return gnuradio::make_block_sptr<${class_name}>( + ${", ".join(param.name for param in parameters)}); +} +% endif +#endif + diff --git a/grc/core/generator/cpp_top_block.py b/grc/core/generator/cpp_top_block.py new file mode 100644 index 0000000..136c9b7 --- /dev/null +++ b/grc/core/generator/cpp_top_block.py @@ -0,0 +1,499 @@ +import codecs +import yaml +import operator +import os +import tempfile +import re + +from mako.template import Template + +from .. import Messages, blocks +from ..Constants import TOP_BLOCK_FILE_MODE +from .FlowGraphProxy import FlowGraphProxy +from ..utils import expr_utils +from .top_block import TopBlockGenerator + +DATA_DIR = os.path.dirname(__file__) + +HEADER_TEMPLATE = os.path.join(DATA_DIR, 'cpp_templates/flow_graph.hpp.mako') +SOURCE_TEMPLATE = os.path.join(DATA_DIR, 'cpp_templates/flow_graph.cpp.mako') +CMAKE_TEMPLATE = os.path.join(DATA_DIR, 'cpp_templates/CMakeLists.txt.mako') + +header_template = Template(filename=HEADER_TEMPLATE) +source_template = Template(filename=SOURCE_TEMPLATE) +cmake_template = Template(filename=CMAKE_TEMPLATE) + + +class CppTopBlockGenerator(object): + + def __init__(self, flow_graph, output_dir): + """ + Initialize the top block generator object. + + Args: + flow_graph: the flow graph object + output_dir: the path for written files + """ + + self._flow_graph = FlowGraphProxy(flow_graph) + self._generate_options = self._flow_graph.get_option( + 'generate_options') + + self._mode = TOP_BLOCK_FILE_MODE + # Handle the case where the directory is read-only + # In this case, use the system's temp directory + if not os.access(output_dir, os.W_OK): + output_dir = tempfile.gettempdir() + filename = self._flow_graph.get_option('id') + self.file_path = os.path.join(output_dir, filename) + self.output_dir = output_dir + + def _warnings(self): + throttling_blocks = [b for b in self._flow_graph.get_enabled_blocks() + if b.flags.throttle] + if not throttling_blocks and not self._generate_options.startswith('hb'): + Messages.send_warning("This flow graph may not have flow control: " + "no audio or RF hardware blocks found. " + "Add a Misc->Throttle block to your flow " + "graph to avoid CPU congestion.") + if len(throttling_blocks) > 1: + keys = set([b.key for b in throttling_blocks]) + if len(keys) > 1 and 'blocks_throttle' in keys: + Messages.send_warning("This flow graph contains a throttle " + "block and another rate limiting block, " + "e.g. a hardware source or sink. " + "This is usually undesired. Consider " + "removing the throttle block.") + + deprecated_block_keys = { + b.name for b in self._flow_graph.get_enabled_blocks() if b.flags.deprecated} + for key in deprecated_block_keys: + Messages.send_warning("The block {!r} is deprecated.".format(key)) + + def write(self): + """create directory, generate output and write it to files""" + self._warnings() + + fg = self._flow_graph + platform = fg.parent + self.title = fg.get_option('title') or fg.get_option( + 'id').replace('_', ' ').title() + variables = fg.get_cpp_variables() + parameters = fg.get_parameters() + monitors = fg.get_monitors() + self._variable_types() + self._parameter_types() + + self.namespace = { + 'flow_graph': fg, + 'variables': variables, + 'parameters': parameters, + 'monitors': monitors, + 'generate_options': self._generate_options, + 'config': platform.config + } + + if not os.path.exists(self.file_path): + os.makedirs(self.file_path) + + for filename, data in self._build_cpp_header_code_from_template(): + with codecs.open(filename, 'w', encoding='utf-8') as fp: + fp.write(data) + + if not self._generate_options.startswith('hb'): + if not os.path.exists(os.path.join(self.file_path, 'build')): + os.makedirs(os.path.join(self.file_path, 'build')) + + for filename, data in self._build_cpp_source_code_from_template(): + with codecs.open(filename, 'w', encoding='utf-8') as fp: + fp.write(data) + + if fg.get_option('gen_cmake') == 'On': + for filename, data in self._build_cmake_code_from_template(): + with codecs.open(filename, 'w', encoding='utf-8') as fp: + fp.write(data) + + def _build_cpp_source_code_from_template(self): + """ + Convert the flow graph to a C++ source file. + + Returns: + a string of C++ code + """ + filename = self._flow_graph.get_option('id') + '.cpp' + file_path = os.path.join(self.file_path, filename) + + output = [] + + flow_graph_code = source_template.render( + title=self.title, + includes=self._includes(), + blocks=self._blocks(), + callbacks=self._callbacks(), + connections=self._connections(), + **self.namespace + ) + # strip trailing white-space + flow_graph_code = "\n".join(line.rstrip() + for line in flow_graph_code.split("\n")) + output.append((file_path, flow_graph_code)) + + return output + + def _build_cpp_header_code_from_template(self): + """ + Convert the flow graph to a C++ header file. + + Returns: + a string of C++ code + """ + filename = self._flow_graph.get_option('id') + '.hpp' + file_path = os.path.join(self.file_path, filename) + + output = [] + + flow_graph_code = header_template.render( + title=self.title, + includes=self._includes(), + blocks=self._blocks(), + callbacks=self._callbacks(), + connections=self._connections(), + **self.namespace + ) + # strip trailing white-space + flow_graph_code = "\n".join(line.rstrip() + for line in flow_graph_code.split("\n")) + output.append((file_path, flow_graph_code)) + + return output + + def _build_cmake_code_from_template(self): + """ + Convert the flow graph to a CMakeLists.txt file. + + Returns: + a string of CMake code + """ + filename = 'CMakeLists.txt' + file_path = os.path.join(self.file_path, filename) + + cmake_tuples = [] + cmake_opt = self._flow_graph.get_option("cmake_opt") + cmake_opt = " " + cmake_opt # To make sure we get rid of the "-D"s when splitting + + for opt_string in cmake_opt.split(" -D"): + opt_string = opt_string.strip() + if opt_string: + cmake_tuples.append(tuple(opt_string.split("="))) + + output = [] + + flow_graph_code = cmake_template.render( + title=self.title, + includes=self._includes(), + blocks=self._blocks(), + callbacks=self._callbacks(), + connections=self._connections(), + links=self._links(), + cmake_tuples=cmake_tuples, + packages=self._packages(), + **self.namespace + ) + # strip trailing white-space + flow_graph_code = "\n".join(line.rstrip() + for line in flow_graph_code.split("\n")) + output.append((file_path, flow_graph_code)) + + return output + + def _links(self): + fg = self._flow_graph + links = fg.links() + seen = set() + + for link_list in links: + if link_list: + for link in link_list: + seen.add(link) + + return list(seen) + + def _packages(self): + fg = self._flow_graph + packages = fg.packages() + seen = set() + + for package_list in packages: + if package_list: + for package in package_list: + seen.add(package) + + return list(seen) + + def _includes(self): + fg = self._flow_graph + includes = fg.includes() + seen = set() + output = [] + + def is_duplicate(line): + if line.startswith('#include') and line in seen: + return True + seen.add(line) + return False + + for block_ in includes: + for include_ in block_: + if not include_: + continue + line = include_.rstrip() + if not is_duplicate(line): + output.append(line) + + return output + + def _blocks(self): + fg = self._flow_graph + parameters = fg.get_parameters() + + # List of blocks not including variables and parameters and disabled + def _get_block_sort_text(block): + code = block.cpp_templates.render('declarations') + try: + # Newer gui markup w/ qtgui + code += block.params['gui_hint'].get_value() + except Exception: + pass + return code + + blocks = [ + b for b in fg.blocks + if b.enabled and not (b.get_bypassed() or b.is_import or b in parameters or b.key == 'options' or b.is_virtual_source() or b.is_virtual_sink()) + ] + + blocks = expr_utils.sort_objects( + blocks, operator.attrgetter('name'), _get_block_sort_text) + blocks_make = [] + for block in blocks: + translations = block.cpp_templates.render('translations') + make = block.cpp_templates.render('make') + declarations = block.cpp_templates.render('declarations') + if translations: + translations = yaml.safe_load(translations) + else: + translations = {} + translations.update({ + r"gr\.sizeof_([\w_]+)": r"sizeof(\1)", + r"'([^']*)'": r'"\1"', + r"True": r"true", + r"False": r"false", + }) + for key in translations: + make = re.sub(key.replace("\\\\", "\\"), + translations[key], make) + declarations = declarations.replace(key, translations[key]) + if make: + blocks_make.append((block, make, declarations)) + elif 'qt' in block.key: + # The QT Widget blocks are technically variables, + # but they contain some code we don't want to miss + blocks_make.append(('', make, declarations)) + return blocks_make + + def _variable_types(self): + fg = self._flow_graph + variables = fg.get_cpp_variables() + + type_translation = {'real': 'double', 'float': 'float', 'int': 'int', 'bool': 'bool', + 'complex_vector': 'std::vector', 'real_vector': 'std::vector', + 'float_vector': 'std::vector', 'int_vector': 'std::vector', + 'string': 'std::string', 'complex': 'gr_complex'} + # If the type is explicitly specified, translate to the corresponding C++ type + for var in list(variables): + if var.params['value'].dtype != 'raw': + var.vtype = type_translation[var.params['value'].dtype] + variables.remove(var) + + # If the type is 'raw', we'll need to evaluate the variable to infer the type. + # Create an executable fragment of code containing all 'raw' variables in + # order to infer the lvalue types. + # + # Note that this differs from using ast.literal_eval() as literal_eval evaluates one + # variable at a time. The code fragment below evaluates all variables together which + # allows the variables to reference each other (i.e. a = b * c). + prog = 'def get_decl_types():\n' + prog += '\tvar_types = {}\n' + for var in variables: + prog += '\t' + str(var.params['id'].value) + \ + '=' + str(var.params['value'].value) + '\n' + prog += '\tvar_types = {}\n' + for var in variables: + prog += '\tvar_types[\'' + str(var.params['id'].value) + \ + '\'] = type(' + str(var.params['id'].value) + ')\n' + prog += '\treturn var_types' + + # Execute the code fragment in a separate namespace and retrieve the lvalue types + var_types = {} + namespace = {} + try: + exec(prog, namespace) + var_types = namespace['get_decl_types']() + except Exception as excp: + print('Failed to get parameter lvalue types: %s' % (excp)) + + # Format the rvalue of each variable expression + for var in variables: + var.format_expr(var_types[str(var.params['id'].value)]) + + def _parameter_types(self): + fg = self._flow_graph + parameters = fg.get_parameters() + + for param in parameters: + type_translation = {'eng_float': 'double', 'intx': 'long', 'str': 'std::string', 'complex': 'gr_complex'} + param.vtype = type_translation[param.params['type'].value] + + cpp_value = param.get_cpp_value(param.params['value'].value) + + # Update 'make' and 'var_make' entries in the cpp_templates dictionary + d = param.cpp_templates + cpp_expr = d['var_make'].replace('${value}', cpp_value) + d.update({'make': cpp_value, 'var_make': cpp_expr}) + param.cpp_templates = d + + def _callbacks(self): + fg = self._flow_graph + variables = fg.get_cpp_variables() + parameters = fg.get_parameters() + + # List of variable names + var_ids = [var.name for var in parameters + variables] + + replace_dict = dict((var_id, 'this->' + var_id) for var_id in var_ids) + + callbacks_all = [] + for block in fg.iter_enabled_blocks(): + if not (block.is_virtual_sink() or block.is_virtual_source()): + callbacks_all.extend(expr_utils.expr_replace( + cb, replace_dict) for cb in block.get_cpp_callbacks()) + + # Map var id to callbacks + def uses_var_id(callback): + used = expr_utils.get_variable_dependencies(callback, [var_id]) + # callback might contain var_id itself + return used and ('this->' + var_id in callback) + + callbacks = {} + for var_id in var_ids: + callbacks[var_id] = [ + callback for callback in callbacks_all if uses_var_id(callback)] + + return callbacks + + def _connections(self): + fg = self._flow_graph + templates = {key: Template(text) + for key, text in fg.parent_platform.cpp_connection_templates.items()} + + def make_port_sig(port): + if port.parent.key in ('pad_source', 'pad_sink'): + block = 'self()' + key = fg.get_pad_port_global_key(port) + else: + block = 'this->' + port.parent_block.name + key = port.key + + if not key.isdigit(): + # TODO What use case is this supporting? + toks = re.findall(r'\d+', key) + if len(toks) > 0: + key = toks[0] + else: + # Assume key is a string + key = '"' + key + '"' + + return '{block}, {key}'.format(block=block, key=key) + + connections = fg.get_enabled_connections() + + # Get the virtual blocks and resolve their connections + connection_factory = fg.parent_platform.Connection + virtual_source_connections = [c for c in connections if isinstance( + c.source_block, blocks.VirtualSource)] + for connection in virtual_source_connections: + sink = connection.sink_port + for source in connection.source_port.resolve_virtual_source(): + resolved = connection_factory( + fg.orignal_flowgraph, source, sink) + connections.append(resolved) + + virtual_connections = [c for c in connections if (isinstance( + c.source_block, blocks.VirtualSource) or isinstance(c.sink_block, blocks.VirtualSink))] + for connection in virtual_connections: + # Remove the virtual connection + connections.remove(connection) + + # Bypassing blocks: Need to find all the enabled connections for the block using + # the *connections* object rather than get_connections(). Create new connections + # that bypass the selected block and remove the existing ones. This allows adjacent + # bypassed blocks to see the newly created connections to downstream blocks, + # allowing them to correctly construct bypass connections. + bypassed_blocks = fg.get_bypassed_blocks() + for block in bypassed_blocks: + # Get the upstream connection (off of the sink ports) + # Use *connections* not get_connections() + source_connection = [ + c for c in connections if c.sink_port == block.sinks[0]] + # The source connection should never have more than one element. + assert (len(source_connection) == 1) + + # Get the source of the connection. + source_port = source_connection[0].source_port + + # Loop through all the downstream connections + for sink in (c for c in connections if c.source_port == block.sources[0]): + if not sink.enabled: + # Ignore disabled connections + continue + connection = connection_factory( + fg.orignal_flowgraph, source_port, sink.sink_port) + connections.append(connection) + # Remove this sink connection + connections.remove(sink) + # Remove the source connection + connections.remove(source_connection[0]) + + # List of connections where each endpoint is enabled (sorted by domains, block names) + def by_domain_and_blocks(c): + return c.type, c.source_block.name, c.sink_block.name + + rendered = [] + for con in sorted(connections, key=by_domain_and_blocks): + template = templates[con.type] + + if con.source_port.dtype != 'bus': + code = template.render( + make_port_sig=make_port_sig, source=con.source_port, sink=con.sink_port) + if not self._generate_options.startswith('hb'): + code = 'this->tb->' + code + rendered.append(code) + else: + # Bus ports need to iterate over the underlying connections and then render + # the code for each subconnection + porta = con.source_port + portb = con.sink_port + fg = self._flow_graph + + if porta.dtype == 'bus' and portb.dtype == 'bus': + # which bus port is this relative to the bus structure + if len(porta.bus_structure) == len(portb.bus_structure): + for port_num in porta.bus_structure: + hidden_porta = porta.parent.sources[port_num] + hidden_portb = portb.parent.sinks[port_num] + code = template.render( + make_port_sig=make_port_sig, source=hidden_porta, sink=hidden_portb) + if not self._generate_options.startswith('hb'): + code = 'this->tb->' + code + rendered.append(code) + + return rendered diff --git a/grc/core/generator/flow_graph.py.mako b/grc/core/generator/flow_graph.py.mako new file mode 100644 index 0000000..30ef055 --- /dev/null +++ b/grc/core/generator/flow_graph.py.mako @@ -0,0 +1,435 @@ +% if not generate_options.startswith('hb'): +#!/usr/bin/env python3 +% endif +# -*- coding: utf-8 -*- +<%def name="indent(code)">${ '\n '.join(str(code).splitlines()) } +# +# SPDX-License-Identifier: GPL-3.0 +# +################################################## +# GNU Radio Python Flow Graph +# Title: ${title} +% if flow_graph.get_option('author'): +# Author: ${flow_graph.get_option('author')} +% endif +% if flow_graph.get_option('copyright'): +# Copyright: ${flow_graph.get_option('copyright')} +% endif +% if flow_graph.get_option('description'): +# Description: ${flow_graph.get_option('description')} +% endif +# GNU Radio version: ${version} +################################################## + +######################################################## +##Create Imports +######################################################## +% if generate_options in ['qt_gui','hb_qt_gui']: +from PyQt5 import Qt +from gnuradio import qtgui +%endif +% for imp in imports: +##${imp.replace(" # grc-generated hier_block", "")} +${imp} +% endfor + +######################################################## +##Prepare snippets +######################################################## +% for snip in flow_graph.get_snippets_dict(): + +${indent(snip['def'])} +% for line in snip['lines']: + ${indent(line)} +% endfor +% endfor +\ +<% +snippet_sections = ['main_after_init', 'main_after_start', 'main_after_stop', 'init_before_blocks'] +snippets = {} +for section in snippet_sections: + snippets[section] = flow_graph.get_snippets_dict(section) +%> +\ +%for section in snippet_sections: +%if snippets[section]: + +def snippets_${section}(tb): + % for snip in snippets[section]: + ${indent(snip['call'])} + % endfor +%endif +%endfor + +######################################################## +##Create Class +## Write the class declaration for a top or hier block. +## The parameter names are the arguments to __init__. +## Setup the IO signature (hier block only). +######################################################## +<% + class_name = flow_graph.get_option('id') + param_str = ', '.join(['self'] + ['%s=%s'%(param.name, param.templates.render('make')) for param in parameters]) +%>\ +% if generate_options == 'qt_gui': +class ${class_name}(gr.top_block, Qt.QWidget): + + def __init__(${param_str}): + gr.top_block.__init__(self, "${title}", catch_exceptions=${catch_exceptions}) + Qt.QWidget.__init__(self) + self.setWindowTitle("${title}") + qtgui.util.check_set_qss() + try: + self.setWindowIcon(Qt.QIcon.fromTheme('gnuradio-grc')) + except BaseException as exc: + print(f"Qt GUI: Could not set Icon: {str(exc)}", file=sys.stderr) + self.top_scroll_layout = Qt.QVBoxLayout() + self.setLayout(self.top_scroll_layout) + self.top_scroll = Qt.QScrollArea() + self.top_scroll.setFrameStyle(Qt.QFrame.NoFrame) + self.top_scroll_layout.addWidget(self.top_scroll) + self.top_scroll.setWidgetResizable(True) + self.top_widget = Qt.QWidget() + self.top_scroll.setWidget(self.top_widget) + self.top_layout = Qt.QVBoxLayout(self.top_widget) + self.top_grid_layout = Qt.QGridLayout() + self.top_layout.addLayout(self.top_grid_layout) + + self.settings = Qt.QSettings("gnuradio/flowgraphs", "${class_name}") + + try: + geometry = self.settings.value("geometry") + if geometry: + self.restoreGeometry(geometry) + except BaseException as exc: + print(f"Qt GUI: Could not restore geometry: {str(exc)}", file=sys.stderr) +% elif generate_options == 'bokeh_gui': + +class ${class_name}(gr.top_block): + def __init__(${param_str}): + gr.top_block.__init__(self, "${title}", catch_exceptions=${catch_exceptions}) + self.plot_lst = [] + self.widget_lst = [] +% elif generate_options == 'no_gui': + +class ${class_name}(gr.top_block): + + def __init__(${param_str}): + gr.top_block.__init__(self, "${title}", catch_exceptions=${catch_exceptions}) +% elif generate_options.startswith('hb'): + <% in_sigs = flow_graph.get_hier_block_stream_io('in') %> + <% out_sigs = flow_graph.get_hier_block_stream_io('out') %> + + +% if generate_options == 'hb_qt_gui': +class ${class_name}(gr.hier_block2, Qt.QWidget): +% else: +class ${class_name}(gr.hier_block2): +% endif +<%def name="make_io_sig(io_sigs)">\ + <% size_strs = ['%s*%s'%(io_sig['size'], io_sig['vlen']) for io_sig in io_sigs] %>\ + % if len(io_sigs) == 0: +gr.io_signature(0, 0, 0)\ + % elif len(io_sigs) == 1: +gr.io_signature(1, 1, ${size_strs[0]})\ + % else: +gr.io_signature.makev(${len(io_sigs)}, ${len(io_sigs)}, [${', '.join(size_strs)}])\ + % endif +\ + def __init__(${param_str}): + gr.hier_block2.__init__( + self, "${ title }", + ${make_io_sig(in_sigs)}, + ${make_io_sig(out_sigs)}, + ) + % for pad in flow_graph.get_hier_block_message_io('in'): + self.message_port_register_hier_in("${ pad['label'] }") + % endfor + % for pad in flow_graph.get_hier_block_message_io('out'): + self.message_port_register_hier_out("${ pad['label'] }") + % endfor + % if generate_options == 'hb_qt_gui': + + Qt.QWidget.__init__(self) + self.top_layout = Qt.QVBoxLayout() + self.top_grid_layout = Qt.QGridLayout() + self.top_layout.addLayout(self.top_grid_layout) + self.setLayout(self.top_layout) + % endif +% endif +% if flow_graph.get_option('thread_safe_setters'): + + self._lock = threading.RLock() +% endif +% if not generate_options.startswith('hb'): + self.flowgraph_started = threading.Event() +% endif +######################################################## +##Create Parameters +## Set the parameter to a property of self. +######################################################## +% if parameters: + + ${'##################################################'} + # Parameters + ${'##################################################'} +% endif +% for param in parameters: + ${indent(param.get_var_make())} +% endfor +######################################################## +##Create Variables +######################################################## +% if variables: + + ${'##################################################'} + # Variables + ${'##################################################'} +% endif +% for var in variables: + ${indent(var.templates.render('var_make'))} +% endfor + % if blocks: + + ${'##################################################'} + # Blocks + ${'##################################################'} + % endif + ${'snippets_init_before_blocks(self)' if snippets['init_before_blocks'] else ''} + % for blk, blk_make in blocks: + % if blk_make: + ${ indent(blk_make.strip('\n')) } + % endif + % if 'alias' in blk.params and blk.params['alias'].get_evaluated(): + self.${blk.name}.set_block_alias("${blk.params['alias'].get_evaluated()}") + % endif + % if 'affinity' in blk.params and blk.params['affinity'].get_evaluated(): + self.${blk.name}.set_processor_affinity(${blk.params['affinity'].to_code()}) + % endif + % if len(blk.sources) > 0 and 'minoutbuf' in blk.params and int(blk.params['minoutbuf'].get_evaluated()) > 0: + self.${blk.name}.set_min_output_buffer(${blk.params['minoutbuf'].to_code()}) + % endif + % if len(blk.sources) > 0 and 'maxoutbuf' in blk.params and int(blk.params['maxoutbuf'].get_evaluated()) > 0: + self.${blk.name}.set_max_output_buffer(${blk.params['maxoutbuf'].to_code()}) + % endif + % endfor + + % if connections: + + ${'##################################################'} + # Connections + ${'##################################################'} + % for connection in connections: + ${ indent(connection.rstrip()) } + % endfor + % endif + +######################################################## +## QT sink close method reimplementation +######################################################## +% if generate_options == 'qt_gui': + + def closeEvent(self, event): + self.settings = Qt.QSettings("gnuradio/flowgraphs", "${class_name}") + self.settings.setValue("geometry", self.saveGeometry()) + self.stop() + self.wait() + ${'snippets_main_after_stop(self)' if snippets['main_after_stop'] else ''} + event.accept() + % if flow_graph.get_option('qt_qss_theme'): + + def setStyleSheetFromFile(self, filename): + try: + if not os.path.exists(filename): + filename = os.path.join( + gr.prefix(), "share", "gnuradio", "themes", filename) + with open(filename) as ss: + self.setStyleSheet(ss.read()) + except Exception as e: + self.logger.error(f"setting stylesheet: {str(e)}") + % endif +% endif +## +## +## +## Create Callbacks +## Write a set method for this variable that calls the callbacks +######################################################## + % for var in parameters + variables: + + def get_${ var.name }(self): + return self.${ var.name } + + def set_${ var.name }(self, ${ var.name }): + % if flow_graph.get_option('thread_safe_setters'): + with self._lock: + self.${ var.name } = ${ var.name } + % for callback in callbacks[var.name]: + ${ indent(callback) } + % endfor + % else: + self.${ var.name } = ${ var.name } + % for callback in callbacks[var.name]: + ${ indent(callback) } + % endfor + % endif + % endfor +\ + +######################################################## +##Create Main +## For top block code, generate a main routine. +## Instantiate the top block and run as gui or cli. +######################################################## +% if not generate_options.startswith('hb'): +<% params_eq_list = list() %> +% if parameters: + +<% arg_parser_args = '' %>\ +def argument_parser(): + % if flow_graph.get_option('description'): + <% + arg_parser_args = 'description=description' + %>description = ${repr(flow_graph.get_option('description'))} + % endif + parser = ArgumentParser(${arg_parser_args}) + % for param in parameters: +<% + switches = ['"--{}"'.format(param.name.replace('_', '-'))] + short_id = param.params['short_id'].get_value() + if short_id: + switches.insert(0, '"-{}"'.format(short_id)) + + type_ = param.params['type'].get_value() + if type_: + params_eq_list.append('%s=options.%s' % (param.name, param.name)) + + default = param.templates.render('make') + if type_ == 'eng_float': + default = "eng_notation.num_to_str(float(" + default + "))" + # FIXME: + if type_ == 'string': + type_ = 'str' + %>\ + % if type_: + parser.add_argument( + ${ ', '.join(switches) }, dest="${param.name}", type=${type_}, default=${ default }, + help="Set ${param.params['label'].get_evaluated() or param.name} [default=%(default)r]") + % endif + % endfor + return parser +% endif + + +def main(top_block_cls=${class_name}, options=None): + % if parameters: + if options is None: + options = argument_parser().parse_args() + % endif + % if flow_graph.get_option('realtime_scheduling'): + if gr.enable_realtime_scheduling() != gr.RT_OK: + gr.logger("realtime").warn("Error: failed to enable real-time scheduling.") + % endif + % if generate_options == 'qt_gui': + + qapp = Qt.QApplication(sys.argv) + + tb = top_block_cls(${ ', '.join(params_eq_list) }) + ${'snippets_main_after_init(tb)' if snippets['main_after_init'] else ''} + % if flow_graph.get_option('run'): + tb.start(${flow_graph.get_option('max_nouts') or ''}) + tb.flowgraph_started.set() + % endif + ${'snippets_main_after_start(tb)' if snippets['main_after_start'] else ''} + % if flow_graph.get_option('qt_qss_theme'): + tb.setStyleSheetFromFile("${ flow_graph.get_option('qt_qss_theme') }") + % endif + tb.show() + + def sig_handler(sig=None, frame=None): + tb.stop() + tb.wait() + ${'snippets_main_after_stop(tb)' if snippets['main_after_stop'] else ''} + Qt.QApplication.quit() + + signal.signal(signal.SIGINT, sig_handler) + signal.signal(signal.SIGTERM, sig_handler) + + timer = Qt.QTimer() + timer.start(500) + timer.timeout.connect(lambda: None) + + % for m in monitors: + % if m.params['en'].get_value() == 'True': + tb.${m.name}.start() + % endif + % endfor + qapp.exec_() + % elif generate_options == 'bokeh_gui': + # Create Top Block instance + tb = top_block_cls(${ ', '.join(params_eq_list) }) + ${'snippets_main_after_init(tb)' if snippets['main_after_init'] else ''} + try: + tb.start() + tb.flowgraph_started.set() + ${'snippets_main_after_start(tb)' if snippets['main_after_start'] else ''} + bokehgui.utils.run_server(tb, sizing_mode = "${flow_graph.get_option('sizing_mode')}", widget_placement = ${flow_graph.get_option('placement')}, window_size = ${flow_graph.get_option('window_size')}) + finally: + tb.logger.info("Exiting the simulation. Stopping Bokeh Server") + tb.stop() + tb.wait() + ${'snippets_main_after_stop(tb)' if snippets['main_after_stop'] else ''} + % elif generate_options == 'no_gui': + tb = top_block_cls(${ ', '.join(params_eq_list) }) + ${'snippets_main_after_init(tb)' if snippets['main_after_init'] else ''} + def sig_handler(sig=None, frame=None): + % for m in monitors: + % if m.params['en'].get_value() == 'True': + tb.${m.name}.stop() + % endif + % endfor + tb.stop() + tb.wait() + ${'snippets_main_after_stop(tb)' if snippets['main_after_stop'] else ''} + sys.exit(0) + + signal.signal(signal.SIGINT, sig_handler) + signal.signal(signal.SIGTERM, sig_handler) + + % if flow_graph.get_option('run_options') == 'prompt': + tb.start(${ flow_graph.get_option('max_nouts') or '' }) + tb.flowgraph_started.set() + ${'snippets_main_after_start(tb)' if snippets['main_after_start'] else ''} + % for m in monitors: + % if m.params['en'].get_value() == 'True': + tb.${m.name}.start() + % endif + % endfor + try: + input('Press Enter to quit: ') + except EOFError: + pass + tb.stop() + ## ${'snippets_main_after_stop(tb)' if snippets['main_after_stop'] else ''} + % elif flow_graph.get_option('run_options') == 'run': + tb.start(${flow_graph.get_option('max_nouts') or ''}) + tb.flowgraph_started.set() + ${'snippets_main_after_start(tb)' if snippets['main_after_start'] else ''} + % for m in monitors: + % if m.params['en'].get_value() == 'True': + tb.${m.name}.start() + % endif + % endfor + % endif + tb.wait() + ${'snippets_main_after_stop(tb)' if snippets['main_after_stop'] else ''} + % for m in monitors: + % if m.params['en'].get_value() == 'True': + tb.${m.name}.stop() + % endif + % endfor + % endif + +if __name__ == '__main__': + main() +% endif diff --git a/grc/core/generator/hier_block.py b/grc/core/generator/hier_block.py new file mode 100644 index 0000000..c3d0d2f --- /dev/null +++ b/grc/core/generator/hier_block.py @@ -0,0 +1,186 @@ +import collections +import os + +import codecs + +from .top_block import TopBlockGenerator + +from .. import Constants +from ..io import yaml + + +class HierBlockGenerator(TopBlockGenerator): + """Extends the top block generator to also generate a block YML file""" + + def __init__(self, flow_graph, _): + """ + Initialize the hier block generator object. + + Args: + flow_graph: the flow graph object + output_dir: the path for written files + """ + platform = flow_graph.parent + output_dir = platform.config.hier_block_lib_dir + if not os.path.exists(output_dir): + os.mkdir(output_dir) + + TopBlockGenerator.__init__(self, flow_graph, output_dir) + self._mode = Constants.HIER_BLOCK_FILE_MODE + self.file_path_yml = self.file_path[:-3] + '.block.yml' + + def write(self): + """generate output and write it to files""" + TopBlockGenerator.write(self) + + data = yaml.dump(self._build_block_n_from_flow_graph_io()) + + replace = [ + ('parameters:', '\nparameters:'), + ('inputs:', '\ninputs:'), + ('outputs:', '\noutputs:'), + ('asserts:', '\nasserts:'), + ('templates:', '\ntemplates:'), + ('documentation:', '\ndocumentation:'), + ('file_format:', '\nfile_format:'), + ] + for r in replace: + data = data.replace(*r) + + with codecs.open(self.file_path_yml, 'w', encoding='utf-8') as fp: + fp.write(data) + + # Windows only supports S_IREAD and S_IWRITE, other flags are ignored + os.chmod(self.file_path_yml, self._mode) + + def _build_block_n_from_flow_graph_io(self): + """ + Generate a block YML nested data from the flow graph IO + + Returns: + a yml node tree + """ + # Extract info from the flow graph + block_id = self._flow_graph.get_option('id') + parameters = self._flow_graph.get_parameters() + + def var_or_value(name): + if name in (p.name for p in parameters): + return "${" + name + " }" + return name + + # Build the nested data + data = collections.OrderedDict() + data['id'] = block_id + data['label'] = ( + self._flow_graph.get_option('title') or + self._flow_graph.get_option('id').replace('_', ' ').title() + ) + data['category'] = self._flow_graph.get_option('category') + + # Parameters + data['parameters'] = [] + for param_block in parameters: + p = collections.OrderedDict() + p['id'] = param_block.name + p['label'] = param_block.params['label'].get_value() or param_block.name + p['dtype'] = param_block.params['value'].dtype + p['default'] = param_block.params['value'].get_value() + p['hide'] = param_block.params['hide'].get_value() + data['parameters'].append(p) + + # Ports + for direction in ('inputs', 'outputs'): + data[direction] = [] + for port in get_hier_block_io(self._flow_graph, direction): + p = collections.OrderedDict() + p['label'] = port.parent.params['label'].value + if port.domain != Constants.DEFAULT_DOMAIN: + p['domain'] = port.domain + p['dtype'] = port.dtype + if port.domain != Constants.GR_MESSAGE_DOMAIN: + p['vlen'] = var_or_value(port.vlen) + if port.optional: + p['optional'] = True + data[direction].append(p) + + t = data['templates'] = collections.OrderedDict() + + t['imports'] = "from {0} import {0} # grc-generated hier_block".format( + self._flow_graph.get_option('id')) + # Make data + if parameters: + t['make'] = '{cls}(\n {kwargs},\n)'.format( + cls=block_id, + kwargs=',\n '.join( + '{key}=${{ {key} }}'.format(key=param.name) for param in parameters + ), + ) + else: + t['make'] = '{cls}()'.format(cls=block_id) + # Self-connect if there aren't any ports + if not data['inputs'] and not data['outputs']: + t['make'] += '\nself.connect(self.${id})' + + # Callback data + t['callbacks'] = [ + 'set_{key}(${{ {key} }})'.format(key=param_block.name) for param_block in parameters + ] + + # Documentation + data['documentation'] = "\n".join(field for field in ( + self._flow_graph.get_option('author'), + self._flow_graph.get_option('description'), + self.file_path + ) if field) + data['grc_source'] = str(self._flow_graph.grc_file_path) + + data['file_format'] = 1 + + return data + + +class QtHierBlockGenerator(HierBlockGenerator): + + def _build_block_n_from_flow_graph_io(self): + n = HierBlockGenerator._build_block_n_from_flow_graph_io(self) + block_n = collections.OrderedDict() + + # insert flags after category + for key, value in n.items(): + block_n[key] = value + if key == 'category': + block_n['flags'] = 'need_qt_gui' + + if not block_n['label'].upper().startswith('QT GUI'): + block_n['label'] = 'QT GUI ' + block_n['label'] + + gui_hint_param = collections.OrderedDict() + gui_hint_param['id'] = 'gui_hint' + gui_hint_param['label'] = 'GUI Hint' + gui_hint_param['dtype'] = 'gui_hint' + gui_hint_param['hide'] = 'part' + block_n['parameters'].append(gui_hint_param) + + block_n['templates']['make'] += ( + "\n<% win = 'self.%s'%id %>" + "\n${ gui_hint() % win }" + ) + + return block_n + + +def get_hier_block_io(flow_graph, direction, domain=None): + """ + Get a list of io ports for this flow graph. + + Returns a list of blocks + """ + pads = flow_graph.get_pad_sources( + ) if direction == 'inputs' else flow_graph.get_pad_sinks() + + for pad in pads: + for port in (pad.sources if direction == 'inputs' else pad.sinks): + if domain and port.domain != domain: + continue + yield port diff --git a/grc/core/generator/top_block.py b/grc/core/generator/top_block.py new file mode 100644 index 0000000..108d86a --- /dev/null +++ b/grc/core/generator/top_block.py @@ -0,0 +1,386 @@ +import codecs +import operator +import os +import tempfile +import textwrap + +from mako.template import Template + +from .. import Messages, blocks +from ..Constants import TOP_BLOCK_FILE_MODE +from .FlowGraphProxy import FlowGraphProxy +from ..utils import expr_utils + +DATA_DIR = os.path.dirname(__file__) + +PYTHON_TEMPLATE = os.path.join(DATA_DIR, 'flow_graph.py.mako') + +python_template = Template(filename=PYTHON_TEMPLATE) + + +class TopBlockGenerator(object): + + def __init__(self, flow_graph, output_dir): + """ + Initialize the top block generator object. + + Args: + flow_graph: the flow graph object + output_dir: the path for written files + """ + + self._flow_graph = FlowGraphProxy(flow_graph) + self._generate_options = self._flow_graph.get_option( + 'generate_options') + + self._mode = TOP_BLOCK_FILE_MODE + # Handle the case where the directory is read-only + # In this case, use the system's temp directory + if not os.access(output_dir, os.W_OK): + output_dir = tempfile.gettempdir() + filename = self._flow_graph.get_option('id') + '.py' + self.file_path = os.path.join(output_dir, filename) + self.output_dir = output_dir + + def _warnings(self): + throttling_blocks = [b for b in self._flow_graph.get_enabled_blocks() + if b.flags.throttle] + if not throttling_blocks and not self._generate_options.startswith('hb'): + Messages.send_warning("This flow graph may not have flow control: " + "no audio or RF hardware blocks found. " + "Add a Misc->Throttle block to your flow " + "graph to avoid CPU congestion.") + if len(throttling_blocks) > 1: + keys = set([b.key for b in throttling_blocks]) + if len(keys) > 1 and 'blocks_throttle' in keys: + Messages.send_warning("This flow graph contains a throttle " + "block and another rate limiting block, " + "e.g. a hardware source or sink. " + "This is usually undesired. Consider " + "removing the throttle block.") + + deprecated_block_keys = { + b.name for b in self._flow_graph.get_enabled_blocks() if b.flags.deprecated} + for key in deprecated_block_keys: + Messages.send_warning("The block {!r} is deprecated.".format(key)) + + def write(self): + """generate output and write it to files""" + self._warnings() + + fg = self._flow_graph + self.title = fg.get_option('title') or fg.get_option( + 'id').replace('_', ' ').title() + variables = fg.get_variables() + parameters = fg.get_parameters() + monitors = fg.get_monitors() + + self.namespace = { + 'flow_graph': fg, + 'variables': variables, + 'parameters': parameters, + 'monitors': monitors, + 'generate_options': self._generate_options, + } + + for filename, data in self._build_python_code_from_template(): + with codecs.open(filename, 'w', encoding='utf-8') as fp: + fp.write(data) + if filename == self.file_path: + os.chmod(filename, self._mode) + + def _build_python_code_from_template(self): + """ + Convert the flow graph to python code. + + Returns: + a string of python code + """ + output = [] + + fg = self._flow_graph + platform = fg.parent + title = fg.get_option('title') or fg.get_option( + 'id').replace('_', ' ').title() + variables = fg.get_variables() + parameters = fg.get_parameters() + monitors = fg.get_monitors() + + for block in fg.iter_enabled_blocks(): + if block.key == 'epy_block': + src = block.params['_source_code'].get_value() + elif block.key == 'epy_module': + src = block.params['source_code'].get_value() + else: + continue + + file_path = os.path.join( + self.output_dir, block.module_name + ".py") + output.append((file_path, src)) + + self.namespace = { + 'flow_graph': fg, + 'variables': variables, + 'parameters': parameters, + 'monitors': monitors, + 'generate_options': self._generate_options, + 'version': platform.config.version, + 'catch_exceptions': fg.get_option('catch_exceptions') + } + flow_graph_code = python_template.render( + title=title, + imports=self._imports(), + blocks=self._blocks(), + callbacks=self._callbacks(), + connections=self._connections(), + **self.namespace + ) + # strip trailing white-space + flow_graph_code = "\n".join(line.rstrip() + for line in flow_graph_code.split("\n")) + output.append((self.file_path, flow_graph_code)) + + return output + + def _imports(self): + fg = self._flow_graph + imports = fg.imports() + seen = set() + output = [] + + need_path_hack = any(imp.endswith( + "# grc-generated hier_block") for imp in imports) + if need_path_hack: + output.insert(0, textwrap.dedent("""\ + import os + import sys + import logging as log + + def get_state_directory() -> str: + oldpath = os.path.expanduser("~/.grc_gnuradio") + try: + from gnuradio.gr import paths + newpath = paths.persistent() + if os.path.exists(newpath): + return newpath + if os.path.exists(oldpath): + log.warning(f"Found persistent state path '{newpath}', but file does not exist. " + + f"Old default persistent state path '{oldpath}' exists; using that. " + + "Please consider moving state to new location.") + return oldpath + # Default to the correct path if both are configured. + # neither old, nor new path exist: create new path, return that + os.makedirs(newpath, exist_ok=True) + return newpath + except (ImportError, NameError): + log.warning("Could not retrieve GNU Radio persistent state directory from GNU Radio. " + + "Trying defaults.") + xdgstate = os.getenv("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + xdgcand = os.path.join(xdgstate, "gnuradio") + if os.path.exists(xdgcand): + return xdgcand + if os.path.exists(oldpath): + log.warning(f"Using legacy state path '{oldpath}'. Please consider moving state " + + f"files to '{xdgcand}'.") + return oldpath + # neither old, nor new path exist: create new path, return that + os.makedirs(xdgcand, exist_ok=True) + return xdgcand + + sys.path.append(os.environ.get('GRC_HIER_PATH', get_state_directory())) + """)) + seen.add('import os') + seen.add('import sys') + + if fg.get_option('qt_qss_theme'): + imports.append('import os') + imports.append('import sys') + + # Used by thread_safe_setters and startup Event + imports.append('import threading') + + def is_duplicate(l): + if (l.startswith('import') or l.startswith('from')) and l in seen: + return True + seen.add(line) + return False + + for import_ in sorted(imports): + lines = import_.strip().split('\n') + if not lines[0]: + continue + for line in lines: + line = line.rstrip() + if not is_duplicate(line): + output.append(line) + + return output + + def _blocks(self): + """ + Returns a list of tuples: (block, block_make) + + 'block' contains a reference to the block object. + 'block_make' contains the pre-rendered string for the 'make' part of the + block. + """ + fg = self._flow_graph + parameters = fg.get_parameters() + + # List of blocks not including variables and imports and parameters and disabled + def _get_block_sort_text(block): + code = block.templates.render('make').replace(block.name, ' ') + try: + # Newer gui markup w/ qtgui + code += block.params['gui_hint'].get_value() + except KeyError: + # No gui hint + pass + return code + + blocks = [ + b for b in fg.blocks + if b.enabled and not (b.get_bypassed() or b.is_import or b.is_snippet or b in parameters or b.key == 'options') + ] + + blocks = expr_utils.sort_objects( + blocks, operator.attrgetter('name'), _get_block_sort_text) + blocks_make = [] + for block in blocks: + make = block.templates.render('make') + if make: + if not (block.is_variable or block.is_virtual_or_pad): + make = 'self.' + block.name + ' = ' + make + blocks_make.append((block, make)) + return blocks_make + + def _callbacks(self): + fg = self._flow_graph + variables = fg.get_variables() + parameters = fg.get_parameters() + + # List of variable names + var_ids = [var.name for var in parameters + variables] + + replace_dict = dict((var_id, 'self.' + var_id) for var_id in var_ids) + + callbacks_all = [] + for block in fg.iter_enabled_blocks(): + callbacks_all.extend(expr_utils.expr_replace( + cb, replace_dict) for cb in block.get_callbacks()) + + # Map var id to callbacks + def uses_var_id(callback): + used = expr_utils.get_variable_dependencies(callback, [var_id]) + # callback might contain var_id itself + return used and (('self.' + var_id in callback) or ('this->' + var_id in callback)) + + callbacks = {} + for var_id in var_ids: + callbacks[var_id] = [ + callback for callback in callbacks_all if uses_var_id(callback)] + + return callbacks + + def _connections(self): + fg = self._flow_graph + templates = {key: Template(text) + for key, text in fg.parent_platform.connection_templates.items()} + + def make_port_sig(port): + # TODO: make sense of this + if port.parent.key in ('pad_source', 'pad_sink'): + block = 'self' + key = fg.get_pad_port_global_key(port) + else: + block = 'self.' + port.parent_block.name + key = port.key + + if not key.isdigit(): + key = repr(key) + + return '({block}, {key})'.format(block=block, key=key) + + connections = fg.get_enabled_connections() + + # Get the virtual blocks and resolve their connections + connection_factory = fg.parent_platform.Connection + virtual_source_connections = [c for c in connections if isinstance( + c.source_block, blocks.VirtualSource)] + for connection in virtual_source_connections: + sink = connection.sink_port + for source in connection.source_port.resolve_virtual_source(): + resolved = connection_factory( + fg.orignal_flowgraph, source, sink) + connections.append(resolved) + + virtual_connections = [c for c in connections if (isinstance( + c.source_block, blocks.VirtualSource) or isinstance(c.sink_block, blocks.VirtualSink))] + for connection in virtual_connections: + # Remove the virtual connection + connections.remove(connection) + + # Bypassing blocks: Need to find all the enabled connections for the block using + # the *connections* object rather than get_connections(). Create new connections + # that bypass the selected block and remove the existing ones. This allows adjacent + # bypassed blocks to see the newly created connections to downstream blocks, + # allowing them to correctly construct bypass connections. + bypassed_blocks = fg.get_bypassed_blocks() + for block in bypassed_blocks: + # Get the upstream connection (off of the sink ports) + # Use *connections* not get_connections() + source_connection = [ + c for c in connections if c.sink_port == block.sinks[0]] + # The source connection should never have more than one element. + assert (len(source_connection) == 1) + + # Get the source of the connection. + source_port = source_connection[0].source_port + + # Loop through all the downstream connections + for sink in (c for c in connections if c.source_port == block.sources[0]): + if not sink.enabled: + # Ignore disabled connections + continue + connection = connection_factory( + fg.orignal_flowgraph, source_port, sink.sink_port) + connections.append(connection) + # Remove this sink connection + connections.remove(sink) + # Remove the source connection + connections.remove(source_connection[0]) + + # List of connections where each endpoint is enabled (sorted by domains, block names) + def by_domain_and_blocks(c): + return c.type, c.source_block.name, c.sink_block.name + + rendered = [] + for con in sorted(connections, key=by_domain_and_blocks): + template = templates[con.type] + if con.source_port.dtype != 'bus': + code = template.render( + make_port_sig=make_port_sig, + source=con.source_port, + sink=con.sink_port, + **con.namespace_templates) + rendered.append(code) + else: + # Bus ports need to iterate over the underlying connections and then render + # the code for each subconnection + porta = con.source_port + portb = con.sink_port + + if porta.dtype == 'bus' and portb.dtype == 'bus': + # which bus port is this relative to the bus structure + if len(porta.bus_structure) == len(portb.bus_structure): + for port_num_a, port_num_b in zip(porta.bus_structure, portb.bus_structure): + hidden_porta = porta.parent.sources[port_num_a] + hidden_portb = portb.parent.sinks[port_num_b] + code = template.render( + make_port_sig=make_port_sig, + source=hidden_porta, + sink=hidden_portb, + **con.namespace_templates) + rendered.append(code) + + return rendered diff --git a/grc/core/io/__init__.py b/grc/core/io/__init__.py new file mode 100644 index 0000000..018ed9a --- /dev/null +++ b/grc/core/io/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# diff --git a/grc/core/io/yaml.py b/grc/core/io/yaml.py new file mode 100644 index 0000000..c616a2e --- /dev/null +++ b/grc/core/io/yaml.py @@ -0,0 +1,89 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from collections import OrderedDict + +import yaml + +from ..params.param import attributed_str + + +class GRCDumper(yaml.SafeDumper): + @classmethod + def add(cls, data_type): + def decorator(func): + cls.add_representer(data_type, func) + return func + return decorator + + def represent_ordered_mapping(self, data): + value = [] + node = yaml.MappingNode(u'tag:yaml.org,2002:map', + value, flow_style=False) + + if self.alias_key is not None: + self.represented_objects[self.alias_key] = node + + for item_key, item_value in data.items(): + node_key = self.represent_data(item_key) + node_value = self.represent_data(item_value) + value.append((node_key, node_value)) + + return node + + def represent_ordered_mapping_flowing(self, data): + node = self.represent_ordered_mapping(data) + node.flow_style = True + return node + + def represent_list_flowing(self, data): + node = self.represent_list(data) + node.flow_style = True + return node + + def represent_ml_string(self, data): + node = self.represent_str(data) + node.style = '|' + return node + + +class OrderedDictFlowing(OrderedDict): + pass + + +class ListFlowing(list): + pass + + +class MultiLineString(str): + pass + + +GRCDumper.add_representer(OrderedDict, GRCDumper.represent_ordered_mapping) +GRCDumper.add_representer( + OrderedDictFlowing, GRCDumper.represent_ordered_mapping_flowing) +GRCDumper.add_representer(ListFlowing, GRCDumper.represent_list_flowing) +GRCDumper.add_representer(tuple, GRCDumper.represent_list) +GRCDumper.add_representer(MultiLineString, GRCDumper.represent_ml_string) +GRCDumper.add_representer(yaml.nodes.ScalarNode, lambda r, n: n) +GRCDumper.add_representer(attributed_str, GRCDumper.represent_str) + + +def dump(data, stream=None, **kwargs): + config = dict(stream=stream, default_flow_style=False, + indent=4, Dumper=GRCDumper) + config.update(kwargs) + return yaml.dump_all([data], **config) + + +if yaml.__with_libyaml__: + loader = yaml.CSafeLoader +else: + loader = yaml.SafeLoader + + +def safe_load(stream): return yaml.load(stream, Loader=loader) diff --git a/grc/core/params/__init__.py b/grc/core/params/__init__.py new file mode 100644 index 0000000..a43cf0a --- /dev/null +++ b/grc/core/params/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +from .param import Param diff --git a/grc/core/params/dtypes.py b/grc/core/params/dtypes.py new file mode 100644 index 0000000..097eee3 --- /dev/null +++ b/grc/core/params/dtypes.py @@ -0,0 +1,128 @@ +# Copyright 2008-2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import re +import builtins +import keyword +from typing import List, Callable + +from .. import blocks +from .. import Constants + + +# Blacklist certain ids, its not complete, but should help +ID_BLACKLIST = {'self', 'gnuradio'} | set(dir(builtins)) | set(keyword.kwlist) +# Python >= 3.10 has soft keywords in a list, i.e. words that are reserved in +# specific contexts, e.g. `match` isn't generally a keyword, but in `case` it is. +ID_BLACKLIST |= set(getattr(keyword, 'softkwlist', set())) + +try: + from gnuradio import gr + ID_BLACKLIST |= {attr for attr in dir( + gr.top_block()) if not attr.startswith('_')} +except (ImportError, AttributeError): + pass + + +validators = {} + + +def validates(*dtypes) -> Callable: + """ + Registers a function as validator for the type of element give as strings + """ + def decorator(func): + for dtype in dtypes: + assert dtype in Constants.PARAM_TYPE_NAMES + validators[dtype] = func + return func + return decorator + + +class ValidateError(Exception): + """Raised by validate functions""" + + +@validates('id') +def validate_block_id(param, black_listed_ids: List[str]) -> None: + value = param.value + # Can python use this as a variable? + if not re.match(r'^[a-z|A-Z]\w*$', value): + raise ValidateError('ID "{}" must begin with a letter and may contain letters, numbers, ' + 'and underscores.'.format(value)) + if (value in ID_BLACKLIST or value in black_listed_ids) \ + and not getattr(param.parent_block, 'exempt_from_id_validation', False): + # Grant blacklist exemption to epy blocks and modules + raise ValidateError('ID "{}" is blacklisted.'.format(value)) + block_names = [ + block.name for block in param.parent_flowgraph.iter_enabled_blocks()] + # Id should only appear once, or zero times if block is disabled + if param.key == 'id' and block_names.count(value) > 1: + raise ValidateError('ID "{}" is not unique.'.format(value)) + elif value not in block_names: + raise ValidateError('ID "{}" does not exist.'.format(value)) + + +@validates('name') +def validate_name(param, _) -> None: + # Name of a function or other block that will be generated literally not as a string + value = param.value + # Allow blank to pass validation + # Can python use this as a variable? + if not re.match(r'^([a-z|A-Z]\w*)?$', value): + raise ValidateError('ID "{}" must begin with a letter and may contain letters, numbers, ' + 'and underscores.'.format(value)) + + +@validates('stream_id') +def validate_stream_id(param, _) -> None: + value = param.value + stream_ids = [ + block.params['stream_id'].value + for block in param.parent_flowgraph.iter_enabled_blocks() + if isinstance(block, blocks.VirtualSink) + ] + # Check that the virtual sink's stream id is unique + if isinstance(param.parent_block, blocks.VirtualSink) and stream_ids.count(value) >= 2: + # Id should only appear once, or zero times if block is disabled + raise ValidateError('Stream ID "{}" is not unique.'.format(value)) + # Check that the virtual source's steam id is found + elif isinstance(param.parent_block, blocks.VirtualSource) and value not in stream_ids: + raise ValidateError('Stream ID "{}" is not found.'.format(value)) + + +@validates('complex', 'real', 'float', 'int') +def validate_scalar(param, _) -> None: + valid_types = Constants.PARAM_TYPE_MAP[param.dtype] + if not isinstance(param.get_evaluated(), valid_types): + raise ValidateError('Expression {!r} is invalid for type {!r}.'.format( + param.get_evaluated(), param.dtype)) + + +@validates('complex_vector', 'real_vector', 'float_vector', 'int_vector') +def validate_vector(param, _) -> None: + # todo: check vector types + + if param.get_evaluated() is None: + raise ValidateError('Expression {!r} is invalid for type{!r}.'.format( + param.get_evaluated(), param.dtype)) + + valid_types = Constants.PARAM_TYPE_MAP[param.dtype.split('_', 1)[0]] + if not all(isinstance(item, valid_types) for item in param.get_evaluated()): + raise ValidateError('Expression {!r} is invalid for type {!r}.'.format( + param.get_evaluated(), param.dtype)) + + +@validates('gui_hint') +def validate_gui_hint(param, _) -> None: + try: + # Only parse the param if there are no errors + if len(param.get_error_messages()) > 0: + return + param.parse_gui_hint(param.value) + except Exception as e: + raise ValidateError(str(e)) diff --git a/grc/core/params/param.py b/grc/core/params/param.py new file mode 100644 index 0000000..8c15f63 --- /dev/null +++ b/grc/core/params/param.py @@ -0,0 +1,484 @@ +# Copyright 2008-2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import ast +import collections +import textwrap +from typing import Union, List + +from .. import Constants +from ..base import Element +from ..utils.descriptors import Evaluated, EvaluatedEnum, setup_names + +from . import dtypes +from .template_arg import TemplateArg + +attributed_str = type('attributed_str', (str,), {}) + + +@setup_names +class Param(Element): + + EvaluationType = Union[None, str, complex, float, int, bool, List[str], List[complex], List[float], List[int]] + + is_param = True + + name = Evaluated(str, default='no name') + dtype = EvaluatedEnum(Constants.PARAM_TYPE_NAMES, default='raw') + hide = EvaluatedEnum('none all part') + + # region init + def __init__(self, parent, id, label='', dtype='raw', default='', + options=None, option_labels=None, option_attributes=None, + category='', hide='none', **_): + """Make a new param from nested data""" + super(Param, self).__init__(parent) + self.key = id + self.name = 'ID' if id == 'id' else (label.strip() or id.title()) + self.category = category or Constants.DEFAULT_PARAM_TAB + + self.dtype = dtype + self.value = self.default = str(default) + + self.options = self._init_options(options or [], option_labels or [], + option_attributes or {}) + self.hide = hide or 'none' + # end of args ######################################################## + + self._evaluated: Param.EvaluationType = None + self._stringify_flag = False + self._lisitify_flag = False + self.hostage_cells = set() + self._init = False + self.scale = { + 'E': 1e18, + 'P': 1e15, + 'T': 1e12, + 'G': 1e9, + 'M': 1e6, + 'k': 1e3, + 'm': 1e-3, + 'u': 1e-6, + 'n': 1e-9, + 'p': 1e-12, + 'f': 1e-15, + 'a': 1e-18, + } + self.scale_factor = None + self.number = None + + def _init_options(self, values, labels, attributes): + """parse option and option attributes""" + options = collections.OrderedDict() + options.attributes = collections.defaultdict(dict) + + padding = [''] * max(len(values), len(labels)) + attributes = {key: value + padding for key, + value in attributes.items()} + + for i, option in enumerate(values): + # Test against repeated keys + if option in options: + raise KeyError( + 'Value "{}" already exists in options'.format(option)) + # get label + try: + label = str(labels[i]) + except IndexError: + label = str(option) + # Store the option + options[option] = label + options.attributes[option] = {attrib: values[i] + for attrib, values in attributes.items()} + + default = next(iter(options)) if options else '' + if not self.value: + self.value = self.default = default + + if self.is_enum() and self.value not in options: + self.value = self.default = default # TODO: warn + # raise ValueError('The value {!r} is not in the possible values of {}.' + # ''.format(self.get_value(), ', '.join(self.options))) + return options + # endregion + + @property + def template_arg(self): + return TemplateArg(self) + + def __str__(self): + return 'Param - {}({})'.format(self.name, self.key) + + def __repr__(self): + return '{!r}.param[{}]'.format(self.parent, self.key) + + def is_enum(self): + return self.get_raw('dtype') == 'enum' + + def get_value(self): + value = self.value + if self.is_enum() and value not in self.options: + value = self.default + self.set_value(value) + return value + + def set_value(self, value): + # Must be a string + self.value = str(value) + + def set_default(self, value): + if self.default == self.value: + self.set_value(value) + self.default = str(value) + + def rewrite(self): + Element.rewrite(self) + del self.name + del self.dtype + del self.hide + + self._evaluated = None + try: + self._evaluated = self.evaluate() + except Exception as e: + self.add_error_message(str(e)) + + rewriter = getattr(dtypes, 'rewrite_' + self.dtype, None) + if rewriter: + rewriter(self) + + def validate(self): + """ + Validate the param. + The value must be evaluated and type must a possible type. + """ + Element.validate(self) + if self.dtype not in Constants.PARAM_TYPE_NAMES: + self.add_error_message( + 'Type "{}" is not a possible type.'.format(self.dtype)) + + validator = dtypes.validators.get(self.dtype, None) + if self._init and validator: + try: + validator(self, self.parent_flowgraph.get_imported_names()) + except dtypes.ValidateError as e: + self.add_error_message(str(e)) + + def get_evaluated(self) -> EvaluationType: + return self._evaluated + + def _is_float(self, num: str) -> bool: + """ + Check if string can be converted to float. + + Returns: + bool type + """ + try: + float(num) + return True + except ValueError: + return False + + def evaluate(self) -> EvaluationType: + """ + Evaluate the value. + + Returns: + evaluated type + """ + self._init = True + self._lisitify_flag = False + self._stringify_flag = False + dtype = self.dtype + expr = self.get_value() + scale_factor = self.scale_factor + + ######################### + # ID and Enum types (not evaled) + ######################### + if dtype in ('id', 'stream_id', 'name') or self.is_enum(): + if self.options.attributes: + expr = attributed_str(expr) + for key, value in self.options.attributes[expr].items(): + setattr(expr, key, value) + return expr + + ######################### + # Numeric Types + ######################### + elif dtype in ('raw', 'complex', 'real', 'float', 'int', 'short', 'byte', 'hex', 'bool'): + if expr: + try: + if isinstance(expr, str) and self._is_float(expr[:-1]): + scale_factor = expr[-1:] + if scale_factor in self.scale: + expr = str(float(expr[:-1]) * + self.scale[scale_factor]) + value = self.parent_flowgraph.evaluate(expr) + except Exception as e: + raise Exception( + 'Value "{}" cannot be evaluated:\n{}'.format(expr, e)) + else: + value = None # No parameter value provided + if dtype == 'hex': + value = hex(value) + elif dtype == 'bool': + value = bool(value) + return value + + ######################### + # Numeric Vector Types + ######################### + elif dtype in ('complex_vector', 'real_vector', 'float_vector', 'int_vector'): + if not expr: + return [] # Turn a blank string into an empty list, so it will eval + try: + value = self.parent.parent.evaluate(expr) + except Exception as value: + raise Exception( + 'Value "{}" cannot be evaluated:\n{}'.format(expr, value)) + if not isinstance(value, Constants.VECTOR_TYPES): + self._lisitify_flag = True + value = [value] + return value + ######################### + # String Types + ######################### + elif dtype in ('string', 'file_open', 'file_save', 'dir_select', '_multiline', '_multiline_python_external'): + # Do not check if file/directory exists, that is a runtime issue + try: + # Do not evaluate multiline strings (code snippets or comments) + if dtype not in ['_multiline', '_multiline_python_external']: + value = self.parent_flowgraph.evaluate(expr) + if not isinstance(value, str): + raise Exception() + else: + value = str(expr) + except Exception: + self._stringify_flag = True + value = str(expr) + if dtype == '_multiline_python_external': + ast.parse(value) # Raises SyntaxError + return value + ######################### + # GUI Position/Hint + ######################### + elif dtype == 'gui_hint': + return self.parse_gui_hint(expr) if self.parent_block.state == 'enabled' else '' + ######################### + # Import Type + ######################### + elif dtype == 'import': + # New namespace + n = dict() + try: + exec(expr, n) + except ImportError: + raise Exception('Import "{}" failed.'.format(expr)) + except Exception: + raise Exception('Bad import syntax: "{}".'.format(expr)) + return [k for k in list(n.keys()) if str(k) != '__builtins__'] + + ######################### + else: + raise TypeError('Type "{}" not handled'.format(dtype)) + + def to_code(self): + """ + Convert the value to code. + For string and list types, check the init flag, call evaluate(). + This ensures that evaluate() was called to set the xxxify_flags. + + Returns: + a string representing the code + """ + self._init = True + value = self.get_value() + # String types + if self.dtype in ('string', 'file_open', 'file_save', 'dir_select', '_multiline', '_multiline_python_external'): + if not self._init: + self.evaluate() + return repr(value) if self._stringify_flag else value + + # Vector types + elif self.dtype in ('complex_vector', 'real_vector', 'float_vector', 'int_vector'): + if not self._init: + self.evaluate() + return '[' + value + ']' if self._lisitify_flag else value + else: + if self.dtype in ('int', 'real') and ('+' in value or '-' in value or '*' in value or '/' in value): + value = '(' + value + ')' + return value + + def get_opt(self, item): + return self.options.attributes[self.get_value()][item] + + ############################################## + # GUI Hint + ############################################## + def parse_gui_hint(self, expr: str) -> str: + """ + Parse/validate gui hint value. + + Args: + expr: gui_hint string from a block's 'gui_hint' param + + Returns: + string of python code for positioning GUI elements in pyQT + """ + self.hostage_cells.clear() + + # Parsing + if ':' in expr: + tab, pos = expr.split(':') + elif ',' in expr: + tab, pos = '', expr + else: + tab, pos = expr, '' + + if '@' in tab: + tab, index = tab.split('@') + else: + index = '0' + index = int(index) + + # Validation + def parse_pos(): + e = self.parent_flowgraph.evaluate(pos) + + if not isinstance(e, (list, tuple)) or len(e) not in (2, 4) or not all(isinstance(ei, int) for ei in e): + self.add_error_message( + 'Invalid GUI Hint entered: {e!r} (Must be a list of {{2,4}} non-negative integers).'.format(e=e)) + + if len(e) == 2: + row, col = e + row_span = col_span = 1 + else: + row, col, row_span, col_span = e + + if (row < 0) or (col < 0): + self.add_error_message( + 'Invalid GUI Hint entered: {e!r} (non-negative rows/cols only).'.format(e=e)) + + if (row_span < 1) or (col_span < 1): + self.add_error_message( + 'Invalid GUI Hint entered: {e!r} (positive row/column span required).'.format(e=e)) + + return row, col, row_span, col_span + + def validate_tab(): + tabs = (block for block in self.parent_flowgraph.iter_enabled_blocks() + if block.key == 'qtgui_tab_widget' and block.name == tab) + tab_block = next(iter(tabs), None) + if not tab_block: + self.add_error_message( + 'Invalid tab name entered: {tab} (Tab name not found).'.format(tab=tab)) + return + + tab_index_size = int(tab_block.params['num_tabs'].value) + if index >= tab_index_size: + self.add_error_message( + 'Invalid tab index entered: {tab}@{index} (Index out of range).'.format(tab=tab, index=index)) + + # Collision Detection + def collision_detection(row, col, row_span, col_span): + my_parent = '{tab}@{index}'.format(tab=tab, + index=index) if tab else 'main' + # Calculate hostage cells + for r in range(row, row + row_span): + for c in range(col, col + col_span): + self.hostage_cells.add((my_parent, (r, c))) + + for other in self._get_all_params('gui_hint'): + if other is self: + continue + collision = next( + iter(self.hostage_cells & other.hostage_cells), None) + if collision: + self.add_error_message('Block {block!r} is also using parent {parent!r}, cell {cell!r}.'.format( + block=other.parent_block.name, parent=collision[0], cell=collision[1] + )) + + # Code Generation + if tab: + validate_tab() + if not pos: + layout = '{tab}_layout_{index}'.format(tab=tab, index=index) + else: + layout = '{tab}_grid_layout_{index}'.format( + tab=tab, index=index) + else: + if not pos: + layout = 'top_layout' + else: + layout = 'top_grid_layout' + + widget = '%s' # to be fill-out in the mail template + + if pos: + row, col, row_span, col_span = parse_pos() + collision_detection(row, col, row_span, col_span) + + if self.parent_flowgraph.get_option('output_language') == 'python': + widget_str = textwrap.dedent(""" + self.{layout}.addWidget({widget}, {row}, {col}, {row_span}, {col_span}) + for r in range({row}, {row_end}): + self.{layout}.setRowStretch(r, 1) + for c in range({col}, {col_end}): + self.{layout}.setColumnStretch(c, 1) + """.strip('\n')).format( + layout=layout, widget=widget, + row=row, row_span=row_span, row_end=row + row_span, + col=col, col_span=col_span, col_end=col + col_span, + ) + elif self.parent_flowgraph.get_option('output_language') == 'cpp': + widget_str = textwrap.dedent(""" + {layout}->addWidget({widget}, {row}, {col}, {row_span}, {col_span}); + for(int r = {row};r < {row_end}; r++) + {layout}->setRowStretch(r, 1); + for(int c = {col}; c <{col_end}; c++) + {layout}->setColumnStretch(c, 1); + """.strip('\n')).format( + layout=layout, widget=widget, + row=row, row_span=row_span, row_end=row + row_span, + col=col, col_span=col_span, col_end=col + col_span, + ) + else: + widget_str = '' + + else: + if self.parent_flowgraph.get_option('output_language') == 'python': + widget_str = 'self.{layout}.addWidget({widget})'.format( + layout=layout, widget=widget) + elif self.parent_flowgraph.get_option('output_language') == 'cpp': + widget_str = '{layout}->addWidget({widget});'.format( + layout=layout, widget=widget) + else: + widget_str = '' + + return widget_str + + def _get_all_params(self, dtype, key=None) -> List[Element]: + """ + Get all the params from the flowgraph that have the given type and + optionally a given key + + Args: + dtype: the specified type + key: the key to match against + + Returns: + a list of params + """ + params = [] + for block in self.parent_flowgraph.iter_enabled_blocks(): + params.extend( + param for param in block.params.values() + if param.dtype == dtype and (key is None or key == param.name) + ) + return params diff --git a/grc/core/params/template_arg.py b/grc/core/params/template_arg.py new file mode 100644 index 0000000..53adb74 --- /dev/null +++ b/grc/core/params/template_arg.py @@ -0,0 +1,37 @@ +# Copyright 2008-2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +class TemplateArg(str): + """ + A cheetah template argument created from a param. + The str of this class evaluates to the param's to code method. + The use of this class as a dictionary (enum only) will reveal the enum opts. + The __call__ or () method can return the param evaluated to a raw python data type. + """ + + def __new__(cls, param): + value = param.to_code() + instance = str.__new__(cls, value) + setattr(instance, '_param', param) + return instance + + def __getitem__(self, item): + return str(self._param.get_opt(item)) if self._param.is_enum() else NotImplemented + + def __getattr__(self, item): + if not self._param.is_enum(): + raise AttributeError() + try: + return str(self._param.get_opt(item)) + except KeyError: + raise AttributeError() + + def __str__(self): + return str(self._param.to_code()) + + def __call__(self): + return self._param.get_evaluated() diff --git a/grc/core/platform.py b/grc/core/platform.py new file mode 100644 index 0000000..4340635 --- /dev/null +++ b/grc/core/platform.py @@ -0,0 +1,464 @@ +# Copyright 2008-2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from codecs import open +from collections import namedtuple +from collections import ChainMap +import os +import logging +from itertools import chain +from typing import Type + +from . import ( + Messages, Constants, + blocks, params, ports, errors, utils, schema_checker +) +from .blocks import Block + +from .Config import Config +from .cache import Cache +from .base import Element +from .io import yaml +from .generator import Generator +from .FlowGraph import FlowGraph +from .Connection import Connection + +logger = logging.getLogger(__name__) + + +class Platform(Element): + + def __init__(self, *args, **kwargs): + """ Make a platform for GNU Radio """ + Element.__init__(self, parent=None) + + self.config = self.Config(*args, **kwargs) + self.block_docstrings = {} + # dummy to be replaced by BlockTreeWindow + self.block_docstrings_loaded_callback = lambda: None + + self._docstring_extractor = utils.extract_docs.SubprocessLoader( + callback_query_result=self._save_docstring_extraction_result, + callback_finished=lambda: self.block_docstrings_loaded_callback() + ) + + self.blocks = self.block_classes + self.domains = {} + self.examples_dict = {} + self.connection_templates = {} + self.cpp_connection_templates = {} + self.connection_params = {} + + self._block_categories = {} + self._auto_hier_block_generate_chain = set() + + def __str__(self): + return 'Platform - {}'.format(self.config.name) + + @staticmethod + def find_file_in_paths(filename, paths, cwd): + """Checks the provided paths relative to cwd for a certain filename""" + if not os.path.isdir(cwd): + cwd = os.path.dirname(cwd) + if isinstance(paths, str): + paths = (p for p in paths.split(':') if p) + + for path in paths: + path = os.path.expanduser(path) + if not os.path.isabs(path): + path = os.path.normpath(os.path.join(cwd, path)) + file_path = os.path.join(path, filename) + if os.path.exists(os.path.normpath(file_path)): + return file_path + + def load_and_generate_flow_graph(self, file_path, out_dir=None, hier_only=False): + """Loads a flow graph from file and generates it""" + Messages.set_indent(len(self._auto_hier_block_generate_chain)) + Messages.send('>>> Loading: {}\n'.format(file_path)) + if file_path in self._auto_hier_block_generate_chain: + Messages.send(' >>> Warning: cyclic hier_block dependency\n') + return None, None + self._auto_hier_block_generate_chain.add(file_path) + try: + flow_graph = self.make_flow_graph() + flow_graph.grc_file_path = file_path + # Other, nested hier_blocks might be auto-loaded here + flow_graph.import_data(self.parse_flow_graph(file_path)) + flow_graph.rewrite() + flow_graph.validate() + if not flow_graph.is_valid(): + raise Exception('Flowgraph invalid') + if hier_only and not flow_graph.get_option('generate_options').startswith('hb'): + raise Exception('Not a hier block') + except Exception as e: + Messages.send('>>> Load Error: {}: {}\n'.format(file_path, str(e))) + Messages.send_flowgraph_error_report(flow_graph) + return None, None + finally: + self._auto_hier_block_generate_chain.discard(file_path) + Messages.set_indent(len(self._auto_hier_block_generate_chain)) + + try: + if flow_graph.get_option('generate_options').startswith('hb'): + generator = self.Generator(flow_graph, out_dir) + else: + generator = self.Generator(flow_graph, out_dir or file_path) + Messages.send('>>> Generating: {}\n'.format(generator.file_path)) + generator.write() + except Exception as e: + Messages.send( + '>>> Generate Error: {}: {}\n'.format(file_path, str(e))) + return None, None + + return flow_graph, generator.file_path + + def build_example_library(self, path=None): + self.examples = list(self._iter_files_in_example_path()) + + def build_library(self, path=None): + """load the blocks and block tree from the search paths + + path: a list of paths/files to search in or load (defaults to config) + """ + self._docstring_extractor.start() + + # Reset + self.blocks.clear() + self.domains.clear() + self.connection_templates.clear() + self.cpp_connection_templates.clear() + self._block_categories.clear() + + try: + from gnuradio.gr import paths + cache_file = os.path.join(paths.cache(), Constants.GRC_SUBDIR, Constants.CACHE_FILE_NAME) + except ImportError: + cache_file = Constants.FALLBACK_CACHE_FILE + with Cache(cache_file, version=self.config.version) as cache: + for file_path in self._iter_files_in_block_path(path): + + if file_path.endswith('.block.yml'): + loader = self.load_block_description + scheme = schema_checker.BLOCK_SCHEME + elif file_path.endswith('.domain.yml'): + loader = self.load_domain_description + scheme = schema_checker.DOMAIN_SCHEME + elif file_path.endswith('.tree.yml'): + loader = self.load_category_tree_description + scheme = None + else: + continue + + try: + checker = schema_checker.Validator(scheme) + data = cache.get_or_load(file_path) + passed = checker.run(data) + for msg in checker.messages: + logger.warning('{:<40s} {}'.format( + os.path.basename(file_path), msg)) + if not passed: + logger.info( + 'YAML schema check failed for: ' + file_path) + + loader(data, file_path) + except Exception as error: + logger.exception('Error while loading %s', file_path) + logger.exception(error) + Messages.flowgraph_error = error + Messages.flowgraph_error_file = file_path + continue + + for key, block in self.blocks.items(): + category = self._block_categories.get(key, block.category) + if not category: + continue + root = category[0] + if root.startswith('[') and root.endswith(']'): + category[0] = root[1:-1] + else: + category.insert(0, Constants.DEFAULT_BLOCK_MODULE_NAME) + block.category = category + + self._docstring_extractor.finish() + # self._docstring_extractor.wait() + if 'options' not in self.blocks: + # we didn't find one of the built-in blocks ("options") + # which probably means the GRC blocks path is bad + errstr = ( + "Failed to find built-in GRC blocks (specifically, the " + "'options' block). Ensure your GRC block paths are correct " + "and at least one points to your prefix installation:" + ) + errstr = "\n".join([errstr] + (path or self.config.block_paths)) + raise RuntimeError(errstr) + else: + # might have some cleanup to do on the options block in particular + utils.hide_bokeh_gui_options_if_not_installed( + self.blocks['options']) + + def _iter_files_in_block_path(self, path=None, ext='yml'): + """Iterator for block descriptions and category trees""" + for entry in (path or self.config.block_paths): + if os.path.isfile(entry): + yield entry + elif os.path.isdir(entry): + for dirpath, dirnames, filenames in os.walk(entry): + for filename in sorted(filter(lambda f: f.endswith('.' + ext), filenames)): + yield os.path.join(dirpath, filename) + else: + logger.debug('Ignoring invalid path entry %r', entry) + + def _save_docstring_extraction_result(self, block_id, docstrings): + docs = {} + for match, docstring in docstrings.items(): + if not docstring or match.endswith('_sptr'): + continue + docs[match] = docstring.replace('\n\n', '\n').strip() + try: + self.blocks[block_id].documentation.update(docs) + except KeyError: + pass # in tests platform might be gone... + + ############################################## + # Description File Loaders + ############################################## + # region loaders + def load_block_description(self, data, file_path): + log = logger.getChild('block_loader') + + # don't load future block format versions + file_format = data['file_format'] + if file_format < 1 or file_format > Constants.BLOCK_DESCRIPTION_FILE_FORMAT_VERSION: + log.error('Unknown format version %d in %s', + file_format, file_path) + return + + block_id = data['id'] = data['id'].rstrip('_') + + if block_id in self.block_classes_build_in: + log.warning('Not overwriting build-in block %s with %s', + block_id, file_path) + return + if block_id in self.blocks: + log.warning('Block with id "%s" loaded from\n %s\noverwritten by\n %s', + block_id, self.blocks[block_id].loaded_from, file_path) + + try: + block_cls = self.blocks[block_id] = self.new_block_class(**data) + block_cls.loaded_from = file_path + except errors.BlockLoadError as error: + log.error('Unable to load block %s', block_id) + log.exception(error) + return + + self._docstring_extractor.query( + block_id, block_cls.templates['imports'], block_cls.templates['make'], + ) + + def load_domain_description(self, data, file_path): + log = logger.getChild('domain_loader') + domain_id = data['id'] + if domain_id in self.domains: # test against repeated keys + log.debug('Domain "{}" already exists. Ignoring: %s', file_path) + return + + color = data.get('color', '') + if color.startswith('#'): + try: + tuple(int(color[o:o + 2], 16) / 255.0 for o in range(1, 6, 2)) + except ValueError: + log.warning('Cannot parse color code "%s" in %s', + color, file_path) + return + + self.domains[domain_id] = self.Domain( + name=data.get('label', domain_id), + multi_in=data.get('multiple_connections_per_input', True), + multi_out=data.get('multiple_connections_per_output', False), + color=color + ) + for connection in data.get('templates', []): + try: + source_id, sink_id = connection.get('type', []) + except ValueError: + log.warn('Invalid connection template.') + continue + connection_id = str(source_id), str(sink_id) + self.connection_templates[connection_id] = connection.get( + 'connect', '') + self.cpp_connection_templates[connection_id] = connection.get( + 'cpp_connect', '') + self.connection_params[connection_id] = connection.get('parameters', {}) + + def load_category_tree_description(self, data, file_path): + """Parse category tree file and add it to list""" + log = logger.getChild('tree_loader') + log.debug('Loading %s', file_path) + path = [] + + def load_category(name, elements): + if not isinstance(name, str): + log.debug('Invalid name %r', name) + return + path.append(name) + for element in utils.to_list(elements): + if isinstance(element, str): + block_id = element + self._block_categories[block_id] = list(path) + elif isinstance(element, dict): + load_category(*next(iter(element.items()))) + else: + log.debug('Ignoring some elements of %s', name) + path.pop() + + try: + module_name, categories = next(iter(data.items())) + except (AttributeError, StopIteration): + log.warning('no valid data found') + else: + load_category(module_name, categories) + + ############################################## + # Access + ############################################## + def parse_flow_graph(self, filename): + """ + Parse a saved flow graph file. + Ensure that the file exists, and passes the dtd check. + + Args: + filename: the flow graph file + + Returns: + nested data + @throws exception if the validation fails + """ + filename = filename or self.config.default_flow_graph + is_xml = False + with open(filename, encoding='utf-8') as fp: + is_xml = '' in fp.read(100) + fp.seek(0) + # todo: try + if not is_xml: + data = yaml.safe_load(fp) + validator = schema_checker.Validator( + schema_checker.FLOW_GRAPH_SCHEME) + validator.run(data) + + if is_xml: + Messages.send('>>> Converting from XML\n') + from ..converter.flow_graph import from_xml + data = from_xml(filename) + + file_format = data.get('metadata', {}).get('file_format') + if file_format is None: + Messages.send( + '>>> WARNING: Flow graph does not contain a file format version!\n') + elif file_format == 0: + Messages.send( + '>>> WARNING: Flow graph format is version 0 (legacy) and will' + ' be converted to version 1 or higher upon saving!\n') + elif file_format > Constants.FLOW_GRAPH_FILE_FORMAT_VERSION: + raise RuntimeError( + f"Flow graph {filename} has unknown flow graph version!") + + return data + + def save_flow_graph(self, filename, flow_graph): + data = flow_graph.export_data() + + try: + data['connections'] = [ + yaml.ListFlowing(conn) if isinstance(conn, (list, tuple)) else conn + for conn in data['connections'] + ] + except KeyError: + pass + + try: + for d in chain([data['options']], data['blocks']): + d['states']['coordinate'] = yaml.ListFlowing( + d['states']['coordinate']) + except KeyError: + pass + + out = yaml.dump(data, indent=2) + + replace = [ + ('blocks:\n', '\nblocks:\n'), + ('connections:\n', '\nconnections:\n'), + ('metadata:\n', '\nmetadata:\n'), + ] + for r in replace: + out = out.replace(*r) + + with open(filename, 'w', encoding='utf-8') as fp: + fp.write(out) + + def get_generate_options(self): + for param in self.block_classes['options'].parameters_data: + if param.get('id') == 'generate_options': + break + else: + return [] + generate_mode_default = param.get('default') + return [(value, name, value == generate_mode_default) + for value, name in zip(param['options'], param['option_labels'])] + + def get_output_language(self): + for param in self.block_classes['options'].parameters_data: + if param.get('id') == 'output_language': + break + else: + return [] + output_language_default = param.get('default') + return [(value, name, value == output_language_default) + for value, name in zip(param['options'], param['option_labels'])] + + ############################################## + # Factories + ############################################## + Config = Config + Domain = namedtuple('Domain', 'name multi_in multi_out color') + Generator = Generator + FlowGraph = FlowGraph + Connection = Connection + + block_classes_build_in = blocks.build_ins + # separates build-in from loaded blocks) + block_classes = ChainMap({}, block_classes_build_in) + + port_classes = { + None: ports.Port, # default + 'clone': ports.PortClone, # clone of ports with multiplicity > 1 + } + param_classes = { + None: params.Param, # default + } + + def make_flow_graph(self, from_filename=None): + fg = self.FlowGraph(parent=self) + if from_filename: + data = self.parse_flow_graph(from_filename) + fg.grc_file_path = from_filename + fg.import_data(data) + return fg + + def new_block_class(self, **data) -> Type[Block]: + return blocks.build(**data) + + def make_block(self, parent, block_id, **kwargs) -> Block: + cls = self.block_classes[block_id] + return cls(parent, **kwargs) + + def make_param(self, parent, **kwargs): + cls = self.param_classes[kwargs.pop('cls_key', None)] + return cls(parent, **kwargs) + + def make_port(self, parent, **kwargs): + cls = self.port_classes[kwargs.pop('cls_key', None)] + return cls(parent, **kwargs) diff --git a/grc/core/ports/__init__.py b/grc/core/ports/__init__.py new file mode 100644 index 0000000..bdbfa50 --- /dev/null +++ b/grc/core/ports/__init__.py @@ -0,0 +1,11 @@ +""" +Copyright 2008-2015 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from .port import Port +from .clone import PortClone diff --git a/grc/core/ports/_virtual_connections.py b/grc/core/ports/_virtual_connections.py new file mode 100644 index 0000000..a328531 --- /dev/null +++ b/grc/core/ports/_virtual_connections.py @@ -0,0 +1,119 @@ +# Copyright 2008-2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from itertools import chain + +from .. import blocks + + +class LoopError(Exception): + pass + + +def upstream_ports(port): + if port.is_sink: + return _sources_from_virtual_sink_port(port) + else: + return _sources_from_virtual_source_port(port) + + +def _sources_from_virtual_sink_port(sink_port, _traversed=None): + """ + Resolve the source port that is connected to the given virtual sink port. + Use the get source from virtual source to recursively resolve subsequent ports. + """ + source_ports_per_virtual_connection = ( + # there can be multiple ports per virtual connection + _sources_from_virtual_source_port( + c.source_port, _traversed) # type: list + for c in sink_port.connections(enabled=True) + ) + # concatenate generated lists of ports + return list(chain(*source_ports_per_virtual_connection)) + + +def _sources_from_virtual_source_port(source_port, _traversed=None): + """ + Recursively resolve source ports over the virtual connections. + Keep track of traversed sources to avoid recursive loops. + """ + _traversed = set(_traversed or []) # a new set! + if source_port in _traversed: + raise LoopError('Loop found when resolving port type') + _traversed.add(source_port) + + block = source_port.parent_block + flow_graph = source_port.parent_flowgraph + + if not isinstance(block, blocks.VirtualSource): + return [source_port] # nothing to resolve, we're done + + stream_id = block.params['stream_id'].value + + # currently the validation does not allow multiple virtual sinks and one virtual source + # but in the future it may... + connected_virtual_sink_blocks = ( + b for b in flow_graph.iter_enabled_blocks() + if isinstance(b, blocks.VirtualSink) and b.params['stream_id'].value == stream_id + ) + source_ports_per_virtual_connection = ( + _sources_from_virtual_sink_port(b.sinks[0], _traversed) # type: list + for b in connected_virtual_sink_blocks + ) + # concatenate generated lists of ports + return list(chain(*source_ports_per_virtual_connection)) + + +def downstream_ports(port): + if port.is_source: + return _sinks_from_virtual_source_port(port) + else: + return _sinks_from_virtual_sink_port(port) + + +def _sinks_from_virtual_source_port(source_port, _traversed=None): + """ + Resolve the sink port that is connected to the given virtual source port. + Use the get sink from virtual sink to recursively resolve subsequent ports. + """ + sink_ports_per_virtual_connection = ( + # there can be multiple ports per virtual connection + _sinks_from_virtual_sink_port(c.sink_port, _traversed) # type: list + for c in source_port.connections(enabled=True) + ) + # concatenate generated lists of ports + return list(chain(*sink_ports_per_virtual_connection)) + + +def _sinks_from_virtual_sink_port(sink_port, _traversed=None): + """ + Recursively resolve sink ports over the virtual connections. + Keep track of traversed sinks to avoid recursive loops. + """ + _traversed = set(_traversed or []) # a new set! + if sink_port in _traversed: + raise LoopError('Loop found when resolving port type') + _traversed.add(sink_port) + + block = sink_port.parent_block + flow_graph = sink_port.parent_flowgraph + + if not isinstance(block, blocks.VirtualSink): + return [sink_port] + + stream_id = block.params['stream_id'].value + + connected_virtual_source_blocks = ( + b for b in flow_graph.iter_enabled_blocks() + if isinstance(b, blocks.VirtualSource) and b.params['stream_id'].value == stream_id + ) + sink_ports_per_virtual_connection = ( + _sinks_from_virtual_source_port(b.sources[0], _traversed) # type: list + for b in connected_virtual_source_blocks + ) + # concatenate generated lists of ports + return list(chain(*sink_ports_per_virtual_connection)) diff --git a/grc/core/ports/clone.py b/grc/core/ports/clone.py new file mode 100644 index 0000000..1587ec0 --- /dev/null +++ b/grc/core/ports/clone.py @@ -0,0 +1,27 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +from .port import Port, Element + + +class PortClone(Port): + + def __init__(self, parent, direction, master, name, key): + Element.__init__(self, parent) + self.master_port = master + + self.name = name + self.key = key + self.multiplicity = 1 + + def __getattr__(self, item): + return getattr(self.master_port, item) + + def add_clone(self): + raise NotImplementedError() + + def remove_clone(self, port): + raise NotImplementedError() diff --git a/grc/core/ports/port.py b/grc/core/ports/port.py new file mode 100644 index 0000000..451dad9 --- /dev/null +++ b/grc/core/ports/port.py @@ -0,0 +1,255 @@ +# Copyright 2008-2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from . import _virtual_connections + +from .. import Constants +from ..base import Element +from ..utils.descriptors import ( + EvaluatedFlag, EvaluatedEnum, EvaluatedPInt, + setup_names, lazy_property +) + + +@setup_names +class Port(Element): + + is_port = True + + dtype = EvaluatedEnum(list(Constants.TYPE_TO_SIZEOF.keys()), default='') + vlen = EvaluatedPInt() + multiplicity = EvaluatedPInt() + hidden = EvaluatedFlag() + optional = EvaluatedFlag() + + def __init__(self, parent, direction, id, label='', domain=Constants.DEFAULT_DOMAIN, dtype='', + vlen='', multiplicity=1, optional=False, hide=False, bus_struct=None, **_): + """Make a new port from nested data.""" + Element.__init__(self, parent) + + self._dir = direction + self.key = id + if not label: + label = id if not id.isdigit() else {'sink': 'in', 'source': 'out'}[ + direction] + if dtype == 'bus': + # Look for existing busses to give proper index + busses = [p for p in self.parent.ports() if p._dir == + self._dir and p.dtype == 'bus'] + bus_structure = self.parent.current_bus_structure[self._dir] + bus_index = len(busses) + if len(bus_structure) > bus_index: + number = str(len(busses)) + '#' + \ + str(len(bus_structure[bus_index])) + label = dtype + number + else: + raise ValueError( + 'Could not initialize bus port due to incompatible bus structure') + + self.name = self._base_name = label + + self.domain = domain + self.dtype = dtype + self.vlen = vlen + + if domain == Constants.GR_MESSAGE_DOMAIN: # ToDo: message port class + self.key = self.name + self.dtype = 'message' + + self.multiplicity = multiplicity + self.optional = optional + self.hidden = hide + self.stored_hidden_state = None + self.bus_structure = bus_struct + + # end of args ######################################################## + self.clones = [] # References to cloned ports (for nports > 1) + + def __str__(self): + if self.is_source: + return 'Source - {}({})'.format(self.name, self.key) + if self.is_sink: + return 'Sink - {}({})'.format(self.name, self.key) + + def __repr__(self): + return '{!r}.{}[{}]'.format(self.parent, 'sinks' if self.is_sink else 'sources', self.key) + + @property + def item_size(self): + return Constants.TYPE_TO_SIZEOF[self.dtype] * self.vlen + + @lazy_property + def is_sink(self): + return self._dir == 'sink' + + @lazy_property + def is_source(self): + return self._dir == 'source' + + @property + def inherit_type(self): + """always empty for e.g. virtual blocks, may eval to empty for 'Wildcard'""" + return not self.dtype + + def validate(self): + del self._error_messages[:] + Element.validate(self) + platform = self.parent_platform + + num_connections = len(list(self.connections(enabled=True))) + need_connection = not self.optional and not self.hidden + if need_connection and num_connections == 0: + self.add_error_message('Port is not connected.') + + if self.dtype not in Constants.TYPE_TO_SIZEOF.keys(): + self.add_error_message( + 'Type "{}" is not a possible type.'.format(self.dtype)) + + try: + domain = platform.domains[self.domain] + if self.is_sink and not domain.multi_in and num_connections > 1: + self.add_error_message('Domain "{}" can have only one upstream block' + ''.format(self.domain)) + if self.is_source and not domain.multi_out and num_connections > 1: + self.add_error_message('Domain "{}" can have only one downstream block' + ''.format(self.domain)) + except KeyError: + self.add_error_message( + 'Domain key "{}" is not registered.'.format(self.domain)) + + def rewrite(self): + del self.vlen + del self.multiplicity + del self.hidden + del self.optional + del self.dtype + + if self.inherit_type: + self.resolve_empty_type() + + Element.rewrite(self) + + # Update domain if was deduced from (dynamic) port type + if self.domain == Constants.GR_STREAM_DOMAIN and self.dtype == "message": + self.domain = Constants.GR_MESSAGE_DOMAIN + self.key = self.name + if self.domain == Constants.GR_MESSAGE_DOMAIN and self.dtype != "message": + self.domain = Constants.GR_STREAM_DOMAIN + self.key = '0' # Is rectified in rewrite() + + def resolve_virtual_source(self): + """Only used by Generator after validation is passed""" + return _virtual_connections.upstream_ports(self) + + def resolve_empty_type(self): + def find_port(finder): + try: + return next((p for p in finder(self) if not p.inherit_type), None) + except _virtual_connections.LoopError as error: + self.add_error_message(str(error)) + except (StopIteration, Exception): + pass + + try: + port = find_port(_virtual_connections.upstream_ports) or \ + find_port(_virtual_connections.downstream_ports) + # we don't want to override the template + self.set_evaluated('dtype', port.dtype) + # we don't want to override the template + self.set_evaluated('vlen', port.vlen) + self.domain = port.domain + except AttributeError: + pass + + def add_clone(self): + """ + Create a clone of this (master) port and store a reference in self._clones. + + The new port name (and key for message ports) will have index 1... appended. + If this is the first clone, this (master) port will get a 0 appended to its name (and key) + + Returns: + the cloned port + """ + # Add index to master port name if there are no clones yet + if not self.clones: + self.name = self._base_name + '0' + # Also update key for none stream ports + if not self.key.isdigit(): + self.key = self.name + + name = self._base_name + str(len(self.clones) + 1) + # Dummy value 99999 will be fixed later + key = '99999' if self.key.isdigit() else name + + # Clone + port_factory = self.parent_platform.make_port + port = port_factory(self.parent, direction=self._dir, + name=name, key=key, + master=self, cls_key='clone') + + self.clones.append(port) + return port + + def remove_clone(self, port): + """ + Remove a cloned port (from the list of clones only) + Remove the index 0 of the master port name (and key9 if there are no more clones left + """ + self.clones.remove(port) + # Remove index from master port name if there are no more clones + if not self.clones: + self.name = self._base_name + # Also update key for none stream ports + if not self.key.isdigit(): + self.key = self.name + + def connections(self, enabled=None): + """Iterator over all connections to/from this port + + enabled: None for all, True for enabled only, False for disabled only + """ + for con in self.parent_flowgraph.connections: + # TODO clean this up - but how to get past this validation + # things don't compare simply with an x in y because + # bus ports are created differently. + port_in_con = False + if self.dtype == 'bus': + if self.is_sink: + if (self.parent.name == con.sink_port.parent.name and + self.name == con.sink_port.name): + port_in_con = True + elif self.is_source: + if (self.parent.name == con.source_port.parent.name and + self.name == con.source_port.name): + port_in_con = True + + if port_in_con: + yield con + + else: + if self in con and (enabled is None or enabled == con.enabled): + yield con + + def get_associated_ports(self): + if not self.dtype == 'bus': + return [self] + else: + if self.is_source: + get_ports = self.parent.sources + bus_structure = self.parent.current_bus_structure['source'] + else: + get_ports = self.parent.sinks + bus_structure = self.parent.current_bus_structure['sink'] + + ports = [i for i in get_ports if not i.dtype == 'bus'] + if bus_structure: + busses = [i for i in get_ports if i.dtype == 'bus'] + bus_index = busses.index(self) + ports = filter(lambda a: ports.index( + a) in bus_structure[bus_index], ports) + return ports diff --git a/grc/core/schema_checker/__init__.py b/grc/core/schema_checker/__init__.py new file mode 100644 index 0000000..e92500e --- /dev/null +++ b/grc/core/schema_checker/__init__.py @@ -0,0 +1,5 @@ +from .validator import Validator + +from .block import BLOCK_SCHEME +from .domain import DOMAIN_SCHEME +from .flow_graph import FLOW_GRAPH_SCHEME diff --git a/grc/core/schema_checker/block.py b/grc/core/schema_checker/block.py new file mode 100644 index 0000000..420773d --- /dev/null +++ b/grc/core/schema_checker/block.py @@ -0,0 +1,73 @@ +from .utils import Spec, expand + +PARAM_SCHEME = expand( + base_key=str, # todo: rename/remove + + id=str, + label=str, + category=str, + + dtype=str, + default=object, + + options=list, + option_labels=list, + option_attributes=Spec(types=dict, required=False, + item_scheme=(str, list)), + + hide=str, +) +PORT_SCHEME = expand( + label=str, + domain=str, + + id=str, + dtype=str, + vlen=(int, str), + + multiplicity=(int, str), + optional=(bool, int, str), + hide=(bool, str), +) +TEMPLATES_SCHEME = expand( + imports=str, + var_make=str, + var_value=str, + make=str, + callbacks=list, +) +CPP_TEMPLATES_SCHEME = expand( + includes=list, + declarations=str, + make=str, + var_make=str, + callbacks=list, + link=list, + packages=list, + translations=dict, +) +BLOCK_SCHEME = expand( + id=Spec(types=str, required=True, item_scheme=None), + label=str, + category=str, + flags=(list, str), + + parameters=Spec(types=list, required=False, item_scheme=PARAM_SCHEME), + inputs=Spec(types=list, required=False, item_scheme=PORT_SCHEME), + outputs=Spec(types=list, required=False, item_scheme=PORT_SCHEME), + + asserts=(list, str), + value=str, + + templates=Spec(types=dict, required=False, item_scheme=TEMPLATES_SCHEME), + cpp_templates=Spec(types=dict, required=False, + item_scheme=CPP_TEMPLATES_SCHEME), + + documentation=str, + doc_url=str, + grc_source=str, + + file_format=Spec(types=int, required=True, item_scheme=None), + + block_wrapper_path=str, # todo: rename/remove +) diff --git a/grc/core/schema_checker/domain.py b/grc/core/schema_checker/domain.py new file mode 100644 index 0000000..ccbc136 --- /dev/null +++ b/grc/core/schema_checker/domain.py @@ -0,0 +1,19 @@ +from .utils import Spec, expand +from .block import PARAM_SCHEME + +DOMAIN_CONNECTION = expand( + type=Spec(types=list, required=True, item_scheme=None), + connect=str, + cpp_connect=str, + parameters=Spec(types=list, required=False, item_scheme=PARAM_SCHEME), +) + +DOMAIN_SCHEME = expand( + id=Spec(types=str, required=True, item_scheme=None), + label=str, + color=str, + multiple_connections_per_input=bool, + multiple_connections_per_output=bool, + + templates=Spec(types=list, required=False, item_scheme=DOMAIN_CONNECTION) +) diff --git a/grc/core/schema_checker/flow_graph.py b/grc/core/schema_checker/flow_graph.py new file mode 100644 index 0000000..ff6c95e --- /dev/null +++ b/grc/core/schema_checker/flow_graph.py @@ -0,0 +1,24 @@ +from .utils import Spec, expand + +OPTIONS_SCHEME = expand( + parameters=Spec(types=dict, required=False, item_scheme=(str, str)), + states=Spec(types=dict, required=False, item_scheme=(str, str)), +) + +BLOCK_SCHEME = expand( + name=str, + id=str, + **OPTIONS_SCHEME +) + +FLOW_GRAPH_SCHEME = expand( + options=Spec(types=dict, required=False, item_scheme=OPTIONS_SCHEME), + blocks=Spec(types=dict, required=False, item_scheme=BLOCK_SCHEME), + connections=list, + + metadata=Spec(types=dict, required=True, item_scheme=expand( + file_format=Spec(types=int, required=True, item_scheme=None), + grc_version=Spec(types=str, required=False, item_scheme=None), + )) + +) diff --git a/grc/core/schema_checker/manifest.py b/grc/core/schema_checker/manifest.py new file mode 100644 index 0000000..417cdac --- /dev/null +++ b/grc/core/schema_checker/manifest.py @@ -0,0 +1,18 @@ +from .utils import Spec, expand + +MANIFEST_SCHEME = expand( + title=Spec(types=str, required=True, item_scheme=None), + version=str, + brief=str, + website=str, + dependencies=list, + repo=str, + copyright_owner=list, + gr_supported_version=list, + tags=list, + license=str, + description=str, + author=list, + icon=str, + file_format=Spec(types=int, required=True, item_scheme=None), +) diff --git a/grc/core/schema_checker/utils.py b/grc/core/schema_checker/utils.py new file mode 100644 index 0000000..2fe0bb0 --- /dev/null +++ b/grc/core/schema_checker/utils.py @@ -0,0 +1,22 @@ +import collections + +Spec = collections.namedtuple('Spec', 'types required item_scheme') + + +def expand(**kwargs): + def expand_spec(spec): + if not isinstance(spec, Spec): + types_ = spec if isinstance(spec, tuple) else (spec,) + spec = Spec(types=types_, required=False, item_scheme=None) + elif not isinstance(spec.types, tuple): + spec = Spec(types=(spec.types,), required=spec.required, + item_scheme=spec.item_scheme) + return spec + return {key: expand_spec(value) for key, value in kwargs.items()} + + +class Message(collections.namedtuple('Message', 'path type message')): + fmt = '{path}: {type}: {message}' + + def __str__(self): + return self.fmt.format(**self._asdict()) diff --git a/grc/core/schema_checker/validator.py b/grc/core/schema_checker/validator.py new file mode 100644 index 0000000..e1843b6 --- /dev/null +++ b/grc/core/schema_checker/validator.py @@ -0,0 +1,88 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from .utils import Message, Spec + + +class Validator(object): + + def __init__(self, scheme=None): + self._path = [] + self.scheme = scheme + self.messages = [] + self.passed = False + + def run(self, data): + if not self.scheme: + return True + self._reset() + self._path.append('block') + self._check(data, self.scheme) + self._path.pop() + return self.passed + + def _reset(self): + del self.messages[:] + del self._path[:] + self.passed = True + + def _check(self, data, scheme): + if not data or not isinstance(data, dict): + self._error('Empty data or not a dict') + return + if isinstance(scheme, dict): + self._check_dict(data, scheme) + else: + self._check_var_key_dict(data, *scheme) + + def _check_var_key_dict(self, data, key_type, value_scheme): + for key, value in data.items(): + if not isinstance(key, key_type): + self._error('Key type {!r} for {!r} not in valid types'.format( + type(value).__name__, key)) + if isinstance(value_scheme, Spec): + self._check_dict(value, value_scheme) + elif not isinstance(value, value_scheme): + self._error('Value type {!r} for {!r} not in valid types'.format( + type(value).__name__, key)) + + def _check_dict(self, data, scheme): + for key, (types_, required, item_scheme) in scheme.items(): + try: + value = data[key] + except KeyError: + if required: + self._error('Missing required entry {!r}'.format(key)) + continue + + self._check_value(value, types_, item_scheme, label=key) + + for key in set(data).difference(scheme): + self._warn('Ignoring extra key {!r}'.format(key)) + + def _check_list(self, data, scheme, label): + for i, item in enumerate(data): + self._path.append('{}[{}]'.format(label, i)) + self._check(item, scheme) + self._path.pop() + + def _check_value(self, value, types_, item_scheme, label): + if not isinstance(value, types_): + self._error('Value type {!r} for {!r} not in valid types'.format( + type(value).__name__, label)) + if item_scheme: + if isinstance(value, list): + self._check_list(value, item_scheme, label) + elif isinstance(value, dict): + self._check(value, item_scheme) + + def _error(self, msg): + self.messages.append(Message('.'.join(self._path), 'error', msg)) + self.passed = False + + def _warn(self, msg): + self.messages.append(Message('.'.join(self._path), 'warn', msg)) diff --git a/grc/core/utils/__init__.py b/grc/core/utils/__init__.py new file mode 100644 index 0000000..f12bf0f --- /dev/null +++ b/grc/core/utils/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +from . import epy_block_io, expr_utils, extract_docs, flow_graph_complexity +from .hide_bokeh_gui_options_if_not_installed import hide_bokeh_gui_options_if_not_installed + + +def to_list(value): + if not value: + return [] + elif isinstance(value, str): + return [value] + else: + return list(value) diff --git a/grc/core/utils/descriptors/__init__.py b/grc/core/utils/descriptors/__init__.py new file mode 100644 index 0000000..66096d3 --- /dev/null +++ b/grc/core/utils/descriptors/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +from ._lazy import lazy_property, nop_write + +from .evaluated import ( + Evaluated, + EvaluatedEnum, + EvaluatedPInt, + EvaluatedFlag, + setup_names, +) diff --git a/grc/core/utils/descriptors/_lazy.py b/grc/core/utils/descriptors/_lazy.py new file mode 100644 index 0000000..e890be4 --- /dev/null +++ b/grc/core/utils/descriptors/_lazy.py @@ -0,0 +1,63 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +""" +Class method decorators. +""" + +import functools + +# pylint: disable=too-few-public-methods,invalid-name + + +class lazy_property: + """ + Class method decorator, similar to @property. This causes a method that is + declared as @lazy_property to be evaluated once, the first time it is called. + Subsequent calls to this property will always return the cached value. + + Careful! Not suitable for properties that change at runtime. + + Example: + + >>> class Foo: + ... def __init__(self): + ... self.x = 5 + ... + ... @lazy_property + ... def xyz(self): + ... return complicated_slow_function(self.x) + ... + ... + >>> f = Foo() + >>> print(f.xyz) # Will give the result, but takes long to compute + >>> print(f.xyz) # Blazing fast! + >>> f.x = 7 # Careful! f.xyz will not be updated any more! + >>> print(f.xyz) # Blazing fast, but returns the same value as before. + """ + + def __init__(self, func): + self.func = func + functools.update_wrapper(self, func) + + def __get__(self, instance, owner): + if instance is None: + return self + value = self.func(instance) + setattr(instance, self.func.__name__, value) + return value +# pylint: enable=too-few-public-methods,invalid-name + + +def nop_write(prop): + """ + Make this a property with a nop setter + + Effectively, makes a property read-only, with no error on write. + """ + + def nop(self, value): + pass + return prop.setter(nop) diff --git a/grc/core/utils/descriptors/evaluated.py b/grc/core/utils/descriptors/evaluated.py new file mode 100644 index 0000000..bef2237 --- /dev/null +++ b/grc/core/utils/descriptors/evaluated.py @@ -0,0 +1,105 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +class Evaluated(object): + def __init__(self, expected_type, default, name=None): + self.expected_type = expected_type + self.default = default + + self.name = name or 'evaled_property_{}'.format(id(self)) + self.eval_function = self.default_eval_func + + @property + def name_raw(self): + return '_' + self.name + + def default_eval_func(self, instance): + raw = getattr(instance, self.name_raw) + try: + value = instance.parent_block.evaluate(raw) + except Exception as error: + if raw: + instance.add_error_message(f"Failed to eval '{raw}': ({type(error)}) {error}") + return self.default + + if not isinstance(value, self.expected_type): + instance.add_error_message("Can not cast evaluated value '{}' to type {}" + "".format(value, self.expected_type)) + return self.default + # print(instance, self.name, raw, value) + return value + + def __call__(self, func): + self.name = func.__name__ + self.eval_function = func + return self + + def __get__(self, instance, owner): + if instance is None: + return self + attribs = instance.__dict__ + try: + value = attribs[self.name] + except KeyError: + value = attribs[self.name] = self.eval_function(instance) + return value + + def __set__(self, instance, value): + attribs = instance.__dict__ + value = value or self.default + if isinstance(value, str) and value.startswith('${') and value.endswith('}'): + attribs[self.name_raw] = value[2:-1].strip() + attribs.pop(self.name, None) # reset previous eval result + else: + attribs[self.name] = type(self.default)(value) + + def __delete__(self, instance): + attribs = instance.__dict__ + if self.name_raw in attribs: + attribs.pop(self.name, None) + + +class EvaluatedEnum(Evaluated): + def __init__(self, allowed_values, default=None, name=None): + if isinstance(allowed_values, str): + allowed_values = set(allowed_values.split()) + self.allowed_values = allowed_values + default = default if default is not None else next( + iter(self.allowed_values)) + super(EvaluatedEnum, self).__init__(str, default, name) + + def default_eval_func(self, instance): + value = super(EvaluatedEnum, self).default_eval_func(instance) + if value not in self.allowed_values: + instance.add_error_message( + "Value '{}' not in allowed values".format(value)) + return self.default + return value + + +class EvaluatedPInt(Evaluated): + def __init__(self, name=None): + super(EvaluatedPInt, self).__init__(int, 1, name) + + def default_eval_func(self, instance): + value = super(EvaluatedPInt, self).default_eval_func(instance) + if value < 1: + # todo: log + return self.default + return value + + +class EvaluatedFlag(Evaluated): + def __init__(self, name=None): + super(EvaluatedFlag, self).__init__((bool, int), False, name) + + +def setup_names(cls): + for name, attrib in cls.__dict__.items(): + if isinstance(attrib, Evaluated): + attrib.name = name + return cls diff --git a/grc/core/utils/epy_block_io.py b/grc/core/utils/epy_block_io.py new file mode 100644 index 0000000..9947fa4 --- /dev/null +++ b/grc/core/utils/epy_block_io.py @@ -0,0 +1,129 @@ + + +import inspect +import collections + + +TYPE_MAP = { + 'complex64': 'complex', 'complex': 'complex', + 'float32': 'float', 'float': 'float', + 'int32': 'int', 'uint32': 'int', + 'int16': 'short', 'uint16': 'short', + 'int8': 'byte', 'uint8': 'byte', +} + +BlockIO = collections.namedtuple( + 'BlockIO', 'name cls params sinks sources doc callbacks') + + +def _ports(sigs, msgs): + ports = list() + for i, dtype in enumerate(sigs): + port_type = TYPE_MAP.get(dtype.base.name, None) + if not port_type: + raise ValueError("Can't map {0!r} to GRC port type".format(dtype)) + vlen = dtype.shape[0] if len(dtype.shape) > 0 else 1 + ports.append((str(i), port_type, vlen)) + for msg_key in msgs: + if msg_key == 'system': + continue + ports.append((msg_key, 'message', 1)) + return ports + + +def _find_block_class(source_code, cls): + ns = {} + try: + exec(source_code, ns) + except Exception as e: + raise ValueError("Can't interpret source code: " + str(e)) + for var in ns.values(): + if inspect.isclass(var) and issubclass(var, cls): + return var + raise ValueError('No python block class found in code') + + +def extract(cls): + try: + from gnuradio import gr + import pmt + except ImportError: + raise EnvironmentError("Can't import GNU Radio") + + if not inspect.isclass(cls): + cls = _find_block_class(cls, gr.gateway.gateway_block) + + spec = inspect.getfullargspec(cls.__init__) + init_args = spec.args[1:] + defaults = [repr(arg) for arg in (spec.defaults or ())] + doc = cls.__doc__ or cls.__init__.__doc__ or '' + cls_name = cls.__name__ + + if len(defaults) + 1 != len(spec.args): + raise ValueError("Need all __init__ arguments to have default values") + + try: + instance = cls() + except Exception as e: + raise RuntimeError("Can't create an instance of your block: " + str(e)) + + name = instance.name() + + params = list(zip(init_args, defaults)) + + def settable(attr): + try: + # check for a property with setter + return callable(getattr(cls, attr).fset) + except AttributeError: + return attr in instance.__dict__ # not dir() - only the instance attribs + + callbacks = [attr for attr in dir( + instance) if attr in init_args and settable(attr)] + + sinks = _ports(instance.in_sig(), + pmt.to_python(instance.message_ports_in())) + sources = _ports(instance.out_sig(), + pmt.to_python(instance.message_ports_out())) + + return BlockIO(name, cls_name, params, sinks, sources, doc, callbacks) + + +if __name__ == '__main__': + blk_code = """ +import numpy as np +from gnuradio import gr +import pmt + +class blk(gr.sync_block): + def __init__(self, param1=None, param2=None, param3=None): + "Test Docu" + gr.sync_block.__init__( + self, + name='Embedded Python Block', + in_sig = (np.float32,), + out_sig = (np.float32,np.complex64,), + ) + self.message_port_register_in(pmt.intern('msg_in')) + self.message_port_register_out(pmt.intern('msg_out')) + self.param1 = param1 + self._param2 = param2 + self._param3 = param3 + + @property + def param2(self): + return self._param2 + + @property + def param3(self): + return self._param3 + + @param3.setter + def param3(self, value): + self._param3 = value + + def work(self, inputs_items, output_items): + return 10 + """ + from pprint import pprint + pprint(dict(extract(blk_code)._asdict())) diff --git a/grc/core/utils/expr_utils.py b/grc/core/utils/expr_utils.py new file mode 100644 index 0000000..7748344 --- /dev/null +++ b/grc/core/utils/expr_utils.py @@ -0,0 +1,214 @@ +""" +Copyright 2008-2011 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import ast +import string + + +def expr_replace(expr, replace_dict): + """ + Search for vars in the expression and add the prepend. + + Args: + expr: an expression string + replace_dict: a dict of find:replace + + Returns: + a new expression with the prepend + """ + expr_splits = _expr_split(expr, var_chars=VAR_CHARS + '.') + for i, es in enumerate(expr_splits): + if es in list(replace_dict.keys()): + expr_splits[i] = replace_dict[es] + return ''.join(expr_splits) + + +def get_variable_dependencies(expr, vars): + """ + Return a set of variables used in this expression. + + Args: + expr: an expression string + vars: a list of variable names + + Returns: + a subset of vars used in the expression + """ + expr_toks = _expr_split(expr) + return set(v for v in vars if v in expr_toks) + + +def sort_objects(objects, get_id, get_expr) -> list: + """ + Sort a list of objects according to their expressions. + + Args: + objects: the list of objects to sort + get_id: the function to extract an id from the object + get_expr: the function to extract an expression from the object + + Returns: + a list of sorted objects + """ + id2obj = {get_id(obj): obj for obj in objects} + # Map obj id to expression code + id2expr = {get_id(obj): get_expr(obj) for obj in objects} + # Sort according to dependency + sorted_ids = _sort_variables(id2expr) + # Return list of sorted objects + return [id2obj[id] for id in sorted_ids] + + +def dependencies(expr, names=None): + node = ast.parse(expr, mode='eval') + used_ids = frozenset( + [n.id for n in ast.walk(node) if isinstance(n, ast.Name)]) + return used_ids & names if names else used_ids + + +def sort_objects2(objects, id_getter, expr_getter, check_circular=True): + known_ids = {id_getter(obj) for obj in objects} + + def dependent_ids(obj): + deps = dependencies(expr_getter(obj)) + return [id_ if id_ in deps else '' for id_ in known_ids] + + objects = sorted(objects, key=dependent_ids) + + if check_circular: # walk var defines step by step + defined_ids = set() # variables defined so far + for obj in objects: + deps = dependencies(expr_getter(obj), known_ids) + if not defined_ids.issuperset(deps): # can't have an undefined dep + raise RuntimeError(obj, deps, defined_ids) + defined_ids.add(id_getter(obj)) # define this one + + return objects + + +VAR_CHARS = string.ascii_letters + string.digits + '_' + + +class _graph(object): + """ + Simple graph structure held in a dictionary. + """ + + def __init__(self): + self._graph = dict() + + def __str__(self): + return str(self._graph) + + def add_node(self, node_key): + if node_key in self._graph: + return + self._graph[node_key] = set() + + def remove_node(self, node_key): + if node_key not in self._graph: + return + for edges in self._graph.values(): + if node_key in edges: + edges.remove(node_key) + self._graph.pop(node_key) + + def add_edge(self, src_node_key, dest_node_key): + self._graph[src_node_key].add(dest_node_key) + + def remove_edge(self, src_node_key, dest_node_key): + self._graph[src_node_key].remove(dest_node_key) + + def get_nodes(self): + return list(self._graph.keys()) + + def get_edges(self, node_key): + return self._graph[node_key] + + +def _expr_split(expr, var_chars=VAR_CHARS): + """ + Split up an expression by non alphanumeric characters, including underscore. + Leave strings in-tact. + #TODO ignore escaped quotes, use raw strings. + + Args: + expr: an expression string + + Returns: + a list of string tokens that form expr + """ + toks = list() + tok = '' + quote = '' + for char in expr: + if quote or char in var_chars: + if char == quote: + quote = '' + tok += char + elif char in ("'", '"'): + toks.append(tok) + tok = char + quote = char + else: + toks.append(tok) + toks.append(char) + tok = '' + toks.append(tok) + return [t for t in toks if t] + + +def _get_graph(exprs): + """ + Get a graph representing the variable dependencies + + Args: + exprs: a mapping of variable name to expression + + Returns: + a graph of variable deps + """ + vars = list(exprs.keys()) + # Get dependencies for each expression, load into graph + var_graph = _graph() + for var in vars: + var_graph.add_node(var) + for var, expr in exprs.items(): + for dep in get_variable_dependencies(expr, vars): + if dep != var: + var_graph.add_edge(dep, var) + return var_graph + + +def _sort_variables(exprs): + """ + Get a list of variables in order of dependencies. + + Args: + exprs: a mapping of variable name to expression + + Returns: + a list of variable names + @throws Exception circular dependencies + """ + var_graph = _get_graph(exprs) + sorted_vars = list() + # Determine dependency order + while var_graph.get_nodes(): + # Get a list of nodes with no edges + indep_vars = [var for var in var_graph.get_nodes() + if not var_graph.get_edges(var)] + if not indep_vars: + raise Exception('circular dependency caught in sort_variables') + # Add the indep vars to the end of the list + sorted_vars.extend(sorted(indep_vars)) + # Remove each edge-less node from the graph + for var in indep_vars: + var_graph.remove_node(var) + return reversed(sorted_vars) diff --git a/grc/core/utils/extract_docs.py b/grc/core/utils/extract_docs.py new file mode 100644 index 0000000..4932882 --- /dev/null +++ b/grc/core/utils/extract_docs.py @@ -0,0 +1,317 @@ +""" +Copyright 2008-2015 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import sys +import re +import subprocess +import threading +import json +import random +import itertools +import queue + + +############################################################################### +# The docstring extraction +############################################################################### + +def docstring_guess_from_key(key): + """ + Extract the documentation from the python __doc__ strings + By guessing module and constructor names from key + + Args: + key: the block key + + Returns: + a dict (block_name --> doc string) + """ + doc_strings = dict() + + in_tree = [key.partition('_')[::2] + ( + lambda package: getattr(__import__('gnuradio.' + package), package), + )] + + key_parts = key.split('_') + oot = [ + ('_'.join(key_parts[:i]), '_'.join(key_parts[i:]), __import__) + for i in range(1, len(key_parts)) + ] + + for module_name, init_name, importer in itertools.chain(in_tree, oot): + if not module_name or not init_name: + continue + try: + module = importer(module_name) + break + except ImportError: + continue + else: + return doc_strings + + pattern = re.compile(get_block_regex(init_name)) + + for match in filter(pattern.match, dir(module)): + try: + doc_strings[match] = getattr(module, match).__doc__ + except AttributeError: + continue + + return doc_strings + + +def get_block_regex(block_name): + """ + Helper function to create regular expression for a given block name + Assumes variable type blocks ends with '_x', '_xx', '_xxx', '_vxx', + or "_xx_ts" + """ + + regex = "^" + block_name + r"\w*$" + variable_suffixes = ["_x", "_xx", "_xxx", "_vxx", "_xx_ts"] + for suffix in variable_suffixes: + if block_name.endswith(suffix): + base_name, _, _ = block_name.rpartition(suffix) + var_suffix = suffix.replace("x", r"\w") + regex = "^" + base_name + var_suffix + r"\w*$" + break + return regex + + +def docstring_from_make(key, imports, make): + """ + Extract the documentation from the python __doc__ strings + By importing it and checking a truncated make + + Args: + key: the block key + imports: a list of import statements (string) to execute + make: block constructor template + + Returns: + a list of tuples (block_name, doc string) + """ + + try: + blk_cls = make.partition('(')[0].strip() + if '$' in blk_cls: + raise ValueError('Not an identifier') + ns = dict() + exec(imports.strip(), ns) + blk = eval(blk_cls, ns) + doc_strings = {key: blk.__doc__} + + except (ImportError, AttributeError, SyntaxError, ValueError): + doc_strings = docstring_guess_from_key(key) + + return doc_strings + + +############################################################################### +# Manage docstring extraction in separate process +############################################################################### + +class SubprocessLoader(object): + """ + Start and manage docstring extraction process + Manages subprocess and handles RPC. + """ + + BOOTSTRAP = "import runpy; runpy.run_path({!r}, run_name='__worker__')" + AUTH_CODE = random.random() # sort out unwanted output of worker process + RESTART = 5 # number of worker restarts before giving up + DONE = object() # sentinel value to signal end-of-queue + + def __init__(self, callback_query_result, callback_finished=None): + self.callback_query_result = callback_query_result + self.callback_finished = callback_finished or (lambda: None) + + self._queue = queue.Queue() + self._thread = None + self._worker = None + self._shutdown = threading.Event() + self._last_cmd = None + + def start(self): + """ Start the worker process handler thread """ + if self._thread is not None: + return + self._shutdown.clear() + thread = self._thread = threading.Thread(target=self.run_worker) + thread.daemon = True + thread.start() + + def run_worker(self): + """ Read docstring back from worker stdout and execute callback. """ + for _ in range(self.RESTART): + if self._shutdown.is_set(): + break + try: + self._worker = subprocess.Popen( + args=(sys.executable, '-uc', + self.BOOTSTRAP.format(__file__)), + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + self._handle_worker() + + except (OSError, IOError): + msg = "Warning: restarting the docstring loader" + cmd, args = self._last_cmd + if cmd == 'query': + msg += " (crashed while loading {0!r})".format(args[0]) + print(msg, file=sys.stderr) + continue # restart + else: + break # normal termination, return + finally: + if self._worker: + self._worker.stdin.close() + self._worker.stdout.close() + self._worker.stderr.close() + self._worker.terminate() + self._worker.wait() + else: + print("Warning: docstring loader crashed too often", file=sys.stderr) + self._thread = None + self._worker = None + self.callback_finished() + + def _handle_worker(self): + """ Send commands and responses back from worker. """ + assert '1' == self._worker.stdout.read(1).decode('utf-8') + for cmd, args in iter(self._queue.get, self.DONE): + self._last_cmd = cmd, args + self._send(cmd, args) + cmd, args = self._receive() + self._handle_response(cmd, args) + + def _send(self, cmd, args): + """ Send a command to worker's stdin """ + fd = self._worker.stdin + query = json.dumps((self.AUTH_CODE, cmd, args)) + fd.write(query.encode('utf-8')) + fd.write(b'\n') + fd.flush() + + def _receive(self): + """ Receive response from worker's stdout """ + for line in iter(self._worker.stdout.readline, ''): + try: + key, cmd, args = json.loads(line.decode('utf-8')) + if key != self.AUTH_CODE: + raise ValueError('Got wrong auth code') + return cmd, args + except ValueError: + if self._worker.poll(): + raise IOError("Worker died") + else: + continue # ignore invalid output from worker + else: + raise IOError("Can't read worker response") + + def _handle_response(self, cmd, args): + """ Handle response from worker, call the callback """ + if cmd == 'result': + key, docs = args + self.callback_query_result(key, docs) + elif cmd == 'error': + print(args) + else: + print("Unknown response:", cmd, args, file=sys.stderr) + + def query(self, key, imports=None, make=None): + """ Request docstring extraction for a certain key """ + if self._thread is None: + self.start() + if imports and make: + self._queue.put(('query', (key, imports, make))) + else: + self._queue.put(('query_key_only', (key,))) + + def finish(self): + """ Signal end of requests """ + self._queue.put(self.DONE) + + def wait(self): + """ Wait for the handler thread to die """ + if self._thread: + self._thread.join() + + def terminate(self): + """ Terminate the worker and wait """ + self._shutdown.set() + try: + self._worker.terminate() + self.wait() + except (OSError, AttributeError): + pass + + +############################################################################### +# Main worker entry point +############################################################################### + +def worker_main(): + """ + Main entry point for the docstring extraction process. + Manages RPC with main process through stdin/stdout. + Runs a docstring extraction for each key it read on stdin. + """ + def send(code, cmd, args): + json.dump((code, cmd, args), sys.stdout) + sys.stdout.write('\n') + # fluh out to get new commands from the queue into stdin + sys.stdout.flush() + + sys.stdout.write('1') + # flush out to signal the main process we are ready for new commands + sys.stdout.flush() + for line in iter(sys.stdin.readline, ''): + code, cmd, args = json.loads(line) + try: + if cmd == 'query': + key, imports, make = args + send(code, 'result', (key, docstring_from_make(key, imports, make))) + elif cmd == 'query_key_only': + key, = args + send(code, 'result', (key, docstring_guess_from_key(key))) + elif cmd == 'exit': + break + except Exception as e: + send(code, 'error', repr(e)) + + +if __name__ == '__worker__': + worker_main() + +elif __name__ == '__main__': + def callback(key, docs): + print(key) + for match, doc in docs.items(): + print('-->', match) + print(str(doc).strip()) + print() + print() + + r = SubprocessLoader(callback) + + # r.query('analog_feedforward_agc_cc') + # r.query('uhd_source') + r.query('expr_utils_graph') + r.query('blocks_add_cc') + r.query('blocks_add_cc', ['import gnuradio.blocks'], + 'gnuradio.blocks.add_cc(') + # r.query('analog_feedforward_agc_cc') + # r.query('uhd_source') + # r.query('uhd_source') + # r.query('analog_feedforward_agc_cc') + r.finish() + # r.terminate() + r.wait() diff --git a/grc/core/utils/flow_graph_complexity.py b/grc/core/utils/flow_graph_complexity.py new file mode 100644 index 0000000..5731769 --- /dev/null +++ b/grc/core/utils/flow_graph_complexity.py @@ -0,0 +1,57 @@ + +def calculate(flowgraph): + """ Determines the complexity of a flowgraph """ + + try: + dbal = 0.0 + for block in flowgraph.blocks: + if block.key == "options": + continue + + # Determine the base value for this block + sinks = sum(1.0 for port in block.sinks if not port.optional) + sources = sum(1.0 for port in block.sources if not port.optional) + base = max(min(sinks, sources), 1) + + # Determine the port multiplier + block_connections = 0.0 + for port in block.sources: + block_connections += sum(1.0 for c in port.connections()) + source_multi = max(block_connections / max(sources, 1.0), 1.0) + + # Port ratio multiplier + multi = 1.0 + if min(sinks, sources) > 0: + multi = float(sinks / sources) + multi = float(1 / multi) if multi > 1 else multi + + dbal += base * multi * source_multi + + blocks = float(len(flowgraph.blocks) - 1) + connections = float(len(flowgraph.connections)) + variables = float(len(flowgraph.get_variables())) + + enabled = float(len(flowgraph.get_enabled_blocks())) + enabled_connections = float(len(flowgraph.get_enabled_connections())) + disabled_connections = connections - enabled_connections + + # Disabled multiplier + if enabled > 0: + disabled_multi = 1 / \ + (max(1 - ((blocks - enabled) / max(blocks, 1)), 0.05)) + else: + disabled_multi = 1 + + # Connection multiplier (How many connections ) + if (connections - disabled_connections) > 0: + conn_multi = 1 / \ + (max(1 - (disabled_connections / max(connections, 1)), 0.05)) + else: + conn_multi = 1 + + final = round(max((dbal - 1) * disabled_multi * + conn_multi * connections, 0.0) / 1000000, 6) + return final + + except Exception: + return "" diff --git a/grc/core/utils/hide_bokeh_gui_options_if_not_installed.py b/grc/core/utils/hide_bokeh_gui_options_if_not_installed.py new file mode 100644 index 0000000..ecce5f2 --- /dev/null +++ b/grc/core/utils/hide_bokeh_gui_options_if_not_installed.py @@ -0,0 +1,16 @@ +# Copyright 2008-2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +def hide_bokeh_gui_options_if_not_installed(options_blk): + try: + import bokehgui + except ImportError: + for param in options_blk.parameters_data: + if param['id'] == 'generate_options': + ind = param['options'].index('bokeh_gui') + del param['options'][ind] + del param['option_labels'][ind] diff --git a/grc/grc.conf.in b/grc/grc.conf.in new file mode 100644 index 0000000..24a93a2 --- /dev/null +++ b/grc/grc.conf.in @@ -0,0 +1,12 @@ +# This file contains system wide configuration data for GNU Radio. +# You may override any setting on a per-user basis by editing +# ~/.config/gnuradio/grc.conf + +[grc] +global_blocks_path = @blocksdir@ +local_blocks_path = +examples_path = @examplesdir@ +default_flow_graph = +xterm_executable = @GRC_XTERM_EXE@ +canvas_font_size = 8 +canvas_default_size = 1280, 1024 diff --git a/grc/gui/Actions.py b/grc/gui/Actions.py new file mode 100644 index 0000000..8e9f1f7 --- /dev/null +++ b/grc/gui/Actions.py @@ -0,0 +1,742 @@ +""" +Copyright 2007-2011 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import logging + +from gi.repository import Gtk, Gdk, Gio, GLib, GObject + +from . import Utils + +log = logging.getLogger(__name__) + + +def filter_from_dict(vars): + return filter(lambda x: isinstance(x[1], Action), vars.items()) + + +class Namespace(object): + def __init__(self): + self._actions = {} + + def add(self, action): + key = action.props.name + self._actions[key] = action + + def connect(self, name, handler): + #log.debug("Connecting action <{}> to handler <{}>".format(name, handler.__name__)) + self._actions[name].connect('activate', handler) + + def register(self, + name, + parameter=None, + handler=None, + label=None, + tooltip=None, + icon_name=None, + keypresses=None, + preference_name=None, + default=None): + # Check types + if not isinstance(name, str): + raise TypeError("Cannot register function: 'name' must be a str") + if parameter and not isinstance(parameter, str): + raise TypeError( + "Cannot register function: 'parameter' must be a str") + if handler and not callable(handler): + raise TypeError( + "Cannot register function: 'handler' must be callable") + + # Check if the name has a prefix. + prefix = None + if name.startswith("app.") or name.startswith("win."): + # Set a prefix for later and remove it + prefix = name[0:3] + name = name[4:] + + if handler: + log.debug( + "Register action [{}, prefix={}, param={}, handler={}]".format( + name, prefix, parameter, handler.__name__)) + else: + log.debug( + "Register action [{}, prefix={}, param={}, handler=None]". + format(name, prefix, parameter)) + + action = Action(name, + parameter, + label=label, + tooltip=tooltip, + icon_name=icon_name, + keypresses=keypresses, + prefix=prefix, + preference_name=preference_name, + default=default) + if handler: + action.connect('activate', handler) + + key = name + if prefix: + key = "{}.{}".format(prefix, name) + if prefix == "app": + pass + # self.app.add_action(action) + elif prefix == "win": + pass + # self.win.add_action(action) + + #log.debug("Registering action as '{}'".format(key)) + self._actions[key] = action + return action + + # If the actions namespace is called, trigger an action + def __call__(self, name): + # Try to parse the action string. + valid, action_name, target_value = Action.parse_detailed_name(name) + if not valid: + raise Exception("Invalid action string: '{}'".format(name)) + if action_name not in self._actions: + raise Exception( + "Action '{}' is not registered!".format(action_name)) + + if target_value: + self._actions[action_name].activate(target_value) + else: + self._actions[action_name].activate() + + def __getitem__(self, key): + return self._actions[key] + + def __iter__(self): + return self._actions.itervalues() + + def __repr__(self): + return str(self) + + def get_actions(self): + return self._actions + + def __str__(self): + s = "{Actions:" + for key in self._actions: + s += " {},".format(key) + s = s.rstrip(",") + "}" + return s + + +class Action(Gio.SimpleAction): + + # Change these to normal python properties. + # prefs_name + + def __init__(self, + name, + parameter=None, + label=None, + tooltip=None, + icon_name=None, + keypresses=None, + prefix=None, + preference_name=None, + default=None): + self.name = name + self.label = label + self.tooltip = tooltip + self.icon_name = icon_name + if keypresses: + self.keypresses = [ + kp.replace("", Utils.get_modifier_key(True)) + for kp in keypresses + ] + else: + self.keypresses = None + self.prefix = prefix + self.preference_name = preference_name + self.default = default + + # Don't worry about checking types here, since it's done in register() + # Save the parameter type to use for converting in __call__ + self.type = None + + variant = None + state = None + if parameter: + variant = GLib.VariantType.new(parameter) + if preference_name: + state = GLib.Variant.new_boolean(True) + Gio.SimpleAction.__init__(self, + name=name, + parameter_type=variant, + state=state) + + def enable(self): + self.props.enabled = True + + def disable(self): + self.props.enabled = False + + def set_enabled(self, state): + if not isinstance(state, bool): + raise TypeError("State must be True/False.") + self.props.enabled = state + + def __str__(self): + return self.props.name + + def __repr__(self): + return str(self) + + def get_active(self): + if self.props.state: + return self.props.state.get_boolean() + return False + + def set_active(self, state): + if not isinstance(state, bool): + raise TypeError("State must be True/False.") + self.change_state(GLib.Variant.new_boolean(state)) + + # Allows actions to be directly called. + def __call__(self, parameter=None): + if self.type and parameter: + # Try to convert it to the correct type. + try: + param = GLib.Variant(self.type, parameter) + self.activate(param) + except TypeError: + raise TypeError( + "Invalid parameter type for action '{}'. Expected: '{}'". + format(self.get_name(), self.type)) + else: + self.activate() + + def load_from_preferences(self, *args): + log.debug("load_from_preferences({})".format(args)) + if self.preference_name is not None: + config = Gtk.Application.get_default().config + self.set_active( + config.entry(self.preference_name, default=bool(self.default))) + + def save_to_preferences(self, *args): + log.debug("save_to_preferences({})".format(args)) + if self.preference_name is not None: + config = Gtk.Application.get_default().config + config.entry(self.preference_name, value=self.get_active()) + + +actions = Namespace() + + +def get_actions(): + return actions.get_actions() + + +def connect(action, handler=None): + return actions.connect(action, handler=handler) + + +######################################################################## +# Old Actions +######################################################################## +PAGE_CHANGE = actions.register("win.page_change") +EXTERNAL_UPDATE = actions.register("app.external_update") +VARIABLE_EDITOR_UPDATE = actions.register("app.variable_editor_update") +FLOW_GRAPH_NEW = actions.register( + "app.flowgraph.new", + label='_New', + tooltip='Create a new flow graph', + icon_name='document-new', + keypresses=["n"], +) +FLOW_GRAPH_NEW_TYPE = actions.register( + "app.flowgraph.new_type", + parameter="s", +) +FLOW_GRAPH_OPEN = actions.register( + "app.flowgraph.open", + label='_Open', + tooltip='Open an existing flow graph', + icon_name='document-open', + keypresses=["o"], +) +FLOW_GRAPH_OPEN_RECENT = actions.register( + "app.flowgraph.open_recent", + label='Open _Recent', + tooltip='Open a recently used flow graph', + icon_name='document-open-recent', + parameter="s", +) +FLOW_GRAPH_CLEAR_RECENT = actions.register("app.flowgraph.clear_recent") +FLOW_GRAPH_SAVE = actions.register( + "app.flowgraph.save", + label='_Save', + tooltip='Save the current flow graph', + icon_name='document-save', + keypresses=["s"], +) +FLOW_GRAPH_SAVE_AS = actions.register( + "app.flowgraph.save_as", + label='Save _As', + tooltip='Save the current flow graph as...', + icon_name='document-save-as', + keypresses=["s"], +) +FLOW_GRAPH_SAVE_COPY = actions.register( + "app.flowgraph.save_copy", + label='Save Copy', + tooltip='Save a copy of current flow graph', +) +FLOW_GRAPH_DUPLICATE = actions.register( + "app.flowgraph.duplicate", + label='_Duplicate', + tooltip='Create a duplicate of current flow graph', + # stock_id=Gtk.STOCK_COPY, + keypresses=["d"], +) +FLOW_GRAPH_CLOSE = actions.register( + "app.flowgraph.close", + label='_Close', + tooltip='Close the current flow graph', + icon_name='window-close', + keypresses=["w"], +) +APPLICATION_INITIALIZE = actions.register("app.initialize") +APPLICATION_QUIT = actions.register( + "app.quit", + label='_Quit', + tooltip='Quit program', + icon_name='application-exit', + keypresses=["q"], +) +FLOW_GRAPH_UNDO = actions.register( + "win.undo", + label='_Undo', + tooltip='Undo a change to the flow graph', + icon_name='edit-undo', + keypresses=["z"], +) +FLOW_GRAPH_REDO = actions.register( + "win.redo", + label='_Redo', + tooltip='Redo a change to the flow graph', + icon_name='edit-redo', + keypresses=["y"], +) +NOTHING_SELECT = actions.register("win.unselect") +SELECT_ALL = actions.register( + "win.select_all", + label='Select _All', + tooltip='Select all blocks and connections in the flow graph', + icon_name='edit-select-all', + keypresses=["a"], +) +ELEMENT_SELECT = actions.register("win.select") +ELEMENT_CREATE = actions.register("win.add") +ELEMENT_DELETE = actions.register( + "win.delete", + label='_Delete', + tooltip='Delete the selected blocks', + icon_name='edit-delete', + keypresses=["Delete"], +) +BLOCK_MOVE = actions.register("win.block_move") +BLOCK_ROTATE_CCW = actions.register( + "win.block_rotate_ccw", + label='Rotate Counterclockwise', + tooltip='Rotate the selected blocks 90 degrees to the left', + icon_name='object-rotate-left', + keypresses=["Left"], +) +BLOCK_ROTATE_CW = actions.register( + "win.block_rotate", + label='Rotate Clockwise', + tooltip='Rotate the selected blocks 90 degrees to the right', + icon_name='object-rotate-right', + keypresses=["Right"], +) +BLOCK_VALIGN_TOP = actions.register( + "win.block_align_top", + label='Vertical Align Top', + tooltip='Align tops of selected blocks', + keypresses=["t"], +) +BLOCK_VALIGN_MIDDLE = actions.register( + "win.block_align_middle", + label='Vertical Align Middle', + tooltip='Align centers of selected blocks vertically', + keypresses=["m"], +) +BLOCK_VALIGN_BOTTOM = actions.register( + "win.block_align_bottom", + label='Vertical Align Bottom', + tooltip='Align bottoms of selected blocks', + keypresses=["b"], +) +BLOCK_HALIGN_LEFT = actions.register( + "win.block_align_left", + label='Horizontal Align Left', + tooltip='Align left edges of blocks selected blocks', + keypresses=["l"], +) +BLOCK_HALIGN_CENTER = actions.register( + "win.block_align_center", + label='Horizontal Align Center', + tooltip='Align centers of selected blocks horizontally', + keypresses=["c"], +) +BLOCK_HALIGN_RIGHT = actions.register( + "win.block_align_right", + label='Horizontal Align Right', + tooltip='Align right edges of selected blocks', + keypresses=["r"], +) +BLOCK_ALIGNMENTS = [ + BLOCK_VALIGN_TOP, + BLOCK_VALIGN_MIDDLE, + BLOCK_VALIGN_BOTTOM, + None, + BLOCK_HALIGN_LEFT, + BLOCK_HALIGN_CENTER, + BLOCK_HALIGN_RIGHT, +] +BLOCK_PARAM_MODIFY = actions.register( + "win.block_modify", + label='_Properties', + tooltip='Modify params for the selected block', + icon_name='document-properties', + keypresses=["Return"], +) +BLOCK_ENABLE = actions.register( + "win.block_enable", + label='E_nable', + tooltip='Enable the selected blocks', + icon_name='network-wired', + keypresses=["e"], +) +BLOCK_DISABLE = actions.register( + "win.block_disable", + label='D_isable', + tooltip='Disable the selected blocks', + icon_name='network-wired-disconnected', + keypresses=["d"], +) +BLOCK_BYPASS = actions.register( + "win.block_bypass", + label='_Bypass', + tooltip='Bypass the selected block', + icon_name='media-seek-forward', + keypresses=["b"], +) +ZOOM_IN = actions.register("win.zoom_in", + label='Zoom In', + tooltip='Increase the canvas zoom level', + keypresses=["plus", + "equal", "KP_Add"], + ) +ZOOM_OUT = actions.register("win.zoom_out", + label='Zoom Out', + tooltip='Decrease the canvas zoom level', + keypresses=["minus", "KP_Subtract"], + ) +ZOOM_RESET = actions.register("win.zoom_reset", + label='Reset Zoom', + tooltip='Reset the canvas zoom level', + keypresses=["0", "KP_0"], + ) +TOGGLE_SNAP_TO_GRID = actions.register("win.snap_to_grid", + label='_Snap to grid', + tooltip='Snap blocks to a grid for an easier connection alignment', + preference_name='snap_to_grid', + default=True, + ) +TOGGLE_HIDE_DISABLED_BLOCKS = actions.register( + "win.hide_disabled", + label='Hide _Disabled Blocks', + tooltip='Toggle visibility of disabled blocks and connections', + icon_name='image-missing', + keypresses=["d"], + preference_name='hide_disabled', + default=False, +) +TOGGLE_HIDE_VARIABLES = actions.register( + "win.hide_variables", + label='Hide Variables', + tooltip='Hide all variable blocks', + preference_name='hide_variables', + default=False, +) +TOGGLE_SHOW_PARAMETER_EXPRESSION = actions.register( + "win.show_param_expression", + label='Show parameter expressions in block', + tooltip='Display the expression that defines a parameter inside the block', + preference_name='show_param_expression', + default=False, +) +TOGGLE_SHOW_PARAMETER_EVALUATION = actions.register( + "win.show_param_expression_value", + label='Show parameter value in block', + tooltip='Display the evaluated value of a parameter expressions inside the block', + preference_name='show_param_expression_value', + default=True, +) +TOGGLE_SHOW_BLOCK_IDS = actions.register( + "win.show_block_ids", + label='Show All Block IDs', + tooltip='Show all the block IDs', + preference_name='show_block_ids', + default=False, +) +TOGGLE_SHOW_FIELD_COLORS = actions.register( + "win.show_field_colors", + label='Show properties fields colors', + tooltip='Use colors to indicate the type of each field in the block properties', + preference_name='show_field_colors', + default=False, +) +TOGGLE_FLOW_GRAPH_VAR_EDITOR = actions.register( + "win.toggle_variable_editor", + label='Show _Variable Editor', + tooltip='Show the variable editor. Modify variables and imports in this flow graph', + icon_name='accessories-text-editor', + default=True, + keypresses=["e"], + preference_name='variable_editor_visable', +) +TOGGLE_FLOW_GRAPH_VAR_EDITOR_SIDEBAR = actions.register( + "win.toggle_variable_editor_sidebar", + label='Move the Variable Editor to the Sidebar', + tooltip='Move the variable editor to the sidebar', + default=False, + preference_name='variable_editor_sidebar', +) +TOGGLE_AUTO_HIDE_PORT_LABELS = actions.register( + "win.auto_hide_port_labels", + label='Auto-Hide _Port Labels', + tooltip='Automatically hide port labels', + preference_name='auto_hide_port_labels', + default=False, +) +TOGGLE_SHOW_BLOCK_COMMENTS = actions.register( + "win.show_block_comments", + label='Show Block Comments', + tooltip="Show comment beneath each block", + preference_name='show_block_comments', + default=True, +) +TOGGLE_SHOW_CODE_PREVIEW_TAB = actions.register( + "win.toggle_code_preview", + label='Generated Code Preview', + tooltip="Show a preview of the code generated for each Block in its " + "Properties Dialog", + preference_name='show_generated_code_tab', + default=False, +) +TOGGLE_SHOW_FLOWGRAPH_COMPLEXITY = actions.register( + "win.show_flowgraph_complexity", + label='Show Flowgraph Complexity', + tooltip="How many Balints is the flowgraph...", + preference_name='show_flowgraph_complexity', + default=False, +) +BLOCK_CREATE_HIER = actions.register( + "win.block_create_hier", + label='C_reate Hier', + tooltip='Create hier block from selected blocks', + icon_name='document-new', + keypresses=["c"], +) +BLOCK_CUT = actions.register( + "win.block_cut", + label='Cu_t', + tooltip='Cut', + icon_name='edit-cut', + keypresses=["x"], +) +BLOCK_COPY = actions.register( + "win.block_copy", + label='_Copy', + tooltip='Copy', + icon_name='edit-copy', + keypresses=["c"], +) +BLOCK_PASTE = actions.register( + "win.block_paste", + label='_Paste', + tooltip='Paste', + icon_name='edit-paste', + keypresses=["v"], +) +ERRORS_WINDOW_DISPLAY = actions.register( + "app.errors", + label='Flowgraph _Errors', + tooltip='View flow graph errors', + icon_name='dialog-error', +) +TOGGLE_CONSOLE_WINDOW = actions.register( + "win.toggle_console_window", + label='Show _Console Panel', + tooltip='Toggle visibility of the console', + keypresses=["r"], + preference_name='console_window_visible', + default=True) +# TODO: Might be able to convert this to a Gio.PropertyAction eventually. +# actions would need to be defined in the correct class and not globally +TOGGLE_BLOCKS_WINDOW = actions.register( + "win.toggle_blocks_window", + label='Show _Block Tree Panel', + tooltip='Toggle visibility of the block tree widget', + keypresses=["b"], + preference_name='blocks_window_visible', + default=True) +TOGGLE_SCROLL_LOCK = actions.register( + "win.console.scroll_lock", + label='Console Scroll _Lock', + tooltip='Toggle scroll lock for the console window', + preference_name='scroll_lock', + keypresses=["Scroll_Lock"], + default=False, +) +ABOUT_WINDOW_DISPLAY = actions.register( + "app.about", + label='_About', + tooltip='About this program', + icon_name='help-about', +) +GET_INVOLVED_WINDOW_DISPLAY = actions.register( + "app.get", + label='_Get Involved', + tooltip='Get involved in the community - instructions', + icon_name='help-faq', +) +HELP_WINDOW_DISPLAY = actions.register( + "app.help", + label='_Help', + tooltip='Usage tips', + icon_name='help-contents', + keypresses=["F1"], +) +TYPES_WINDOW_DISPLAY = actions.register( + "app.types", + label='_Types', + tooltip='Types color mapping', + icon_name='dialog-information', +) +KEYBOARD_SHORTCUTS_WINDOW_DISPLAY = actions.register( + "app.keys", + label='_Keys', + tooltip='Keyboard - Shortcuts', + icon_name='dialog-information', + keypresses=["K"], +) +FLOW_GRAPH_GEN = actions.register( + "app.flowgraph.generate", + label='_Generate', + tooltip='Generate the flow graph', + icon_name='insert-object', + keypresses=["F5"], +) +FLOW_GRAPH_EXEC = actions.register( + "app.flowgraph.execute", + label='_Execute', + tooltip='Execute the flow graph', + icon_name='media-playback-start', + keypresses=["F6"], +) +FLOW_GRAPH_KILL = actions.register( + "app.flowgraph.kill", + label='_Kill', + tooltip='Kill the flow graph', + icon_name='media-playback-stop', + keypresses=["F7"], +) +FLOW_GRAPH_SCREEN_CAPTURE = actions.register( + "app.flowgraph.screen_capture", + label='Screen Ca_pture', + tooltip='Create a screen capture of the flow graph', + icon_name='printer', + keypresses=["p"], +) +PORT_CONTROLLER_DEC = actions.register( + "win.port_controller_dec", + keypresses=["KP_Subtract", "minus"], +) +PORT_CONTROLLER_INC = actions.register( + "win.port_controller_inc", + keypresses=["KP_Add", "plus"], +) +BLOCK_INC_TYPE = actions.register( + "win.block_inc_type", + keypresses=["Down"], +) +BLOCK_DEC_TYPE = actions.register( + "win.block_dec_type", + keypresses=["Up"], +) +RELOAD_BLOCKS = actions.register("app.reload_blocks", + label='Reload _Blocks', + tooltip='Reload Blocks', + icon_name='view-refresh') +FIND_BLOCKS = actions.register( + "win.find_blocks", + label='_Find Blocks', + tooltip='Search for a block by name (and key)', + icon_name='edit-find', + keypresses=["f", "slash"], +) +CLEAR_CONSOLE = actions.register( + "win.console.clear", + label='_Clear Console', + tooltip='Clear Console', + icon_name='edit-clear', + keypresses=["l"], +) +SAVE_CONSOLE = actions.register( + "win.console.save", + label='_Save Console', + tooltip='Save Console', + icon_name='edit-save', + keypresses=["p"], +) +OPEN_HIER = actions.register( + "win.open_hier", + label='Open H_ier', + tooltip='Open the source of the selected hierarchical block', + icon_name='go-jump', +) +BUSSIFY_SOURCES = actions.register( + "win.bussify_sources", + label='Toggle So_urce Bus', + tooltip='Gang source ports into a single bus port', + icon_name='go-jump', +) +BUSSIFY_SINKS = actions.register( + "win.bussify_sinks", + label='Toggle S_ink Bus', + tooltip='Gang sink ports into a single bus port', + icon_name='go-jump', +) +XML_PARSER_ERRORS_DISPLAY = actions.register( + "app.xml_errors", + label='_Parser Errors', + tooltip='View errors that occurred while parsing XML files', + icon_name='dialog-error', +) +FLOW_GRAPH_OPEN_QSS_THEME = actions.register( + "app.open_qss_theme", + label='Set Default QT GUI _Theme', + tooltip='Set a default QT Style Sheet file to use for QT GUI', + icon_name='document-open', +) +TOOLS_RUN_FDESIGN = actions.register( + "app.filter_design", + label='Filter Design Tool', + tooltip='Execute gr_filter_design', + icon_name='media-playback-start', +) +POST_HANDLER = actions.register("app.post_handler") +READY = actions.register("app.ready") diff --git a/grc/gui/Application.py b/grc/gui/Application.py new file mode 100644 index 0000000..64b7a4d --- /dev/null +++ b/grc/gui/Application.py @@ -0,0 +1,901 @@ +""" +Copyright 2007-2011 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import logging +import os +import subprocess + +from gi.repository import Gtk, Gio, GLib, GObject +from getpass import getuser + +from . import Constants, Dialogs, Actions, Executor, FileDialogs, Utils, Bars + +from .MainWindow import MainWindow +# from .ParserErrorsDialog import ParserErrorsDialog +from .PropsDialog import PropsDialog + +from ..core import Messages +from ..core.Connection import Connection +from ..core.blocks import Block + + +log = logging.getLogger(__name__) + + +class Application(Gtk.Application): + """ + The action handler will setup all the major window components, + and handle button presses and flow graph operations from the GUI. + """ + + def __init__(self, file_paths, platform): + Gtk.Application.__init__(self) + """ + Application constructor. + Create the main window, setup the message handler, import the preferences, + and connect all of the action handlers. Finally, enter the gtk main loop and block. + + Args: + file_paths: a list of flow graph file passed from command line + platform: platform module + """ + self.clipboard = None + self.dialog = None + + # Setup the main window + self.platform = platform + self.config = platform.config + + log.debug("Application()") + # Connect all actions to _handle_action + for x in Actions.get_actions(): + Actions.connect(x, handler=self._handle_action) + Actions.actions[x].enable() + if x.startswith("app."): + self.add_action(Actions.actions[x]) + # Setup the shortcut keys + # These are the globally defined shortcuts + keypress = Actions.actions[x].keypresses + if keypress: + self.set_accels_for_action(x, keypress) + + # Initialize + self.init_file_paths = [os.path.abspath( + file_path) for file_path in file_paths] + self.init = False + + def do_startup(self): + Gtk.Application.do_startup(self) + log.debug("Application.do_startup()") + + def do_activate(self): + Gtk.Application.do_activate(self) + log.debug("Application.do_activate()") + + self.main_window = MainWindow(self, self.platform) + self.main_window.connect('delete-event', self._quit) + self.get_focus_flag = self.main_window.get_focus_flag + + # setup the messages + Messages.register_messenger(self.main_window.add_console_line) + Messages.send_init(self.platform) + + log.debug("Calling Actions.APPLICATION_INITIALIZE") + Actions.APPLICATION_INITIALIZE() + + def _quit(self, window, event): + """ + Handle the delete event from the main window. + Generated by pressing X to close, alt+f4, or right click+close. + This method in turns calls the state handler to quit. + + Returns: + true + """ + Actions.APPLICATION_QUIT() + return True + + def _handle_action(self, action, *args): + log.debug("_handle_action({0}, {1})".format(action, args)) + main = self.main_window + page = main.current_page + flow_graph = page.flow_graph if page else None + + def flow_graph_update(fg=flow_graph): + main.vars.update_gui(fg.blocks) + fg.update() + + ################################################## + # Initialize/Quit + ################################################## + if action == Actions.APPLICATION_INITIALIZE: + log.debug("APPLICATION_INITIALIZE") + file_path_to_show = self.config.file_open() + for file_path in (self.init_file_paths or self.config.get_open_files()): + if os.path.exists(file_path): + main.new_page( + file_path, show=file_path_to_show == file_path) + if not main.current_page: + main.new_page() # ensure that at least a blank page exists + + main.btwin.search_entry.hide() + + """ + Only disable certain actions on startup. Each of these actions are + conditionally enabled in _handle_action, so disable them first. + - FLOW_GRAPH_UNDO/REDO are set in gui/StateCache.py + - XML_PARSER_ERRORS_DISPLAY is set in RELOAD_BLOCKS + + TODO: These 4 should probably be included, but they are not currently + enabled anywhere else: + - PORT_CONTROLLER_DEC, PORT_CONTROLLER_INC + - BLOCK_INC_TYPE, BLOCK_DEC_TYPE + + TODO: These should be handled better. They are set in + update_exec_stop(), but not anywhere else + - FLOW_GRAPH_GEN, FLOW_GRAPH_EXEC, FLOW_GRAPH_KILL + """ + for action in ( + Actions.ERRORS_WINDOW_DISPLAY, + Actions.ELEMENT_DELETE, + Actions.BLOCK_PARAM_MODIFY, + Actions.BLOCK_ROTATE_CCW, + Actions.BLOCK_ROTATE_CW, + Actions.BLOCK_VALIGN_TOP, + Actions.BLOCK_VALIGN_MIDDLE, + Actions.BLOCK_VALIGN_BOTTOM, + Actions.BLOCK_HALIGN_LEFT, + Actions.BLOCK_HALIGN_CENTER, + Actions.BLOCK_HALIGN_RIGHT, + Actions.BLOCK_CUT, + Actions.BLOCK_COPY, + Actions.BLOCK_PASTE, + Actions.BLOCK_ENABLE, + Actions.BLOCK_DISABLE, + Actions.BLOCK_BYPASS, + Actions.BLOCK_CREATE_HIER, + Actions.OPEN_HIER, + Actions.BUSSIFY_SOURCES, + Actions.BUSSIFY_SINKS, + Actions.FLOW_GRAPH_SAVE, + Actions.FLOW_GRAPH_UNDO, + Actions.FLOW_GRAPH_REDO, + Actions.XML_PARSER_ERRORS_DISPLAY + ): + action.disable() + + # Load preferences + for action in ( + Actions.TOGGLE_BLOCKS_WINDOW, + Actions.TOGGLE_CONSOLE_WINDOW, + Actions.TOGGLE_HIDE_DISABLED_BLOCKS, + Actions.TOGGLE_SCROLL_LOCK, + Actions.TOGGLE_AUTO_HIDE_PORT_LABELS, + Actions.TOGGLE_SNAP_TO_GRID, + Actions.TOGGLE_SHOW_BLOCK_COMMENTS, + Actions.TOGGLE_SHOW_CODE_PREVIEW_TAB, + Actions.TOGGLE_SHOW_FLOWGRAPH_COMPLEXITY, + Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR, + Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR_SIDEBAR, + Actions.TOGGLE_HIDE_VARIABLES, + Actions.TOGGLE_SHOW_PARAMETER_EXPRESSION, + Actions.TOGGLE_SHOW_PARAMETER_EVALUATION, + Actions.TOGGLE_SHOW_BLOCK_IDS, + Actions.TOGGLE_SHOW_FIELD_COLORS, + ): + action.set_enabled(True) + if hasattr(action, 'load_from_preferences'): + action.load_from_preferences() + + # Hide the panels *IF* it's saved in preferences + main.update_panel_visibility( + main.BLOCKS, Actions.TOGGLE_BLOCKS_WINDOW.get_active()) + main.update_panel_visibility( + main.CONSOLE, Actions.TOGGLE_CONSOLE_WINDOW.get_active()) + main.update_panel_visibility( + main.VARIABLES, Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR.get_active()) + + # Force an update on the current page to match loaded preferences. + # In the future, change the __init__ order to load preferences first + page = main.current_page + if page: + page.flow_graph.update() + + self.init = True + elif action == Actions.APPLICATION_QUIT: + if main.close_pages(): + while Gtk.main_level(): + Gtk.main_quit() + exit(0) + ################################################## + # Selections + ################################################## + elif action == Actions.ELEMENT_SELECT: + pass # do nothing, update routines below + elif action == Actions.NOTHING_SELECT: + flow_graph.unselect() + elif action == Actions.SELECT_ALL: + if main.btwin.search_entry.has_focus(): + main.btwin.search_entry.select_region(0, -1) + else: + flow_graph.select_all() + ################################################## + # Enable/Disable + ################################################## + elif action in (Actions.BLOCK_ENABLE, Actions.BLOCK_DISABLE, Actions.BLOCK_BYPASS): + changed = flow_graph.change_state_selected(new_state={ + Actions.BLOCK_ENABLE: 'enabled', + Actions.BLOCK_DISABLE: 'disabled', + Actions.BLOCK_BYPASS: 'bypassed', + }[action]) + if changed: + flow_graph_update() + page.flow_graph.update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + ################################################## + # Cut/Copy/Paste + ################################################## + elif action == Actions.BLOCK_CUT: + Actions.BLOCK_COPY() + Actions.ELEMENT_DELETE() + elif action == Actions.BLOCK_COPY: + self.clipboard = flow_graph.copy_to_clipboard() + elif action == Actions.BLOCK_PASTE: + if self.clipboard: + flow_graph.paste_from_clipboard(self.clipboard) + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + ################################################## + # Create hier block + ################################################## + elif action == Actions.BLOCK_CREATE_HIER: + + selected_blocks = [] + + pads = [] + params = set() + + for block in flow_graph.selected_blocks(): + selected_blocks.append(block) + # Check for string variables within the blocks + for param in block.params.values(): + for variable in flow_graph.get_variables(): + # If a block parameter exists that is a variable, create a parameter for it + if param.get_value() == variable.name: + params.add(param.get_value()) + for flow_param in flow_graph.get_parameters(): + # If a block parameter exists that is a parameter, create a parameter for it + if param.get_value() == flow_param.name: + params.add(param.get_value()) + + x_min = min(block.coordinate[0] for block in selected_blocks) + y_min = min(block.coordinate[1] for block in selected_blocks) + + for connection in flow_graph.connections: + + # Get id of connected blocks + source = connection.source_block + sink = connection.sink_block + + if source not in selected_blocks and sink in selected_blocks: + # Create Pad Source + pads.append({ + 'key': connection.sink_port.key, + 'coord': source.coordinate, + # Ignore the options block + 'block_index': selected_blocks.index(sink) + 1, + 'direction': 'source' + }) + + elif sink not in selected_blocks and source in selected_blocks: + # Create Pad Sink + pads.append({ + 'key': connection.source_port.key, + 'coord': sink.coordinate, + # Ignore the options block + 'block_index': selected_blocks.index(source) + 1, + 'direction': 'sink' + }) + + # Copy the selected blocks and paste them into a new page + # then move the flowgraph to a reasonable position + Actions.BLOCK_COPY() + main.new_page() + flow_graph = main.current_page.flow_graph + Actions.BLOCK_PASTE() + coords = (x_min, y_min) + flow_graph.move_selected(coords) + + # Set flow graph to heir block type + top_block = flow_graph.get_block(Constants.DEFAULT_FLOW_GRAPH_ID) + top_block.params['generate_options'].set_value('hb') + + # this needs to be a unique name + top_block.params['id'].set_value('new_hier') + + # Remove the default samp_rate variable block that is created + remove_me = flow_graph.get_block("samp_rate") + flow_graph.remove_element(remove_me) + + # Add the param blocks along the top of the window + x_pos = 150 + for param in params: + param_id = flow_graph.add_new_block('parameter', (x_pos, 10)) + param_block = flow_graph.get_block(param_id) + param_block.params['id'].set_value(param) + x_pos = x_pos + 100 + + for pad in pads: + # add the pad sources and sinks within the new hier block + if pad['direction'] == 'sink': + + # add new pad_sink block to the canvas + pad_id = flow_graph.add_new_block('pad_sink', pad['coord']) + + # setup the references to the sink and source + pad_block = flow_graph.get_block(pad_id) + pad_sink = pad_block.sinks[0] + + source_block = flow_graph.get_block( + flow_graph.blocks[pad['block_index']].name) + source = source_block.get_source(pad['key']) + + # ensure the port types match + if pad_sink.dtype != source.dtype: + if pad_sink.dtype == 'complex' and source.dtype == 'fc32': + pass + else: + pad_block.params['type'].value = source.dtype + pad_sink.dtype = source.dtype + + # connect the pad to the proper sinks + new_connection = flow_graph.connect(source, pad_sink) + + elif pad['direction'] == 'source': + pad_id = flow_graph.add_new_block( + 'pad_source', pad['coord']) + + # setup the references to the sink and source + pad_block = flow_graph.get_block(pad_id) + pad_source = pad_block.sources[0] + + sink_block = flow_graph.get_block( + flow_graph.blocks[pad['block_index']].name) + sink = sink_block.get_sink(pad['key']) + + # ensure the port types match + if pad_source.dtype != sink.dtype: + if pad_source.dtype == 'complex' and sink.dtype == 'fc32': + pass + else: + pad_block.params['type'].value = sink.dtype + pad_source.dtype = sink.dtype + + # connect the pad to the proper sinks + new_connection = flow_graph.connect(pad_source, sink) + + flow_graph_update(flow_graph) + ################################################## + # Move/Rotate/Delete/Create + ################################################## + elif action == Actions.BLOCK_MOVE: + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + elif action in Actions.BLOCK_ALIGNMENTS: + if flow_graph.align_selected(action): + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + elif action == Actions.BLOCK_ROTATE_CCW: + if flow_graph.rotate_selected(90): + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + elif action == Actions.BLOCK_ROTATE_CW: + if flow_graph.rotate_selected(-90): + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + elif action == Actions.ELEMENT_DELETE: + if flow_graph.remove_selected(): + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + Actions.NOTHING_SELECT() + page.saved = False + elif action == Actions.ELEMENT_CREATE: + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + Actions.NOTHING_SELECT() + page.saved = False + elif action == Actions.BLOCK_INC_TYPE: + if flow_graph.type_controller_modify_selected(1): + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + elif action == Actions.BLOCK_DEC_TYPE: + if flow_graph.type_controller_modify_selected(-1): + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + elif action == Actions.PORT_CONTROLLER_INC: + if flow_graph.port_controller_modify_selected(1): + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + elif action == Actions.PORT_CONTROLLER_DEC: + if flow_graph.port_controller_modify_selected(-1): + flow_graph_update() + page.state_cache.save_new_state(flow_graph.export_data()) + page.saved = False + ################################################## + # Window stuff + ################################################## + elif action == Actions.ABOUT_WINDOW_DISPLAY: + Dialogs.show_about(main, self.platform.config) + elif action == Actions.HELP_WINDOW_DISPLAY: + Dialogs.show_help(main) + elif action == Actions.GET_INVOLVED_WINDOW_DISPLAY: + Dialogs.show_get_involved(main) + elif action == Actions.TYPES_WINDOW_DISPLAY: + Dialogs.show_types(main) + elif action == Actions.KEYBOARD_SHORTCUTS_WINDOW_DISPLAY: + Dialogs.show_keyboard_shortcuts(main) + elif action == Actions.ERRORS_WINDOW_DISPLAY: + Dialogs.ErrorsDialog(main, flow_graph).run_and_destroy() + elif action == Actions.TOGGLE_CONSOLE_WINDOW: + action.set_active(not action.get_active()) + main.update_panel_visibility(main.CONSOLE, action.get_active()) + action.save_to_preferences() + elif action == Actions.TOGGLE_BLOCKS_WINDOW: + # This would be better matched to a Gio.PropertyAction, but to do + # this, actions would have to be defined in the window not globally + action.set_active(not action.get_active()) + main.update_panel_visibility(main.BLOCKS, action.get_active()) + action.save_to_preferences() + elif action == Actions.TOGGLE_SCROLL_LOCK: + action.set_active(not action.get_active()) + active = action.get_active() + main.console.text_display.scroll_lock = active + if active: + main.console.text_display.scroll_to_end() + action.save_to_preferences() + elif action == Actions.CLEAR_CONSOLE: + main.console.text_display.clear() + elif action == Actions.SAVE_CONSOLE: + file_path = FileDialogs.SaveConsole(main, page.file_path).run() + if file_path is not None: + main.console.text_display.save(file_path) + elif action == Actions.TOGGLE_HIDE_DISABLED_BLOCKS: + action.set_active(not action.get_active()) + flow_graph_update() + action.save_to_preferences() + page.state_cache.save_new_state(flow_graph.export_data()) + Actions.NOTHING_SELECT() + elif action == Actions.TOGGLE_AUTO_HIDE_PORT_LABELS: + action.set_active(not action.get_active()) + action.save_to_preferences() + for page in main.get_pages(): + page.flow_graph.create_shapes() + elif action in (Actions.TOGGLE_SNAP_TO_GRID, + Actions.TOGGLE_SHOW_BLOCK_COMMENTS, + Actions.TOGGLE_SHOW_CODE_PREVIEW_TAB): + action.set_active(not action.get_active()) + action.save_to_preferences() + elif action == Actions.TOGGLE_SHOW_FLOWGRAPH_COMPLEXITY: + action.set_active(not action.get_active()) + action.save_to_preferences() + for page in main.get_pages(): + flow_graph_update(page.flow_graph) + elif action == Actions.TOGGLE_SHOW_PARAMETER_EXPRESSION: + action.set_active(not action.get_active()) + action.save_to_preferences() + flow_graph_update() + elif action == Actions.TOGGLE_SHOW_PARAMETER_EVALUATION: + action.set_active(not action.get_active()) + action.save_to_preferences() + flow_graph_update() + elif action == Actions.TOGGLE_HIDE_VARIABLES: + action.set_active(not action.get_active()) + active = action.get_active() + # Either way, triggering this should simply trigger the variable editor + # to be visible. + varedit = Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR + if active: + log.debug( + "Variables are hidden. Forcing the variable panel to be visible.") + varedit.disable() + else: + varedit.enable() + # Just force it to show. + varedit.set_active(True) + main.update_panel_visibility(main.VARIABLES) + Actions.NOTHING_SELECT() + action.save_to_preferences() + varedit.save_to_preferences() + flow_graph_update() + elif action == Actions.TOGGLE_SHOW_BLOCK_IDS: + action.set_active(not action.get_active()) + active = action.get_active() + Actions.NOTHING_SELECT() + action.save_to_preferences() + flow_graph_update() + elif action == Actions.TOGGLE_SHOW_FIELD_COLORS: + action.set_active(not action.get_active()) + action.save_to_preferences() + elif action == Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR: + # TODO: There may be issues at startup since these aren't triggered + # the same was as Gtk.Actions when loading preferences. + action.set_active(not action.get_active()) + # Just assume this was triggered because it was enabled. + main.update_panel_visibility(main.VARIABLES, action.get_active()) + action.save_to_preferences() + + # Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR_SIDEBAR.set_enabled(action.get_active()) + action.save_to_preferences() + elif action == Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR_SIDEBAR: + action.set_active(not action.get_active()) + if self.init: + Dialogs.MessageDialogWrapper( + main, Gtk.MessageType.INFO, Gtk.ButtonsType.CLOSE, + markup="Moving the variable editor requires a restart of GRC." + ).run_and_destroy() + action.save_to_preferences() + elif action == Actions.ZOOM_IN: + page.drawing_area.zoom_in() + elif action == Actions.ZOOM_OUT: + page.drawing_area.zoom_out() + elif action == Actions.ZOOM_RESET: + page.drawing_area.reset_zoom() + ################################################## + # Param Modifications + ################################################## + elif action == Actions.BLOCK_PARAM_MODIFY: + selected_block = args[0] if args[0] else flow_graph.selected_block + selected_conn = args[0] if args[0] else flow_graph.selected_connection + if selected_block and isinstance(selected_block, Block): + self.dialog = PropsDialog(self.main_window, selected_block) + response = Gtk.ResponseType.APPLY + while response == Gtk.ResponseType.APPLY: # rerun the dialog if Apply was hit + response = self.dialog.run() + if response in (Gtk.ResponseType.APPLY, Gtk.ResponseType.ACCEPT): + page.state_cache.save_new_state( + flow_graph.export_data()) + # Following line forces a complete update of io ports + flow_graph_update() + page.saved = False + if response in (Gtk.ResponseType.REJECT, Gtk.ResponseType.ACCEPT): + n = page.state_cache.get_current_state() + flow_graph.import_data(n) + flow_graph_update() + if response == Gtk.ResponseType.APPLY: + # null action, that updates the main window + Actions.ELEMENT_SELECT() + self.dialog.destroy() + self.dialog = None + elif selected_conn and isinstance(selected_conn, Connection): + self.dialog = PropsDialog(self.main_window, selected_conn) + response = Gtk.ResponseType.APPLY + while response == Gtk.ResponseType.APPLY: # rerun the dialog if Apply was hit + response = self.dialog.run() + if response in (Gtk.ResponseType.APPLY, Gtk.ResponseType.ACCEPT): + page.state_cache.save_new_state( + flow_graph.export_data()) + # Following line forces a complete update of io ports + flow_graph_update() + page.saved = False + if response in (Gtk.ResponseType.REJECT, Gtk.ResponseType.ACCEPT): + curr_state = page.state_cache.get_current_state() + flow_graph.import_data(curr_state) + flow_graph_update() + if response == Gtk.ResponseType.APPLY: + # null action, that updates the main window + Actions.ELEMENT_SELECT() + self.dialog.destroy() + self.dialog = None + elif action == Actions.EXTERNAL_UPDATE: + page.state_cache.save_new_state(flow_graph.export_data()) + flow_graph_update() + if self.dialog is not None: + self.dialog.update_gui(force=True) + page.saved = False + elif action == Actions.VARIABLE_EDITOR_UPDATE: + page.state_cache.save_new_state(flow_graph.export_data()) + flow_graph_update() + page.saved = False + ################################################## + # View Parser Errors + ################################################## + elif action == Actions.XML_PARSER_ERRORS_DISPLAY: + pass + ################################################## + # Undo/Redo + ################################################## + elif action == Actions.FLOW_GRAPH_UNDO: + n = page.state_cache.get_prev_state() + if n: + flow_graph.unselect() + flow_graph.import_data(n) + flow_graph_update() + page.saved = False + elif action == Actions.FLOW_GRAPH_REDO: + n = page.state_cache.get_next_state() + if n: + flow_graph.unselect() + flow_graph.import_data(n) + flow_graph_update() + page.saved = False + ################################################## + # New/Open/Save/Close + ################################################## + elif action == Actions.FLOW_GRAPH_NEW: + main.new_page() + args = (GLib.Variant('s', 'qt_gui'),) + flow_graph = main.current_page.flow_graph + flow_graph.options_block.params['generate_options'].set_value(args[0].get_string()) + flow_graph.options_block.params['author'].set_value(getuser()) + flow_graph_update(flow_graph) + elif action == Actions.FLOW_GRAPH_NEW_TYPE: + main.new_page() + if args: + flow_graph = main.current_page.flow_graph + flow_graph.options_block.params['generate_options'].set_value(args[0].get_string()) + flow_graph_update(flow_graph) + elif action == Actions.FLOW_GRAPH_OPEN: + file_paths = args[0] if args[0] else FileDialogs.OpenFlowGraph( + main, page.file_path).run() + if file_paths: # Open a new page for each file, show only the first + for i, file_path in enumerate(file_paths): + main.new_page(file_path, show=(i == 0)) + self.config.add_recent_file(file_path) + main.tool_bar.refresh_submenus() + main.menu.refresh_submenus() + elif action == Actions.FLOW_GRAPH_OPEN_QSS_THEME: + file_paths = FileDialogs.OpenQSS(main, self.platform.config.install_prefix + + '/share/gnuradio/themes/').run() + if file_paths: + self.platform.config.default_qss_theme = file_paths[0] + elif action == Actions.FLOW_GRAPH_CLOSE: + main.close_page() + elif action == Actions.FLOW_GRAPH_OPEN_RECENT: + file_path = args[0].get_string() + main.new_page(file_path, show=True) + self.config.add_recent_file(file_path) + main.tool_bar.refresh_submenus() + main.menu.refresh_submenus() + elif action == Actions.FLOW_GRAPH_SAVE: + # read-only or undefined file path, do save-as + if page.get_read_only() or not page.file_path: + Actions.FLOW_GRAPH_SAVE_AS() + # otherwise try to save + else: + try: + self.platform.save_flow_graph(page.file_path, flow_graph) + flow_graph.grc_file_path = page.file_path + page.saved = True + except IOError: + Messages.send_fail_save(page.file_path) + page.saved = False + elif action == Actions.FLOW_GRAPH_SAVE_AS: + file_path = FileDialogs.SaveFlowGraph(main, page.file_path).run() + + if file_path is not None: + if flow_graph.options_block.params['id'].get_value() == Constants.DEFAULT_FLOW_GRAPH_ID: + file_name = os.path.basename(file_path).replace(".grc", "") + flow_graph.options_block.params['id'].set_value(file_name) + flow_graph_update(flow_graph) + + page.file_path = os.path.abspath(file_path) + try: + self.platform.save_flow_graph(page.file_path, flow_graph) + flow_graph.grc_file_path = page.file_path + page.saved = True + except IOError: + Messages.send_fail_save(page.file_path) + page.saved = False + self.config.add_recent_file(file_path) + main.tool_bar.refresh_submenus() + main.menu.refresh_submenus() + elif action == Actions.FLOW_GRAPH_SAVE_COPY: + try: + if not page.file_path: + # Make sure the current flowgraph has been saved + Actions.FLOW_GRAPH_SAVE_AS() + else: + dup_file_path = page.file_path + # Assuming .grc extension at the end of file_path + dup_file_name = '.'.join( + dup_file_path.split('.')[:-1]) + "_copy" + dup_file_path_temp = dup_file_name + Constants.FILE_EXTENSION + count = 1 + while os.path.exists(dup_file_path_temp): + dup_file_path_temp = '{}({}){}'.format( + dup_file_name, count, Constants.FILE_EXTENSION) + count += 1 + dup_file_path_user = FileDialogs.SaveFlowGraph( + main, dup_file_path_temp).run() + if dup_file_path_user is not None: + self.platform.save_flow_graph( + dup_file_path_user, flow_graph) + Messages.send('Saved Copy to: "' + + dup_file_path_user + '"\n') + except IOError: + Messages.send_fail_save( + "Can not create a copy of the flowgraph\n") + elif action == Actions.FLOW_GRAPH_DUPLICATE: + previous = flow_graph + # Create a new page + main.new_page() + page = main.current_page + new_flow_graph = page.flow_graph + # Import the old data and mark the current as not saved + new_flow_graph.import_data(previous.export_data()) + flow_graph_update(new_flow_graph) + page.state_cache.save_new_state(new_flow_graph.export_data()) + page.saved = False + elif action == Actions.FLOW_GRAPH_SCREEN_CAPTURE: + file_path, background_transparent = FileDialogs.SaveScreenShot( + main, page.file_path).run() + if file_path is not None: + try: + Utils.make_screenshot( + flow_graph, file_path, background_transparent) + except ValueError: + Messages.send('Failed to generate screen shot\n') + ################################################## + # Gen/Exec/Stop + ################################################## + elif action == Actions.FLOW_GRAPH_GEN: + self.generator = None + if not page.process: + if not page.saved or not page.file_path: + Actions.FLOW_GRAPH_SAVE() # only save if file path missing or not saved + if page.saved and page.file_path: + generator = page.get_generator() + try: + Messages.send_start_gen(generator.file_path) + generator.write() + self.generator = generator + except Exception as e: + Messages.send_fail_gen(e) + + elif action == Actions.FLOW_GRAPH_EXEC: + if not page.process: + Actions.FLOW_GRAPH_GEN() + if self.generator: + xterm = self.platform.config.xterm_executable + if self.config.xterm_missing() != xterm: + if not os.path.exists(xterm): + Dialogs.show_missing_xterm(main, xterm) + self.config.xterm_missing(xterm) + if page.saved and page.file_path: + # Save config before execution + self.config.save() + Executor.ExecFlowGraphThread( + flow_graph_page=page, + xterm_executable=xterm, + callback=self.update_exec_stop + ) + elif action == Actions.FLOW_GRAPH_KILL: + if page.process: + try: + page.process.terminate() + except OSError: + print("could not terminate process: %d" % page.process.pid) + + elif action == Actions.PAGE_CHANGE: # pass and run the global actions + flow_graph_update() + elif action == Actions.RELOAD_BLOCKS: + self.platform.build_library() + main.btwin.repopulate() + + # todo: implement parser error dialog for YAML + + # Force a redraw of the graph, by getting the current state and re-importing it + main.update_pages() + + elif action == Actions.FIND_BLOCKS: + main.update_panel_visibility(main.BLOCKS, True) + main.btwin.search_entry.show() + main.btwin.search_entry.grab_focus() + elif action == Actions.OPEN_HIER: + for b in flow_graph.selected_blocks(): + grc_source = b.extra_data.get('grc_source', '') + if grc_source: + main.new_page(grc_source, show=True) + elif action == Actions.BUSSIFY_SOURCES: + for b in flow_graph.selected_blocks(): + b.bussify('source') + flow_graph._old_selected_port = None + flow_graph._new_selected_port = None + Actions.ELEMENT_CREATE() + + elif action == Actions.BUSSIFY_SINKS: + for b in flow_graph.selected_blocks(): + b.bussify('sink') + flow_graph._old_selected_port = None + flow_graph._new_selected_port = None + Actions.ELEMENT_CREATE() + + elif action == Actions.TOOLS_RUN_FDESIGN: + subprocess.Popen('gr_filter_design', + shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + else: + log.warning('!!! Action "%s" not handled !!!' % action) + ################################################## + # Global Actions for all States + ################################################## + page = main.current_page # page and flow graph might have changed + flow_graph = page.flow_graph if page else None + + selected_blocks = list(flow_graph.selected_blocks()) + selected_block = selected_blocks[0] if selected_blocks else None + # See if a connection has modifiable parameters or grey out the entry + # in the menu + selected_connections = list(flow_graph.selected_connections()) + selected_connection = selected_connections[0] \ + if len(selected_connections) == 1 \ + else None + selected_conn_has_params = selected_connection and bool(len(selected_connection.params)) + + # update general buttons + Actions.ERRORS_WINDOW_DISPLAY.set_enabled(not flow_graph.is_valid()) + Actions.ELEMENT_DELETE.set_enabled(bool(flow_graph.selected_elements)) + Actions.BLOCK_PARAM_MODIFY.set_enabled(bool(selected_block) or bool(selected_conn_has_params)) + Actions.BLOCK_ROTATE_CCW.set_enabled(bool(selected_blocks)) + Actions.BLOCK_ROTATE_CW.set_enabled(bool(selected_blocks)) + # update alignment options + for act in Actions.BLOCK_ALIGNMENTS: + if act: + act.set_enabled(len(selected_blocks) > 1) + # update cut/copy/paste + Actions.BLOCK_CUT.set_enabled(bool(selected_blocks)) + Actions.BLOCK_COPY.set_enabled(bool(selected_blocks)) + Actions.BLOCK_PASTE.set_enabled(bool(self.clipboard)) + # update enable/disable/bypass + can_enable = any(block.state != 'enabled' + for block in selected_blocks) + can_disable = any(block.state != 'disabled' + for block in selected_blocks) + can_bypass_all = ( + all(block.can_bypass() for block in selected_blocks) and + any(not block.get_bypassed() for block in selected_blocks) + ) + Actions.BLOCK_ENABLE.set_enabled(can_enable) + Actions.BLOCK_DISABLE.set_enabled(can_disable) + Actions.BLOCK_BYPASS.set_enabled(can_bypass_all) + + Actions.BLOCK_CREATE_HIER.set_enabled(bool(selected_blocks)) + Actions.OPEN_HIER.set_enabled(bool(selected_blocks)) + Actions.BUSSIFY_SOURCES.set_enabled(any(block.sources for block in selected_blocks)) + Actions.BUSSIFY_SINKS.set_enabled(any(block.sinks for block in selected_blocks)) + Actions.RELOAD_BLOCKS.enable() + Actions.FIND_BLOCKS.enable() + + self.update_exec_stop() + + Actions.FLOW_GRAPH_SAVE.set_enabled(not page.saved) + main.update() + + flow_graph.update_selected() + page.drawing_area.queue_draw() + + return True # Action was handled + + def update_exec_stop(self): + """ + Update the exec and stop buttons. + Lock and unlock the mutex for race conditions with exec flow graph threads. + """ + page = self.main_window.current_page + sensitive = page.flow_graph.is_valid() and not page.process + Actions.FLOW_GRAPH_GEN.set_enabled(sensitive) + Actions.FLOW_GRAPH_EXEC.set_enabled(sensitive) + Actions.FLOW_GRAPH_KILL.set_enabled(page.process is not None) diff --git a/grc/gui/Bars.py b/grc/gui/Bars.py new file mode 100644 index 0000000..cde1f86 --- /dev/null +++ b/grc/gui/Bars.py @@ -0,0 +1,330 @@ +""" +Copyright 2007, 2008, 2009, 2015, 2016 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import logging + +from gi.repository import Gtk, GObject, Gio, GLib + +from . import Actions + + +log = logging.getLogger(__name__) + + +''' +# Menu/Toolbar Lists: +# +# Sub items can be 1 of 3 types +# - List Creates a section within the current menu +# - Tuple Creates a submenu using a string or action as the parent. The child +# can be another menu list or an identifier used to call a helper function. +# - Action Appends a new menu item to the current menu +# + +LIST_NAME = [ + [Action1, Action2], # New section + (Action3, [Action4, Action5]), # Submenu with action as parent + ("Label", [Action6, Action7]), # Submenu with string as parent + ("Label2", "helper") # Submenu with helper function. Calls 'create_helper()' +] +''' + + +# The list of actions for the toolbar. +TOOLBAR_LIST = [ + [(Actions.FLOW_GRAPH_NEW, 'flow_graph_new_type'), + (Actions.FLOW_GRAPH_OPEN, 'flow_graph_recent'), + Actions.FLOW_GRAPH_SAVE, Actions.FLOW_GRAPH_CLOSE], + [Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR, Actions.FLOW_GRAPH_SCREEN_CAPTURE], + [Actions.BLOCK_CUT, Actions.BLOCK_COPY, + Actions.BLOCK_PASTE, Actions.ELEMENT_DELETE], + [Actions.FLOW_GRAPH_UNDO, Actions.FLOW_GRAPH_REDO], + [Actions.ERRORS_WINDOW_DISPLAY, Actions.FLOW_GRAPH_GEN, + Actions.FLOW_GRAPH_EXEC, Actions.FLOW_GRAPH_KILL], + [Actions.BLOCK_ROTATE_CCW, Actions.BLOCK_ROTATE_CW], + [Actions.BLOCK_ENABLE, Actions.BLOCK_DISABLE, + Actions.BLOCK_BYPASS, Actions.TOGGLE_HIDE_DISABLED_BLOCKS], + [Actions.FIND_BLOCKS, Actions.RELOAD_BLOCKS, Actions.OPEN_HIER] +] + + +# The list of actions and categories for the menu bar. +MENU_BAR_LIST = [ + ('_File', [ + [(Actions.FLOW_GRAPH_NEW, 'flow_graph_new_type'), Actions.FLOW_GRAPH_DUPLICATE, + Actions.FLOW_GRAPH_OPEN, (Actions.FLOW_GRAPH_OPEN_RECENT, 'flow_graph_recent')], + [Actions.FLOW_GRAPH_SAVE, Actions.FLOW_GRAPH_SAVE_AS, + Actions.FLOW_GRAPH_SAVE_COPY], + [Actions.FLOW_GRAPH_SCREEN_CAPTURE], + [Actions.FLOW_GRAPH_CLOSE, Actions.APPLICATION_QUIT] + ]), + ('_Edit', [ + [Actions.FLOW_GRAPH_UNDO, Actions.FLOW_GRAPH_REDO], + [Actions.BLOCK_CUT, Actions.BLOCK_COPY, Actions.BLOCK_PASTE, + Actions.ELEMENT_DELETE, Actions.SELECT_ALL], + [Actions.BLOCK_ROTATE_CCW, Actions.BLOCK_ROTATE_CW, + ('_Align', Actions.BLOCK_ALIGNMENTS)], + [Actions.BLOCK_ENABLE, Actions.BLOCK_DISABLE, Actions.BLOCK_BYPASS], + [Actions.BLOCK_PARAM_MODIFY] + ]), + ('_View', [ + [Actions.TOGGLE_BLOCKS_WINDOW], + [Actions.TOGGLE_CONSOLE_WINDOW, Actions.TOGGLE_SCROLL_LOCK, + Actions.SAVE_CONSOLE, Actions.CLEAR_CONSOLE], + [Actions.TOGGLE_HIDE_VARIABLES, Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR, Actions.TOGGLE_FLOW_GRAPH_VAR_EDITOR_SIDEBAR, + Actions.TOGGLE_SHOW_PARAMETER_EXPRESSION, Actions.TOGGLE_SHOW_PARAMETER_EVALUATION], + [Actions.TOGGLE_HIDE_DISABLED_BLOCKS, Actions.TOGGLE_AUTO_HIDE_PORT_LABELS, + Actions.TOGGLE_SNAP_TO_GRID, Actions.TOGGLE_SHOW_BLOCK_COMMENTS, Actions.TOGGLE_SHOW_BLOCK_IDS, + Actions.TOGGLE_SHOW_FIELD_COLORS, ], + [Actions.TOGGLE_SHOW_CODE_PREVIEW_TAB], + [Actions.ZOOM_IN], + [Actions.ZOOM_OUT], + [Actions.ZOOM_RESET], + [Actions.ERRORS_WINDOW_DISPLAY, Actions.FIND_BLOCKS], + ]), + ('_Run', [ + Actions.FLOW_GRAPH_GEN, Actions.FLOW_GRAPH_EXEC, Actions.FLOW_GRAPH_KILL + ]), + ('_Tools', [ + [Actions.TOOLS_RUN_FDESIGN, Actions.FLOW_GRAPH_OPEN_QSS_THEME], + [Actions.TOGGLE_SHOW_FLOWGRAPH_COMPLEXITY] + ]), + ('_Help', [ + [Actions.HELP_WINDOW_DISPLAY, Actions.TYPES_WINDOW_DISPLAY, + Actions.KEYBOARD_SHORTCUTS_WINDOW_DISPLAY, Actions.XML_PARSER_ERRORS_DISPLAY], + [Actions.GET_INVOLVED_WINDOW_DISPLAY, Actions.ABOUT_WINDOW_DISPLAY] + ])] + + +# The list of actions for the context menu. +CONTEXT_MENU_LIST = [ + [Actions.BLOCK_CUT, Actions.BLOCK_COPY, + Actions.BLOCK_PASTE, Actions.ELEMENT_DELETE], + [Actions.BLOCK_ROTATE_CCW, Actions.BLOCK_ROTATE_CW, + Actions.BLOCK_ENABLE, Actions.BLOCK_DISABLE, Actions.BLOCK_BYPASS], + [("_More", [ + [Actions.BLOCK_CREATE_HIER, Actions.OPEN_HIER], + [Actions.BUSSIFY_SOURCES, Actions.BUSSIFY_SINKS] + ])], + [Actions.BLOCK_PARAM_MODIFY], +] + + +class SubMenuHelper(object): + ''' Generates custom submenus for the main menu or toolbar. ''' + + def __init__(self): + self.submenus = {} + + def build_submenu(self, name, parent_obj, obj_idx, obj, set_func): + # Get the correct helper function + create_func = getattr(self, "create_{}".format(name)) + # Save the helper functions for rebuilding the menu later + self.submenus[name] = (create_func, parent_obj, obj_idx, obj, set_func) + # Actually build the menu + set_func(obj, create_func()) + + def create_flow_graph_new_type(self): + """ Different flowgraph types """ + menu = Gio.Menu() + platform = Gtk.Application.get_default().platform + generate_modes = platform.get_generate_options() + for key, name, default in generate_modes: + target = "app.flowgraph.new_type::{}".format(key) + menu.append(name, target) + return menu + + def create_flow_graph_recent(self): + """ Recent flow graphs """ + + config = Gtk.Application.get_default().config + recent_files = config.get_recent_files() + menu = Gio.Menu() + if len(recent_files) > 0: + files = Gio.Menu() + for i, file_name in enumerate(recent_files): + target = "app.flowgraph.open_recent::{}".format(file_name) + files.append(file_name.replace("_", "__"), target) + menu.append_section(None, files) + #clear = Gio.Menu() + #clear.append("Clear recent files", "app.flowgraph.clear_recent") + #menu.append_section(None, clear) + else: + # Show an empty menu + menuitem = Gio.MenuItem.new("No items found", "app.none") + menu.append_item(menuitem) + return menu + + +class MenuHelper(SubMenuHelper): + """ + Recursively builds a menu from a given list of actions. + + Args: + - actions: List of actions to build the menu + - menu: Current menu being built + + Notes: + - Tuple: Create a new submenu from the parent (1st) and child (2nd) elements + - Action: Append to current menu + - List: Start a new section + """ + + def __init__(self): + SubMenuHelper.__init__(self) + + def build_menu(self, actions, menu): + for idx, item in enumerate(actions): + log.debug("build_menu idx, action: %s, %s", idx, item) + if isinstance(item, tuple): + # Create a new submenu + parent, child = (item[0], item[1]) + + # Create the parent + label, target = (parent, None) + if isinstance(parent, Actions.Action): + label = parent.label + target = "{}.{}".format(parent.prefix, parent.name) + menuitem = Gio.MenuItem.new(label, None) + if hasattr(parent, "icon_name"): + menuitem.set_icon( + Gio.Icon.new_for_string(parent.icon_name)) + + # Create the new submenu + if isinstance(child, list): + submenu = Gio.Menu() + self.build_menu(child, submenu) + menuitem.set_submenu(submenu) + elif isinstance(child, str): + # Child is the name of the submenu to create + def set_func(obj, menu): + obj.set_submenu(menu) + self.build_submenu(child, menu, idx, menuitem, set_func) + menu.append_item(menuitem) + + elif isinstance(item, list): + # Create a new section + section = Gio.Menu() + self.build_menu(item, section) + menu.append_section(None, section) + + elif isinstance(item, Actions.Action): + # Append a new menuitem + target = "{}.{}".format(item.prefix, item.name) + menuitem = Gio.MenuItem.new(item.label, target) + if item.icon_name: + menuitem.set_icon(Gio.Icon.new_for_string(item.icon_name)) + menu.append_item(menuitem) + + def refresh_submenus(self): + for name in self.submenus: + create_func, parent_obj, obj_idx, obj, set_func = self.submenus[name] + set_func(obj, create_func()) + parent_obj.remove(obj_idx) + parent_obj.insert_item(obj_idx, obj) + + +class ToolbarHelper(SubMenuHelper): + """ + Builds a toolbar from a given list of actions. + + Args: + - actions: List of actions to build the menu + - item: Current menu being built + + Notes: + - Tuple: Create a new submenu from the parent (1st) and child (2nd) elements + - Action: Append to current menu + - List: Start a new section + """ + + def __init__(self): + SubMenuHelper.__init__(self) + + def build_toolbar(self, actions, current): + for idx, item in enumerate(actions): + if isinstance(item, list): + # Toolbar's don't have sections like menus, so call this function + # recursively with the "section" and just append a separator. + self.build_toolbar(item, self) + current.insert(Gtk.SeparatorToolItem.new(), -1) + + elif isinstance(item, tuple): + parent, child = (item[0], item[1]) + # Create an item with a submenu + # Generate the submenu and add to the item. + # Add the item to the toolbar + button = Gtk.MenuToolButton.new() + # The tuple should be made up of an Action and something. + button.set_label(parent.label) + button.set_tooltip_text(parent.tooltip) + button.set_icon_name(parent.icon_name) + + target = "{}.{}".format(parent.prefix, parent.name) + button.set_action_name(target) + + def set_func(obj, menu): + obj.set_menu(Gtk.Menu.new_from_model(menu)) + + self.build_submenu(child, current, idx, button, set_func) + current.insert(button, -1) + + elif isinstance(item, Actions.Action): + button = Gtk.ToolButton.new() + button.set_label(item.label) + button.set_tooltip_text(item.tooltip) + button.set_icon_name(item.icon_name) + target = "{}.{}".format(item.prefix, item.name) + button.set_action_name(target) + current.insert(button, -1) + + def refresh_submenus(self): + for name in self.submenus: + create_func, parent_obj, _, obj, set_func = self.submenus[name] + set_func(obj, create_func()) + + +class Menu(Gio.Menu, MenuHelper): + """ Main Menu """ + + def __init__(self): + GObject.GObject.__init__(self) + MenuHelper.__init__(self) + + log.debug("Building the main menu") + self.build_menu(MENU_BAR_LIST, self) + + +class ContextMenu(Gio.Menu, MenuHelper): + """ Context menu for the drawing area """ + + def __init__(self): + GObject.GObject.__init__(self) + + log.debug("Building the context menu") + self.build_menu(CONTEXT_MENU_LIST, self) + + +class Toolbar(Gtk.Toolbar, ToolbarHelper): + """ The gtk toolbar with actions added from the toolbar list. """ + + def __init__(self): + """ + Parse the list of action names in the toolbar list. + Look up the action for each name in the action list and add it to the + toolbar. + """ + GObject.GObject.__init__(self) + ToolbarHelper.__init__(self) + + self.set_style(Gtk.ToolbarStyle.ICONS) + # self.get_style_context().add_class(Gtk.STYLE_CLASS_PRIMARY_TOOLBAR) + + # SubMenuCreator.__init__(self) + self.build_toolbar(TOOLBAR_LIST, self) diff --git a/grc/gui/BlockTreeWindow.py b/grc/gui/BlockTreeWindow.py new file mode 100644 index 0000000..05341ec --- /dev/null +++ b/grc/gui/BlockTreeWindow.py @@ -0,0 +1,300 @@ +""" +Copyright 2007, 2008, 2009, 2016 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + +from gi.repository import Gtk, Gdk, GObject + +from . import Actions, Utils, Constants + + +NAME_INDEX, KEY_INDEX, DOC_INDEX = range(3) + + +def _format_doc(doc): + docs = [] + if doc.get(''): + docs += doc.get('').splitlines() + [''] + for block_name, docstring in doc.items(): + docs.append('--- {0} ---'.format(block_name)) + docs += docstring.splitlines() + docs.append('') + out = '' + for n, line in enumerate(docs[:-1]): + if n: + out += '\n' + out += Utils.encode(line) + if n > 10 or len(out) > 500: + out += '\n...' + break + return out or 'undocumented' + + +def _format_cat_tooltip(category): + tooltip = '{}: {}'.format('Category' if len( + category) > 1 else 'Module', category[-1]) + + if category == ('Core',): + tooltip += '\n\nThis subtree is meant for blocks included with GNU Radio (in-tree).' + + elif category == (Constants.DEFAULT_BLOCK_MODULE_NAME,): + tooltip += '\n\n' + Constants.DEFAULT_BLOCK_MODULE_TOOLTIP + + return tooltip + + +class BlockTreeWindow(Gtk.VBox): + """The block selection panel.""" + + __gsignals__ = { + 'create_new_block': (GObject.SignalFlags.RUN_FIRST, None, (str,)) + } + + def __init__(self, platform): + """ + BlockTreeWindow constructor. + Create a tree view of the possible blocks in the platform. + The tree view nodes will be category names, the leaves will be block names. + A mouse double click or button press action will trigger the add block event. + + Args: + platform: the particular platform will all block prototypes + """ + Gtk.VBox.__init__(self) + self.platform = platform + + # search entry + self.search_entry = Gtk.Entry() + try: + self.search_entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.PRIMARY, 'edit-find') + self.search_entry.set_icon_activatable( + Gtk.EntryIconPosition.PRIMARY, False) + self.search_entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, 'window-close') + self.search_entry.connect('icon-release', self._handle_icon_event) + except AttributeError: + pass # no icon for old pygtk + self.search_entry.connect('changed', self._update_search_tree) + self.search_entry.connect( + 'key-press-event', self._handle_search_key_press) + self.pack_start(self.search_entry, False, False, 0) + + # make the tree model for holding blocks and a temporary one for search results + self.treestore = Gtk.TreeStore( + GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_STRING) + self.treestore_search = Gtk.TreeStore( + GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_STRING) + + self.treeview = Gtk.TreeView(model=self.treestore) + self.treeview.set_enable_search(False) # disable pop up search box + self.treeview.set_search_column(-1) # really disable search + self.treeview.set_headers_visible(False) + self.treeview.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + self.treeview.connect('button-press-event', + self._handle_mouse_button_press) + self.treeview.connect('key-press-event', self._handle_search_key_press) + + self.treeview.get_selection().set_mode(Gtk.SelectionMode.SINGLE) + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn('Blocks', renderer, text=NAME_INDEX) + self.treeview.append_column(column) + self.treeview.set_tooltip_column(DOC_INDEX) + # setup sort order + column.set_sort_column_id(0) + self.treestore.set_sort_column_id(0, Gtk.SortType.ASCENDING) + # setup drag and drop + self.treeview.enable_model_drag_source( + Gdk.ModifierType.BUTTON1_MASK, Constants.DND_TARGETS, Gdk.DragAction.COPY) + self.treeview.connect('drag-data-get', self._handle_drag_get_data) + # make the scrolled window to hold the tree view + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled_window.add(self.treeview) + scrolled_window.set_size_request( + Constants.DEFAULT_BLOCKS_WINDOW_WIDTH, -1) + self.pack_start(scrolled_window, True, True, 0) + # map categories to iters, automatic mapping for root + self._categories = {tuple(): None} + self._categories_search = {tuple(): None} + self.platform.block_docstrings_loaded_callback = self.update_docs + self.repopulate() + + def clear(self): + self.treestore.clear() + self._categories = {(): None} + + def repopulate(self): + self.clear() + for block in self.platform.blocks.values(): + if block.category: + self.add_block(block) + self.expand_module_in_tree() + + def expand_module_in_tree(self, module_name='Core'): + self.treeview.collapse_all() + core_module_iter = self._categories.get((module_name,)) + if core_module_iter: + self.treeview.expand_row( + self.treestore.get_path(core_module_iter), False) + + ############################################################ + # Block Tree Methods + ############################################################ + def add_block(self, block, treestore=None, categories=None): + """ + Add a block with category to this selection window. + Add only the category when block is None. + + Args: + block: the block object or None + """ + treestore = treestore or self.treestore + categories = categories or self._categories + + # tuple is hashable, remove empty cats + category = tuple(filter(str, block.category)) + + # add category and all sub categories + for level, parent_cat_name in enumerate(category, 1): + parent_category = category[:level] + if parent_category not in categories: + iter_ = treestore.insert_before( + categories[parent_category[:-1]], None) + treestore.set_value(iter_, NAME_INDEX, parent_cat_name) + treestore.set_value(iter_, KEY_INDEX, '') + treestore.set_value( + iter_, DOC_INDEX, _format_cat_tooltip(parent_category)) + categories[parent_category] = iter_ + # add block + iter_ = treestore.insert_before(categories[category], None) + treestore.set_value(iter_, KEY_INDEX, block.key) + treestore.set_value(iter_, NAME_INDEX, block.label) + treestore.set_value(iter_, DOC_INDEX, _format_doc(block.documentation)) + + def update_docs(self): + """Update the documentation column of every block""" + + def update_doc(model, _, iter_): + key = model.get_value(iter_, KEY_INDEX) + if not key: + return # category node, no doc string + block = self.platform.blocks[key] + model.set_value(iter_, DOC_INDEX, _format_doc(block.documentation)) + + self.treestore.foreach(update_doc) + self.treestore_search.foreach(update_doc) + + ############################################################ + # Helper Methods + ############################################################ + def _get_selected_block_key(self): + """ + Get the currently selected block key. + + Returns: + the key of the selected block or a empty string + """ + selection = self.treeview.get_selection() + treestore, iter = selection.get_selected() + return iter and treestore.get_value(iter, KEY_INDEX) or '' + + def _expand_category(self): + treestore, iter = self.treeview.get_selection().get_selected() + if iter and treestore.iter_has_child(iter): + path = treestore.get_path(iter) + self.treeview.expand_to_path(path) + + ############################################################ + # Event Handlers + ############################################################ + def _handle_icon_event(self, widget, icon, event): + if icon == Gtk.EntryIconPosition.PRIMARY: + pass + elif icon == Gtk.EntryIconPosition.SECONDARY: + widget.set_text('') + self.search_entry.hide() + + def _update_search_tree(self, widget): + key = widget.get_text().lower() + if not key: + self.treeview.set_model(self.treestore) + self.expand_module_in_tree() + else: + matching_blocks = [b for b in list(self.platform.blocks.values()) + if key in b.key.lower() or key in b.label.lower()] + + self.treestore_search.clear() + self._categories_search = {tuple(): None} + for block in matching_blocks: + self.add_block(block, self.treestore_search, + self._categories_search) + self.treeview.set_model(self.treestore_search) + self.treeview.expand_all() + + def _handle_search_key_press(self, widget, event): + """Handle Return and Escape key events in search entry and treeview""" + if event.keyval == Gdk.KEY_Return: + # add block on enter + + if widget == self.search_entry: + # Get the first block in the search tree and add it + selected = self.treestore_search.get_iter_first() + while self.treestore_search.iter_children(selected): + selected = self.treestore_search.iter_children(selected) + if selected is not None: + key = self.treestore_search.get_value(selected, KEY_INDEX) + if key: + self.emit('create_new_block', key) + elif widget == self.treeview: + key = self._get_selected_block_key() + if key: + self.emit('create_new_block', key) + else: + self._expand_category() + else: + return False # propagate event + + elif event.keyval == Gdk.KEY_Escape: + # reset the search + self.search_entry.set_text('') + self.search_entry.hide() + + elif (event.get_state() & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_f) \ + or event.keyval == Gdk.KEY_slash: + # propagation doesn't work although treeview search is disabled =( + # manually trigger action... + Actions.FIND_BLOCKS.activate() + + elif event.get_state() & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_b: + # ugly... + Actions.TOGGLE_BLOCKS_WINDOW.activate() + + else: + return False # propagate event + + return True + + def _handle_drag_get_data(self, widget, drag_context, selection_data, info, time): + """ + Handle a drag and drop by setting the key to the selection object. + This will call the destination handler for drag and drop. + Only call set when the key is valid to ignore DND from categories. + """ + key = self._get_selected_block_key() + if key: + selection_data.set_text(key, len(key)) + + def _handle_mouse_button_press(self, widget, event): + """ + Handle the mouse button press. + If a left double click is detected, call add selected block. + """ + if event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS: + key = self._get_selected_block_key() + if key: + self.emit('create_new_block', key) diff --git a/grc/gui/Config.py b/grc/gui/Config.py new file mode 100644 index 0000000..dac7a57 --- /dev/null +++ b/grc/gui/Config.py @@ -0,0 +1,186 @@ +""" +Copyright 2016 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import sys +import os +import configparser + +from ..main import get_config_file_path +from ..core.Config import Config as CoreConfig +from . import Constants + +HEADER = """\ +# This contains only GUI settings for GRC and is not meant for users to edit. +# +# GRC settings not accessible through the GUI are in config.conf under +# section [grc]. + +""" + + +class Config(CoreConfig): + + name = 'GNU Radio Companion' + + gui_prefs_file = os.environ.get('GRC_PREFS_PATH', get_config_file_path()) + + def __init__(self, install_prefix, *args, **kwargs): + CoreConfig.__init__(self, *args, **kwargs) + self.install_prefix = install_prefix + Constants.update_font_size(self.font_size) + + self.parser = configparser.ConfigParser() + for section in ['main', 'files_open', 'files_recent']: + try: + self.parser.add_section(section) + except Exception as e: + print(e) + try: + self.parser.read(self.gui_prefs_file) + except Exception as err: + print(err, file=sys.stderr) + + def save(self): + try: + with open(self.gui_prefs_file, 'w') as fp: + fp.write(HEADER) + self.parser.write(fp) + except Exception as err: + print(err, file=sys.stderr) + + def entry(self, key, value=None, default=None): + if value is not None: + self.parser.set('main', key, str(value)) + result = value + else: + _type = type(default) if default is not None else str + getter = { + bool: self.parser.getboolean, + int: self.parser.getint, + }.get(_type, self.parser.get) + try: + result = getter('main', key) + except (AttributeError, configparser.Error): + result = _type() if default is None else default + return result + + @property + def editor(self): + return self._gr_prefs.get_string('grc', 'editor', '') + + @editor.setter + def editor(self, value): + self._gr_prefs.set_string('grc', 'editor', value) + self._gr_prefs.save() + + @property + def xterm_executable(self): + return self._gr_prefs.get_string('grc', 'xterm_executable', 'xterm') + + @property + def wiki_block_docs_url_prefix(self): + return self._gr_prefs.get_string('grc-docs', 'wiki_block_docs_url_prefix', '') + + @property + def font_size(self): + try: # ugly, but matches current code style + font_size = self._gr_prefs.get_long('grc', 'canvas_font_size', + Constants.DEFAULT_FONT_SIZE) + if font_size <= 0: + raise ValueError + except (ValueError, TypeError): + font_size = Constants.DEFAULT_FONT_SIZE + print("Error: invalid 'canvas_font_size' setting.", file=sys.stderr) + + return font_size + + @property + def default_qss_theme(self): + return self._gr_prefs.get_string('qtgui', 'qss', '') + + @default_qss_theme.setter + def default_qss_theme(self, value): + self._gr_prefs.set_string("qtgui", "qss", value) + self._gr_prefs.save() + + ##### Originally from Preferences.py ##### + def main_window_size(self, size=None): + if size is None: + size = [None, None] + w = self.entry('main_window_width', size[0], default=800) + h = self.entry('main_window_height', size[1], default=600) + return w, h + + def file_open(self, filename=None): + return self.entry('file_open', filename, default='') + + def set_file_list(self, key, files): + self.parser.remove_section(key) # clear section + self.parser.add_section(key) + for i, filename in enumerate(files): + self.parser.set(key, '%s_%d' % (key, i), filename) + + def get_file_list(self, key): + try: + files = [value for name, value in self.parser.items(key) + if name.startswith('%s_' % key)] + except (AttributeError, configparser.Error): + files = [] + return files + + def get_open_files(self): + return self.get_file_list('files_open') + + def set_open_files(self, files): + return self.set_file_list('files_open', files) + + def get_recent_files(self): + """ Gets recent files, removes any that do not exist and re-saves it """ + files = list( + filter(os.path.exists, self.get_file_list('files_recent'))) + self.set_recent_files(files) + return files + + def set_recent_files(self, files): + return self.set_file_list('files_recent', files) + + def add_recent_file(self, file_name): + # double check file_name + if os.path.exists(file_name): + recent_files = self.get_recent_files() + if file_name in recent_files: + recent_files.remove(file_name) # Attempt removal + recent_files.insert(0, file_name) # Insert at start + self.set_recent_files(recent_files[:10]) # Keep up to 10 files + + def console_window_position(self, pos=None): + return self.entry('console_window_position', pos, default=-1) or 1 + + def blocks_window_position(self, pos=None): + return self.entry('blocks_window_position', pos, default=-1) or 1 + + def variable_editor_position(self, pos=None, sidebar=False): + # Figure out default + if sidebar: + _, h = self.main_window_size() + return self.entry('variable_editor_sidebar_position', pos, default=int(h * 0.7)) + else: + return self.entry('variable_editor_position', pos, default=int(self.blocks_window_position() * 0.5)) + + def variable_editor_sidebar(self, pos=None): + return self.entry('variable_editor_sidebar', pos, default=False) + + def variable_editor_confirm_delete(self, pos=None): + return self.entry('variable_editor_confirm_delete', pos, default=True) + + def xterm_missing(self, cmd=None): + return self.entry('xterm_missing', cmd, default='INVALID_XTERM_SETTING') + + def screen_shot_background_transparent(self, transparent=None): + return self.entry('screen_shot_background_transparent', transparent, default=False) diff --git a/grc/gui/Console.py b/grc/gui/Console.py new file mode 100644 index 0000000..f88191c --- /dev/null +++ b/grc/gui/Console.py @@ -0,0 +1,43 @@ +""" +Copyright 2008, 2009, 2011 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from ..core import Messages +from .Dialogs import TextDisplay, MessageDialogWrapper +from .Constants import DEFAULT_CONSOLE_WINDOW_WIDTH +from gi.repository import Gtk, Gdk, GObject +import os +import logging + +import gi +gi.require_version('Gtk', '3.0') + + +log = logging.getLogger(__name__) + + +class Console(Gtk.ScrolledWindow): + def __init__(self): + Gtk.ScrolledWindow.__init__(self) + log.debug("console()") + self.app = Gtk.Application.get_default() + + self.text_display = TextDisplay() + + self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + self.add(self.text_display) + self.set_size_request(-1, DEFAULT_CONSOLE_WINDOW_WIDTH) + + def add_line(self, line): + """ + Place line at the end of the text buffer, then scroll its window all the way down. + + Args: + line: the new text + """ + self.text_display.insert(line) diff --git a/grc/gui/Constants.py b/grc/gui/Constants.py new file mode 100644 index 0000000..dd74497 --- /dev/null +++ b/grc/gui/Constants.py @@ -0,0 +1,123 @@ +""" +Copyright 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from gi.repository import Gtk, Gdk + +from ..core.Constants import * + + +# default path for the open/save dialogs +DEFAULT_FILE_PATH = os.getcwd() if os.name != 'nt' else os.path.expanduser("~/Documents") +FILE_EXTENSION = '.grc' + +# name for new/unsaved flow graphs +NEW_FLOGRAPH_TITLE = 'untitled' + +# main window constraints +MIN_WINDOW_WIDTH = 600 +MIN_WINDOW_HEIGHT = 400 +# dialog constraints +MIN_DIALOG_WIDTH = 600 +MIN_DIALOG_HEIGHT = 500 +# default sizes +DEFAULT_BLOCKS_WINDOW_WIDTH = 100 +DEFAULT_CONSOLE_WINDOW_WIDTH = 100 + +FONT_SIZE = DEFAULT_FONT_SIZE = 8 +FONT_FAMILY = "Sans" +BLOCK_FONT = PORT_FONT = "Sans 8" +PARAM_FONT = "Sans 7.5" + +# size of the state saving cache in the flow graph (undo/redo functionality) +STATE_CACHE_SIZE = 42 + +# Shared targets for drag and drop of blocks +DND_TARGETS = [Gtk.TargetEntry.new('STRING', Gtk.TargetFlags.SAME_APP, 0), + Gtk.TargetEntry.new('UTF8_STRING', Gtk.TargetFlags.SAME_APP, 1)] + +# label constraint dimensions +LABEL_SEPARATION = 3 +BLOCK_LABEL_PADDING = 7 +PORT_LABEL_PADDING = 2 + +# canvas grid size +CANVAS_GRID_SIZE = 8 + +# port constraint dimensions +PORT_BORDER_SEPARATION = 8 +PORT_SPACING = 2 * PORT_BORDER_SEPARATION +PORT_SEPARATION = 32 + +PORT_MIN_WIDTH = 20 +PORT_LABEL_HIDDEN_WIDTH = 10 +PORT_EXTRA_BUS_HEIGHT = 40 + +# minimal length of connector +CONNECTOR_EXTENSION_MINIMAL = 11 + +# increment length for connector +CONNECTOR_EXTENSION_INCREMENT = 11 + +# connection arrow dimensions +CONNECTOR_ARROW_BASE = 10 +CONNECTOR_ARROW_HEIGHT = 13 + +# possible rotations in degrees +POSSIBLE_ROTATIONS = (0, 90, 180, 270) + +# How close the mouse can get to the edge of the visible window before scrolling is invoked. +SCROLL_PROXIMITY_SENSITIVITY = 50 + +# When the window has to be scrolled, move it this distance in the required direction. +SCROLL_DISTANCE = 15 + +# How close the mouse click can be to a line and register a connection select. +LINE_SELECT_SENSITIVITY = 5 + +DEFAULT_BLOCK_MODULE_TOOLTIP = """\ +This subtree holds all blocks (from OOT modules) that specify no module name. \ +The module name is the root category enclosed in square brackets. + +Please consider contacting OOT module maintainer for any block in here \ +and kindly ask to update their GRC Block Descriptions or Block Tree to include a module name.""" + + +# _SCREEN = Gdk.Screen.get_default() +# _SCREEN_RESOLUTION = _SCREEN.get_resolution() if _SCREEN else -1 +# DPI_SCALING = _SCREEN_RESOLUTION / 96.0 if _SCREEN_RESOLUTION > 0 else 1.0 +# todo: figure out the GTK3 way (maybe cairo does this for us +DPI_SCALING = 1.0 + +# Gtk-themes classified as dark +GTK_DARK_THEMES = [ + 'Adwaita-dark', + 'HighContrastInverse', +] + +GTK_SETTINGS_INI_PATH = '~/.config/gtk-3.0/settings.ini' + +GTK_INI_PREFER_DARK_KEY = 'gtk-application-prefer-dark-theme' +GTK_INI_THEME_NAME_KEY = 'gtk-theme-name' + + +def update_font_size(font_size): + global PORT_SEPARATION, BLOCK_FONT, PORT_FONT, PARAM_FONT, FONT_SIZE + + FONT_SIZE = font_size + BLOCK_FONT = "%s %f" % (FONT_FAMILY, font_size) + PORT_FONT = BLOCK_FONT + PARAM_FONT = "%s %f" % (FONT_FAMILY, font_size - 0.5) + + PORT_SEPARATION = PORT_SPACING + 2 * \ + PORT_LABEL_PADDING + int(1.5 * font_size) + PORT_SEPARATION += - \ + PORT_SEPARATION % (2 * CANVAS_GRID_SIZE) # even multiple + + +update_font_size(DEFAULT_FONT_SIZE) diff --git a/grc/gui/Dialogs.py b/grc/gui/Dialogs.py new file mode 100644 index 0000000..01037ab --- /dev/null +++ b/grc/gui/Dialogs.py @@ -0,0 +1,432 @@ +# Copyright 2008, 2009, 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import sys +import textwrap +from shutil import which as find_executable + +from gi.repository import Gtk, GLib, Gdk, Gio + +from . import Utils, Actions, Constants +from ..core import Messages + + +class SimpleTextDisplay(Gtk.TextView): + """ + A non user-editable gtk text view. + """ + + def __init__(self, text=''): + """ + TextDisplay constructor. + + Args: + text: the text to display (string) + """ + Gtk.TextView.__init__(self) + self.set_text = self.get_buffer().set_text + self.set_text(text) + self.set_editable(False) + self.set_cursor_visible(False) + self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + + +class TextDisplay(SimpleTextDisplay): + """ + A non user-editable scrollable text view with popup menu. + """ + + def __init__(self, text=''): + """ + TextDisplay constructor. + + Args: + text: the text to display (string) + """ + SimpleTextDisplay.__init__(self, text) + self.scroll_lock = True + self.connect("populate-popup", self.populate_popup) + + def insert(self, line): + """ + Append text after handling backspaces and auto-scroll. + + Args: + line: the text to append (string) + """ + line = self._consume_backspaces(line) + self.get_buffer().insert(self.get_buffer().get_end_iter(), line) + self.scroll_to_end() + + def _consume_backspaces(self, line): + """ + Removes text from the buffer if line starts with '\b' + + Args: + line: a string which may contain backspaces + + Returns: + The string that remains from 'line' with leading '\b's removed. + """ + if not line: + return + + # for each \b delete one char from the buffer + back_count = 0 + start_iter = self.get_buffer().get_end_iter() + while len(line) > back_count and line[back_count] == '\b': + # stop at the beginning of a line + if not start_iter.starts_line(): + start_iter.backward_char() + back_count += 1 + # remove chars from buffer + self.get_buffer().delete(start_iter, self.get_buffer().get_end_iter()) + return line[back_count:] + + def scroll_to_end(self): + """ Update view's scroll position. """ + if self.scroll_lock: + buf = self.get_buffer() + mark = buf.get_insert() + buf.move_mark(mark, buf.get_end_iter()) + self.scroll_mark_onscreen(mark) + + def clear(self): + """ Clear all text from buffer. """ + buf = self.get_buffer() + buf.delete(buf.get_start_iter(), buf.get_end_iter()) + + def save(self, file_path): + """ + Save context of buffer to the given file. + + Args: + file_path: location to save buffer contents + """ + with open(file_path, 'w') as logfile: + buf = self.get_buffer() + logfile.write(buf.get_text(buf.get_start_iter(), + buf.get_end_iter(), True)) + + # Action functions are set by the Application's init function + def clear_cb(self, menu_item, web_view): + """ Callback function to clear the text buffer """ + Actions.CLEAR_CONSOLE() + + def scroll_back_cb(self, menu_item, web_view): + """ Callback function to toggle scroll lock """ + Actions.TOGGLE_SCROLL_LOCK() + + def save_cb(self, menu_item, web_view): + """ Callback function to save the buffer """ + Actions.SAVE_CONSOLE() + + def populate_popup(self, view, menu): + """Create a popup menu for the scroll lock and clear functions""" + menu.append(Gtk.SeparatorMenuItem()) + + lock = Gtk.CheckMenuItem(label="Scroll Lock") + menu.append(lock) + lock.set_active(self.scroll_lock) + lock.connect('activate', self.scroll_back_cb, view) + + save = Gtk.ImageMenuItem(label="Save Console") + menu.append(save) + save.connect('activate', self.save_cb, view) + + clear = Gtk.ImageMenuItem(label="Clear Console") + menu.append(clear) + clear.connect('activate', self.clear_cb, view) + menu.show_all() + return False + + +class MessageDialogWrapper(Gtk.MessageDialog): + """ Run a message dialog. """ + + def __init__(self, parent, message_type, buttons, title=None, markup=None, + default_response=None, extra_buttons=None): + """ + Create a modal message dialog. + + Args: + message_type: the type of message may be one of: + Gtk.MessageType.INFO + Gtk.MessageType.WARNING + Gtk.MessageType.QUESTION or Gtk.MessageType.ERROR + buttons: the predefined set of buttons to use: + Gtk.ButtonsType.NONE + Gtk.ButtonsType.OK + Gtk.ButtonsType.CLOSE + Gtk.ButtonsType.CANCEL + Gtk.ButtonsType.YES_NO + Gtk.ButtonsType.OK_CANCEL + title: the title of the window (string) + markup: the message text with pango markup + default_response: if set, determines which button is highlighted by default + extra_buttons: a tuple containing pairs of values: + each value is the button's text and the button's return value + + """ + Gtk.MessageDialog.__init__( + self, transient_for=parent, modal=True, destroy_with_parent=True, + message_type=message_type, buttons=buttons + ) + self.set_keep_above(True) + if title: + self.set_title(title) + if markup: + self.set_markup(markup) + if extra_buttons: + self.add_buttons(*extra_buttons) + if default_response: + self.set_default_response(default_response) + + def run_and_destroy(self): + response = self.run() + self.hide() + return response + + +class ErrorsDialog(Gtk.Dialog): + """ Display flowgraph errors. """ + + def __init__(self, parent, flowgraph): + """Create a listview of errors""" + Gtk.Dialog.__init__( + self, + title='Errors and Warnings', + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + self.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT) + self.set_size_request(750, Constants.MIN_DIALOG_HEIGHT) + self.set_border_width(10) + + self.store = Gtk.ListStore(str, str, str) + self.update(flowgraph) + + self.treeview = Gtk.TreeView(model=self.store) + self.treeview.connect("button_press_event", self.mouse_click) + for i, column_title in enumerate(["Block", "Aspect", "Message"]): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sort_column_id(i) # liststore id matches treeview id + column.set_resizable(True) + self.treeview.append_column(column) + + self.scrollable = Gtk.ScrolledWindow() + self.scrollable.set_vexpand(True) + self.scrollable.add(self.treeview) + + self.vbox.pack_start(self.scrollable, True, True, 0) + self.show_all() + + def update(self, flowgraph): + self.store.clear() + for element, message in flowgraph.iter_error_messages(): + if element.is_block: + src, aspect = element.name, '' + elif element.is_connection: + src = element.source_block.name + aspect = "Connection to '{}'".format(element.sink_block.name) + elif element.is_port: + src = element.parent_block.name + aspect = "{} '{}'".format( + 'Sink' if element.is_sink else 'Source', element.name) + elif element.is_param: + src = element.parent_block.name + aspect = "Param '{}'".format(element.name) + else: + src = aspect = '' + self.store.append([src, aspect, message]) + + def run_and_destroy(self): + response = self.run() + self.hide() + return response + + def mouse_click(self, _, event): + """ Handle mouse click, so user can copy the error message """ + if event.button == 3: + path_info = self.treeview.get_path_at_pos(event.x, event.y) + if path_info is not None: + path, col, _, _ = path_info + self.treeview.grab_focus() + self.treeview.set_cursor(path, col, 0) + + selection = self.treeview.get_selection() + (model, iterator) = selection.get_selected() + self.clipboard.set_text(model[iterator][2], -1) + print(model[iterator][2]) + + +def show_about(parent, config): + ad = Gtk.AboutDialog(transient_for=parent) + ad.set_program_name(config.name) + ad.set_name('') + ad.set_license(config.license) + + py_version = sys.version.split()[0] + ad.set_version("{} (Python {})".format(config.version, py_version)) + + try: + ad.set_logo(Gtk.IconTheme().load_icon('gnuradio-grc', 64, 0)) + except GLib.Error: + Messages.send("Failed to set window logo\n") + + # ad.set_comments("") + ad.set_copyright(config.license.splitlines()[0]) + ad.set_website(config.website) + + ad.connect("response", lambda action, param: action.hide()) + ad.show() + + +def show_help(parent): + """ Display basic usage tips. """ + markup = textwrap.dedent("""\ + Usage Tips + \n\ + Add block: drag and drop or double click a block in the block + selection window. + Rotate block: Select a block, press left/right on the keyboard. + Change type: Select a block, press up/down on the keyboard. + Edit parameters: double click on a block in the flow graph. + Make connection: click on the source port of one block, then + click on the sink port of another block. + Remove connection: select the connection and press delete, or + drag the connection. + \n\ + *Press Ctrl+K or see menu for Keyboard - Shortcuts + \ + """) + markup = markup.replace("Ctrl", Utils.get_modifier_key()) + + MessageDialogWrapper( + parent, Gtk.MessageType.INFO, Gtk.ButtonsType.CLOSE, title='Help', markup=markup + ).run_and_destroy() + + +def show_keyboard_shortcuts(parent): + """ Display keyboard shortcut-keys. """ + markup = textwrap.dedent("""\ + Keyboard Shortcuts + \n\ + Ctrl+N: Create a new flowgraph. + Ctrl+O: Open an existing flowgraph. + Ctrl+S: Save the current flowgraph or save as for new. + Ctrl+W: Close the current flowgraph. + Ctrl+Z: Undo a change to the flowgraph. + Ctrl+Y: Redo a change to the flowgraph. + Ctrl+A: Selects all blocks and connections. + Ctrl+P: Screen Capture of the Flowgraph. + Ctrl+Shift+P: Save the console output to file. + Ctrl+L: Clear the console. + Ctrl+E: Show variable editor. + Ctrl+F: Search for a block by name. + Ctrl+Q: Quit. + F1 : Help menu. + F5 : Generate the Flowgraph. + F6 : Execute the Flowgraph. + F7 : Kill the Flowgraph. + Ctrl+Shift+S: Save as the current flowgraph. + Ctrl+Shift+D: Create a duplicate of current flow graph. + + Ctrl+X/C/V: Edit-cut/copy/paste. + Ctrl+D/B/R: Toggle visibility of disabled blocks or + connections/block tree widget/console. + Shift+T/M/B/L/C/R: Vertical Align Top/Middle/Bottom and + Horizontal Align Left/Center/Right respectively of the + selected block. + Ctrl+0: Reset the zoom level + Ctrl++/-: Zoom in and out + \ + """) + markup = markup.replace("Ctrl", Utils.get_modifier_key()) + + MessageDialogWrapper( + parent, Gtk.MessageType.INFO, Gtk.ButtonsType.CLOSE, title='Keyboard - Shortcuts', markup=markup + ).run_and_destroy() + + +def show_get_involved(parent): + """Get Involved Instructions""" + markup = textwrap.dedent("""\ + Welcome to GNU Radio Community! + \n\ + For more details on contributing to GNU Radio and getting engaged with our great community visit here. + \n\ + You can also join our Matrix chat server, IRC Channel (#gnuradio) or contact through our mailing list (discuss-gnuradio). + \ + """) + + MessageDialogWrapper( + parent, Gtk.MessageType.QUESTION, Gtk.ButtonsType.CLOSE, title='Get - Involved', markup=markup + ).run_and_destroy() + + +def show_types(parent): + """ Display information about standard data types. """ + colors = [(name, color) + for name, key, sizeof, color in Constants.CORE_TYPES] + max_len = 10 + max(len(name) for name, code in colors) + + message = '\n'.join( + '{name}' + ''.format(color=color, name=Utils.encode(name).center(max_len)) + for name, color in colors + ) + + MessageDialogWrapper( + parent, Gtk.MessageType.INFO, Gtk.ButtonsType.CLOSE, title='Types - Color Mapping', markup=message + ).run_and_destroy() + + +def show_missing_xterm(parent, xterm): + markup = textwrap.dedent("""\ + The xterm executable {0!r} is missing. + You can change this setting in your gnuradio.conf, in section [grc], 'xterm_executable'. + \n\ + (This message is shown only once)\ + """).format(xterm) + + MessageDialogWrapper( + parent, message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, + title='Warning: missing xterm executable', markup=markup + ).run_and_destroy() + + +def choose_editor(parent, config): + """ + Give the option to either choose an editor or use the default. + """ + content_type = Gio.content_type_from_mime_type("text/x-python") + if content_type == "*": + # fallback to plain text on Windows if no useful x-python association + content_type = Gio.content_type_from_mime_type("text/plain") + dialog = Gtk.AppChooserDialog.new_for_content_type( + parent, + Gtk.DialogFlags.MODAL, + content_type, + ) + dialog.set_heading("Choose an editor below") + widget = dialog.get_widget() + widget.set_default_text("Choose an editor") + widget.set_show_default(True) + widget.set_show_recommended(True) + widget.set_show_fallback(True) + + editor = None + response = dialog.run() + if response == Gtk.ResponseType.OK: + appinfo = dialog.get_app_info() + editor = config.editor = appinfo.get_executable() + dialog.destroy() + return editor diff --git a/grc/gui/DrawingArea.py b/grc/gui/DrawingArea.py new file mode 100644 index 0000000..fae1ab4 --- /dev/null +++ b/grc/gui/DrawingArea.py @@ -0,0 +1,262 @@ +""" +Copyright 2007, 2008, 2009, 2010 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from gi.repository import Gtk, Gdk + +from .canvas.colors import FLOWGRAPH_BACKGROUND_COLOR +from . import Constants +from . import Actions + + +class DrawingArea(Gtk.DrawingArea): + """ + DrawingArea is the gtk pixel map that graphical elements may draw themselves on. + The drawing area also responds to mouse and key events. + """ + + def __init__(self, flow_graph): + """ + DrawingArea constructor. + Connect event handlers. + + Args: + main_window: the main_window containing all flow graphs + """ + Gtk.DrawingArea.__init__(self) + + self._flow_graph = flow_graph + self.set_property('can_focus', True) + + self.zoom_factor = 1.0 + self._update_after_zoom = False + self.ctrl_mask = False + self.mod1_mask = False + self.button_state = [False] * 10 + + # middle mouse panning + self._old_mouse_coodinates = (0, 0) + + # self.set_size_request(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) + self.connect('realize', self._handle_window_realize) + self.connect('draw', self.draw) + self.connect('motion-notify-event', self._handle_mouse_motion) + self.connect('button-press-event', self._handle_mouse_button_press) + self.connect('button-release-event', self._handle_mouse_button_release) + self.connect('scroll-event', self._handle_mouse_scroll) + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.POINTER_MOTION_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK | + Gdk.EventMask.SCROLL_MASK | + Gdk.EventMask.LEAVE_NOTIFY_MASK | + Gdk.EventMask.ENTER_NOTIFY_MASK + # Gdk.EventMask.FOCUS_CHANGE_MASK + ) + + # This may not be the correct place to be handling the user events + # Should this be in the page instead? + # Or should more of the page functionality move here? + + # setup drag and drop + self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY) + self.connect('drag-data-received', self._handle_drag_data_received) + self.drag_dest_set_target_list(None) + self.drag_dest_add_text_targets() + + # setup the focus flag + self._focus_flag = False + self.get_focus_flag = lambda: self._focus_flag + + def _handle_notify_event(widget, event, focus_flag): + self._focus_flag = focus_flag + + self.connect('leave-notify-event', _handle_notify_event, False) + self.connect('enter-notify-event', _handle_notify_event, True) + + self.set_can_focus(True) + self.connect('focus-out-event', self._handle_focus_lost_event) + + ########################################################################## + # Handlers + ########################################################################## + + def _handle_drag_data_received(self, widget, drag_context, x, y, selection_data, info, time): + """ + Handle a drag and drop by adding a block at the given coordinate. + """ + coords = x / self.zoom_factor, y / self.zoom_factor + self._flow_graph.add_new_block(selection_data.get_text(), coords) + + def zoom_in(self): + change = 1.2 + zoom_factor = min(self.zoom_factor * change, 5.0) + self._set_zoom_factor(zoom_factor) + + def zoom_out(self): + change = 1 / 1.2 + zoom_factor = max(self.zoom_factor * change, 0.1) + self._set_zoom_factor(zoom_factor) + + def reset_zoom(self): + self._set_zoom_factor(1.0) + + def _set_zoom_factor(self, zoom_factor): + if zoom_factor != self.zoom_factor: + self.zoom_factor = zoom_factor + self._update_after_zoom = True + self.queue_draw() + + def _middle_mouse_pan(self, event): + """ + Pan the canvas with the middle mouse button. + """ + x, y = event.x, event.y + old_x, old_y = self._old_mouse_coodinates + + scrollbox = self.get_parent().get_parent() + + def scroll(dpos, adj): + adj_val = adj.get_value() + if abs(dpos) >= Constants.SCROLL_DISTANCE: + adj.set_value(adj_val - dpos) + adj.emit('changed') + + dx = x - old_x + dy = y - old_y + + scroll(dx, scrollbox.get_hadjustment()) + scroll(dy, scrollbox.get_vadjustment()) + + def _handle_mouse_scroll(self, widget, event): + if event.get_state() & Gdk.ModifierType.CONTROL_MASK: + if event.direction == Gdk.ScrollDirection.UP: + self.zoom_in() + else: + self.zoom_out() + return True + return False + + def _handle_mouse_button_press(self, widget, event): + """ + Forward button click information to the flow graph. + """ + self.grab_focus() + + self.ctrl_mask = event.get_state() & Gdk.ModifierType.CONTROL_MASK + self.mod1_mask = event.get_state() & Gdk.ModifierType.MOD1_MASK + self.button_state[event.button] = True + + if event.button == 1: + double_click = (event.type == Gdk.EventType._2BUTTON_PRESS) + self.button_state[1] = not double_click + self._flow_graph.handle_mouse_selector_press( + double_click=double_click, + coordinate=self._translate_event_coords(event), + ) + elif event.button == 3: + self._flow_graph.handle_mouse_context_press( + coordinate=self._translate_event_coords(event), + event=event, + ) + elif event.button == 2: + # middle mouse panning + self._old_mouse_coodinates = (event.x, event.y) + + def _handle_mouse_button_release(self, widget, event): + """ + Forward button release information to the flow graph. + """ + self.ctrl_mask = event.get_state() & Gdk.ModifierType.CONTROL_MASK + self.mod1_mask = event.get_state() & Gdk.ModifierType.MOD1_MASK + self.button_state[event.button] = False + if event.button == 1: + self._flow_graph.handle_mouse_selector_release( + coordinate=self._translate_event_coords(event), + ) + + def _handle_mouse_motion(self, widget, event): + """ + Forward mouse motion information to the flow graph. + """ + self.ctrl_mask = event.get_state() & Gdk.ModifierType.CONTROL_MASK + self.mod1_mask = event.get_state() & Gdk.ModifierType.MOD1_MASK + + if self.button_state[1]: + self._auto_scroll(event) + elif self.button_state[2]: + self._middle_mouse_pan(event) + + self._flow_graph.handle_mouse_motion( + coordinate=self._translate_event_coords(event), + ) + + def _update_size(self): + w, h = self._flow_graph.get_extents()[2:] + self.set_size_request( + w * self.zoom_factor + 100, + h * self.zoom_factor + 100, + ) + + def _auto_scroll(self, event): + x, y = event.x, event.y + scrollbox = self.get_parent().get_parent() + + self._update_size() + + def scroll(pos, adj): + """scroll if we moved near the border""" + adj_val = adj.get_value() + adj_len = adj.get_page_size() + if pos - adj_val > adj_len - Constants.SCROLL_PROXIMITY_SENSITIVITY: + adj.set_value(adj_val + Constants.SCROLL_DISTANCE) + adj.emit('changed') + elif pos - adj_val < Constants.SCROLL_PROXIMITY_SENSITIVITY: + adj.set_value(adj_val - Constants.SCROLL_DISTANCE) + adj.emit('changed') + + scroll(x, scrollbox.get_hadjustment()) + scroll(y, scrollbox.get_vadjustment()) + + def _handle_window_realize(self, widget): + """ + Called when the window is realized. + Update the flowgraph, which calls new pixmap. + """ + self._flow_graph.update() + self._update_size() + + def draw(self, widget, cr): + width = widget.get_allocated_width() + height = widget.get_allocated_height() + + cr.set_source_rgba(*FLOWGRAPH_BACKGROUND_COLOR) + cr.rectangle(0, 0, width, height) + cr.fill() + + cr.scale(self.zoom_factor, self.zoom_factor) + cr.set_line_width(2.0 / self.zoom_factor) + + if self._update_after_zoom: + self._flow_graph.create_labels(cr) + self._flow_graph.create_shapes() + self._update_size() + self._update_after_zoom = False + + self._flow_graph.draw(cr) + + def _translate_event_coords(self, event): + return event.x / self.zoom_factor, event.y / self.zoom_factor + + def _handle_focus_lost_event(self, widget, event): + # don't clear selection while context menu is active + if not self._flow_graph.get_context_menu()._menu.get_visible(): + self._flow_graph.unselect() + self._flow_graph.update_selected() + self.queue_draw() + Actions.ELEMENT_SELECT() diff --git a/grc/gui/Executor.py b/grc/gui/Executor.py new file mode 100644 index 0000000..4d604ca --- /dev/null +++ b/grc/gui/Executor.py @@ -0,0 +1,129 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import os +import shlex +import subprocess +import threading +import time +from pathlib import Path +from shutil import which as find_executable + +from gi.repository import GLib + +from ..core import Messages +from . import Utils + + +class ExecFlowGraphThread(threading.Thread): + """Execute the flow graph as a new process and wait on it to finish.""" + + def __init__(self, flow_graph_page, xterm_executable, callback): + """ + ExecFlowGraphThread constructor. + """ + threading.Thread.__init__(self) + + self.page = flow_graph_page # store page and don't use main window calls in run + self.flow_graph = self.page.flow_graph + self.xterm_executable = xterm_executable + self.update_callback = callback + + try: + if self.flow_graph.get_option('output_language') == 'python': + self.process = self.page.process = self._popen() + elif self.flow_graph.get_option('output_language') == 'cpp': + self.process = self.page.process = self._cpp_popen() + + self.update_callback() + self.start() + except Exception as e: + Messages.send_verbose_exec(str(e)) + Messages.send_end_exec() + + def _popen(self): + """ + Execute this python flow graph. + """ + generator = self.page.get_generator() + run_command = self.flow_graph.get_run_command(generator.file_path) + run_command_args = shlex.split(run_command) + + # When in no gui mode on linux, use a graphical terminal (looks nice) + xterm_executable = find_executable(self.xterm_executable) + if generator.generate_options == 'no_gui' and xterm_executable: + if ('gnome-terminal' in xterm_executable): + run_command_args = [xterm_executable, '--'] + run_command_args + else: + run_command_args = [xterm_executable, '-e'] + run_command_args + + # this does not reproduce a shell executable command string, if a graphical + # terminal is used. Passing run_command though shlex_quote would do it but + # it looks really ugly and confusing in the console panel. + Messages.send_start_exec(' '.join(run_command_args)) + + dirname = Path(generator.file_path).parent + + return subprocess.Popen( + args=run_command_args, + cwd=dirname, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + shell=False, universal_newlines=True + ) + + def _cpp_popen(self): + """ + Execute this C++ flow graph after generating and compiling it. + """ + generator = self.page.get_generator() + run_command = generator.file_path + \ + '/build/' + self.flow_graph.get_option('id') + + dirname = generator.file_path + builddir = os.path.join(dirname, 'build') + + if os.path.isfile(run_command): + os.remove(run_command) + + xterm_executable = find_executable(self.xterm_executable) + + nproc = Utils.get_cmake_nproc() + + run_command_args = f'cmake .. && cmake --build . -j{nproc} && cd ../.. && {xterm_executable} -e {run_command}' + Messages.send_start_exec(run_command_args) + + return subprocess.Popen( + args=run_command_args, + cwd=builddir, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + shell=True, universal_newlines=True + ) + + def run(self): + """ + Wait on the executing process by reading from its stdout. + Use GObject.idle_add when calling functions that modify gtk objects. + """ + # handle completion + r = "\n" + while r: + GLib.idle_add(Messages.send_verbose_exec, r) + r = self.process.stdout.read(1) + + # Properly close pipe before thread is terminated + self.process.stdout.close() + while self.process.poll() is None: + # Wait for the process to fully terminate + time.sleep(0.05) + + GLib.idle_add(self.done) + + def done(self): + """Perform end of execution tasks.""" + Messages.send_end_exec(self.process.returncode) + self.page.process = None + self.update_callback() diff --git a/grc/gui/FileDialogs.py b/grc/gui/FileDialogs.py new file mode 100644 index 0000000..275e2b3 --- /dev/null +++ b/grc/gui/FileDialogs.py @@ -0,0 +1,197 @@ +""" +Copyright 2007 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from os import path + +from gi.repository import Gtk + +from . import Constants, Utils, Dialogs + + +class FileDialogHelper(Gtk.FileChooserDialog, object): + """ + A wrapper class for the gtk file chooser dialog. + Implement a file chooser dialog with only necessary parameters. + """ + title = '' + action = Gtk.FileChooserAction.OPEN + filter_label = '' + filter_ext = '' + + def __init__(self, parent, current_file_path): + """ + FileDialogHelper constructor. + Create a save or open dialog with cancel and ok buttons. + Use standard settings: no multiple selection, local files only, and the * filter. + + Args: + action: Gtk.FileChooserAction.OPEN or Gtk.FileChooserAction.SAVE + title: the title of the dialog (string) + """ + ok_stock = { + Gtk.FileChooserAction.OPEN: 'gtk-open', + Gtk.FileChooserAction.SAVE: 'gtk-save' + }[self.action] + + Gtk.FileChooserDialog.__init__(self, title=self.title, action=self.action, + transient_for=parent) + self.add_buttons('gtk-cancel', Gtk.ResponseType.CANCEL, + ok_stock, Gtk.ResponseType.OK) + self.set_select_multiple(False) + self.set_local_only(True) + + self.parent = parent + self.current_file_path = current_file_path or path.join( + Constants.DEFAULT_FILE_PATH, Constants.NEW_FLOGRAPH_TITLE + Constants.FILE_EXTENSION) + + self.set_current_folder(path.dirname( + current_file_path)) # current directory + self.setup_filters() + + def setup_filters(self, filters=None): + set_default = True + filters = filters or ( + [(self.filter_label, self.filter_ext)] if self.filter_label else []) + if ('All Files', '') not in filters: + filters.append(('All Files', '')) + for label, ext in filters: + if not label: + continue + f = Gtk.FileFilter() + f.set_name(label) + f.add_pattern('*' + ext) + self.add_filter(f) + if not set_default: + self.set_filter(f) + set_default = True + + def run(self): + """Get the filename and destroy the dialog.""" + response = Gtk.FileChooserDialog.run(self) + filename = self.get_filename() if response == Gtk.ResponseType.OK else None + self.destroy() + return filename + + +class SaveFileDialog(FileDialogHelper): + """A dialog box to save or open flow graph files. This is a base class, do not use.""" + action = Gtk.FileChooserAction.SAVE + + def __init__(self, parent, current_file_path): + super(SaveFileDialog, self).__init__(parent, current_file_path) + self.set_current_name(path.splitext(path.basename( + self.current_file_path))[0] + self.filter_ext) + self.set_create_folders(True) + self.set_do_overwrite_confirmation(True) + + +class OpenFileDialog(FileDialogHelper): + """A dialog box to save or open flow graph files. This is a base class, do not use.""" + action = Gtk.FileChooserAction.OPEN + + def show_missing_message(self, filename): + Dialogs.MessageDialogWrapper( + self.parent, + Gtk.MessageType.WARNING, Gtk.ButtonsType.CLOSE, 'Cannot Open!', + 'File {filename} Does not Exist!'.format( + filename=Utils.encode(filename)), + ).run_and_destroy() + + def get_filename(self): + """ + Run the dialog and get the filename. + If this is a save dialog and the file name is missing the extension, append the file extension. + If the file name with the extension already exists, show a overwrite dialog. + If this is an open dialog, return a list of filenames. + + Returns: + the complete file path + """ + filenames = Gtk.FileChooserDialog.get_filenames(self) + for filename in filenames: + if not path.exists(filename): + self.show_missing_message(filename) + return None # rerun + return filenames + + +class OpenFlowGraph(OpenFileDialog): + title = 'Open a Flow Graph from a File...' + filter_label = 'Flow Graph Files' + filter_ext = Constants.FILE_EXTENSION + + def __init__(self, parent, current_file_path=''): + super(OpenFlowGraph, self).__init__(parent, current_file_path) + self.set_select_multiple(True) + + +class OpenQSS(OpenFileDialog): + title = 'Open a QSS theme...' + filter_label = 'QSS Themes' + filter_ext = '.qss' + + +class SaveFlowGraph(SaveFileDialog): + title = 'Save a Flow Graph to a File...' + filter_label = 'Flow Graph Files' + filter_ext = Constants.FILE_EXTENSION + + +class SaveConsole(SaveFileDialog): + title = 'Save Console to a File...' + filter_label = 'Test Files' + filter_ext = '.txt' + + +class SaveScreenShot(SaveFileDialog): + title = 'Save a Flow Graph Screen Shot...' + filters = [('PDF Files', '.pdf'), ('PNG Files', '.png'), + ('SVG Files', '.svg')] + filter_ext = '.pdf' # the default + + def __init__(self, parent, current_file_path=''): + super(SaveScreenShot, self).__init__(parent, current_file_path) + + self.config = Gtk.Application.get_default().config + + self._button = button = Gtk.CheckButton(label='Background transparent') + self._button.set_active( + self.config.screen_shot_background_transparent()) + self.set_extra_widget(button) + + def setup_filters(self, filters=None): + super(SaveScreenShot, self).setup_filters(self.filters) + + def show_missing_message(self, filename): + Dialogs.MessageDialogWrapper( + self.parent, + Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, 'Can not Save!', + 'File Extension of {filename} not supported!'.format( + filename=Utils.encode(filename)), + ).run_and_destroy() + + def run(self): + valid_exts = {ext for label, ext in self.filters} + filename = None + while True: + response = Gtk.FileChooserDialog.run(self) + if response != Gtk.ResponseType.OK: + filename = None + break + + filename = self.get_filename() + if path.splitext(filename)[1] in valid_exts: + break + + self.show_missing_message(filename) + + bg_transparent = self._button.get_active() + self.config.screen_shot_background_transparent(bg_transparent) + self.destroy() + return filename, bg_transparent diff --git a/grc/gui/MainWindow.py b/grc/gui/MainWindow.py new file mode 100644 index 0000000..244667c --- /dev/null +++ b/grc/gui/MainWindow.py @@ -0,0 +1,463 @@ +""" +Copyright 2008, 2009, 2011 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import os +import logging + +from gi.repository import Gtk, Gdk, GObject + +from . import Bars, Actions, Utils +from .BlockTreeWindow import BlockTreeWindow +from .Console import Console +from .VariableEditor import VariableEditor +from .Constants import \ + NEW_FLOGRAPH_TITLE, DEFAULT_CONSOLE_WINDOW_WIDTH +from .Dialogs import TextDisplay, MessageDialogWrapper +from .Notebook import Notebook, Page + +from ..core import Messages + + +log = logging.getLogger(__name__) + + +############################################################ +# Main window +############################################################ +class MainWindow(Gtk.ApplicationWindow): + """The topmost window with menus, the tool bar, and other major windows.""" + + # Constants the action handler can use to indicate which panel visibility to change. + BLOCKS = 0 + CONSOLE = 1 + VARIABLES = 2 + + def __init__(self, app, platform): + """ + MainWindow constructor + Setup the menu, toolbar, flow graph editor notebook, block selection window... + """ + Gtk.ApplicationWindow.__init__( + self, title="GNU Radio Companion", application=app) + log.debug("__init__()") + + self._platform = platform + self.app = app + self.config = platform.config + + # Add all "win" actions to the local + for x in Actions.get_actions(): + if x.startswith("win."): + self.add_action(Actions.actions[x]) + + # Setup window + vbox = Gtk.VBox() + self.add(vbox) + + icon_theme = Gtk.IconTheme.get_default() + icon = icon_theme.lookup_icon("gnuradio-grc", 48, 0) + if not icon: + # Set default window icon + self.set_icon_from_file(os.path.dirname( + os.path.abspath(__file__)) + "/icon.png") + else: + # Use gnuradio icon + self.set_icon(icon.load_icon()) + + # Create the menu bar and toolbar + log.debug("Creating menu") + generate_modes = platform.get_generate_options() + + # This needs to be replaced + # Have an option for either the application menu or this menu + self.menu = Bars.Menu() + self.menu_bar = Gtk.MenuBar.new_from_model(self.menu) + vbox.pack_start(self.menu_bar, False, False, 0) + + self.tool_bar = Bars.Toolbar() + self.tool_bar.set_hexpand(True) + # Show the toolbar + self.tool_bar.show() + vbox.pack_start(self.tool_bar, False, False, 0) + + # Main parent container for the different panels + self.main = Gtk.HPaned() # (orientation=Gtk.Orientation.HORIZONTAL) + vbox.pack_start(self.main, True, True, 0) + + # Create the notebook + self.notebook = Notebook() + self.page_to_be_closed = None + + self.current_page = None # type: Page + + # Create the console window + self.console = Console() + + # Create the block tree and variable panels + self.btwin = BlockTreeWindow(platform) + self.btwin.connect('create_new_block', + self._add_block_to_current_flow_graph) + self.vars = VariableEditor() + self.vars.connect('create_new_block', + self._add_block_to_current_flow_graph) + self.vars.connect( + 'remove_block', self._remove_block_from_current_flow_graph) + + # Figure out which place to put the variable editor + self.left = Gtk.VPaned() # orientation=Gtk.Orientation.VERTICAL) + self.right = Gtk.VPaned() # orientation=Gtk.Orientation.VERTICAL) + # orientation=Gtk.Orientation.HORIZONTAL) + self.left_subpanel = Gtk.HPaned() + + self.variable_panel_sidebar = self.config.variable_editor_sidebar() + if self.variable_panel_sidebar: + self.left.pack1(self.notebook) + self.left.pack2(self.console, False) + self.right.pack1(self.btwin) + self.right.pack2(self.vars, False) + else: + # Put the variable editor in a panel with the console + self.left.pack1(self.notebook) + self.left_subpanel.pack1(self.console, shrink=False) + self.left_subpanel.pack2(self.vars, resize=False, shrink=True) + self.left.pack2(self.left_subpanel, False) + + # Create the right panel + self.right.pack1(self.btwin) + + self.main.pack1(self.left) + self.main.pack2(self.right, False) + + # Load preferences and show the main window + self.resize(*self.config.main_window_size()) + self.main.set_position(self.config.blocks_window_position()) + self.left.set_position(self.config.console_window_position()) + if self.variable_panel_sidebar: + self.right.set_position( + self.config.variable_editor_position(sidebar=True)) + else: + self.left_subpanel.set_position( + self.config.variable_editor_position()) + + self.show_all() + log.debug("Main window ready") + + ############################################################ + # Event Handlers + ############################################################ + + def _add_block_to_current_flow_graph(self, widget, key): + self.current_flow_graph.add_new_block(key) + + def _remove_block_from_current_flow_graph(self, widget, key): + block = self.current_flow_graph.get_block(key) + self.current_flow_graph.remove_element(block) + + def _quit(self, window, event): + """ + Handle the delete event from the main window. + Generated by pressing X to close, alt+f4, or right click+close. + This method in turns calls the state handler to quit. + + Returns: + true + """ + Actions.APPLICATION_QUIT() + return True + + def update_panel_visibility(self, panel, visibility=True): + """ + Handles changing visibility of panels. + """ + # Set the visibility for the requested panel, then update the containers if they need + # to be hidden as well. + + if panel == self.BLOCKS: + if visibility: + self.btwin.show() + else: + self.btwin.hide() + elif panel == self.CONSOLE: + if visibility: + self.console.show() + else: + self.console.hide() + elif panel == self.VARIABLES: + if visibility: + self.vars.show() + else: + self.vars.hide() + else: + return + + if self.variable_panel_sidebar: + # If both the variable editor and block panels are hidden, hide the right container + if not (self.btwin.get_property('visible')) and not (self.vars.get_property('visible')): + self.right.hide() + else: + self.right.show() + else: + if not (self.btwin.get_property('visible')): + self.right.hide() + else: + self.right.show() + if not (self.vars.get_property('visible')) and not (self.console.get_property('visible')): + self.left_subpanel.hide() + else: + self.left_subpanel.show() + + ############################################################ + # Console Window + ############################################################ + + @property + def current_page(self): + return self.notebook.current_page + + @current_page.setter + def current_page(self, page): + self.notebook.current_page = page + + def add_console_line(self, line): + """ + Place line at the end of the text buffer, then scroll its window all the way down. + + Args: + line: the new text + """ + self.console.add_line(line) + + ############################################################ + # Pages: create and close + ############################################################ + + def new_page(self, file_path='', show=False): + """ + Create a new notebook page. + Set the tab to be selected. + + Args: + file_path: optional file to load into the flow graph + show: true if the page should be shown after loading + """ + # if the file is already open, show the open page and return + if file_path and file_path in self._get_files(): # already open + page = self.notebook.get_nth_page( + self._get_files().index(file_path)) + self._set_page(page) + return + try: # try to load from file + if file_path: + Messages.send_start_load(file_path) + flow_graph = self._platform.make_flow_graph() + flow_graph.grc_file_path = file_path + # print flow_graph + page = Page( + self, + flow_graph=flow_graph, + file_path=file_path, + ) + if getattr(Messages, 'flowgraph_error') is not None: + Messages.send( + ">>> Check: {}\n>>> FlowGraph Error: {}\n".format( + str(Messages.flowgraph_error_file), + str(Messages.flowgraph_error) + ) + ) + if file_path: + Messages.send_end_load() + except Exception as e: # return on failure + Messages.send_fail_load(e) + if isinstance(e, KeyError) and str(e) == "'options'": + # This error is unrecoverable, so crash gracefully + exit(-1) + return + # add this page to the notebook + self.notebook.append_page(page, page.tab) + self.notebook.set_tab_reorderable(page, True) + # only show if blank or manual + if not file_path or show: + self._set_page(page) + + def close_pages(self): + """ + Close all the pages in this notebook. + + Returns: + true if all closed + """ + open_files = [file for file in self._get_files() + if file] # filter blank files + open_file = self.current_page.file_path + # close each page + for page in sorted(self.get_pages(), key=lambda p: p.saved): + self.page_to_be_closed = page + closed = self.close_page(False) + if not closed: + break + if self.notebook.get_n_pages(): + return False + # save state before closing + self.config.set_open_files(open_files) + self.config.file_open(open_file) + self.config.main_window_size(self.get_size()) + self.config.console_window_position(self.left.get_position()) + self.config.blocks_window_position(self.main.get_position()) + if self.variable_panel_sidebar: + self.config.variable_editor_position( + self.right.get_position(), sidebar=True) + else: + self.config.variable_editor_position( + self.left_subpanel.get_position()) + self.config.save() + return True + + def close_page(self, ensure=True): + """ + Close the current page. + If the notebook becomes empty, and ensure is true, + call new page upon exit to ensure that at least one page exists. + + Args: + ensure: boolean + """ + if not self.page_to_be_closed: + self.page_to_be_closed = self.current_page + # show the page if it has an executing flow graph or is unsaved + if self.page_to_be_closed.process or not self.page_to_be_closed.saved: + self._set_page(self.page_to_be_closed) + # unsaved? ask the user + if not self.page_to_be_closed.saved: + response = self._save_changes() # return value can be OK, CLOSE, CANCEL, DELETE_EVENT, or NONE + if response == Gtk.ResponseType.OK: + Actions.FLOW_GRAPH_SAVE() # try to save + if not self.page_to_be_closed.saved: # still unsaved? + self.page_to_be_closed = None # set the page to be closed back to None + return False + elif response != Gtk.ResponseType.CLOSE: + self.page_to_be_closed = None + return False + # stop the flow graph if executing + if self.page_to_be_closed.process: + Actions.FLOW_GRAPH_KILL() + # remove the page + self.notebook.remove_page( + self.notebook.page_num(self.page_to_be_closed)) + if ensure and self.notebook.get_n_pages() == 0: + self.new_page() # no pages, make a new one + self.page_to_be_closed = None # set the page to be closed back to None + return True + + ############################################################ + # Misc + ############################################################ + + def update(self): + """ + Set the title of the main window. + Set the titles on the page tabs. + Show/hide the console window. + """ + page = self.current_page + + basename = os.path.basename(page.file_path) + dirname = os.path.dirname(page.file_path) + Gtk.Window.set_title(self, ''.join(( + '*' if not page.saved else '', basename if basename else NEW_FLOGRAPH_TITLE, + '(read only)' if page.get_read_only() else '', ' - ', + dirname if dirname else self._platform.config.name, + ))) + # set tab titles + for page in self.get_pages(): + file_name = os.path.splitext(os.path.basename(page.file_path))[0] + page.set_markup('{title}{ro}'.format( + foreground='black' if page.saved else 'red', ro=' (ro)' if page.get_read_only() else '', + title=Utils.encode(file_name or NEW_FLOGRAPH_TITLE), + )) + fpath = page.file_path + if not fpath: + fpath = '(unsaved)' + page.set_tooltip(fpath) + # show/hide notebook tabs + self.notebook.set_show_tabs(len(self.get_pages()) > 1) + + # Need to update the variable window when changing + self.vars.update_gui(self.current_flow_graph.blocks) + + def update_pages(self): + """ + Forces a reload of all the pages in this notebook. + """ + for page in self.get_pages(): + success = page.flow_graph.reload() + if success: # Only set saved if errors occurred during import + page.saved = False + + @property + def current_flow_graph(self): + return self.current_page.flow_graph + + def get_focus_flag(self): + """ + Get the focus flag from the current page. + Returns: + the focus flag + """ + return self.current_page.drawing_area.get_focus_flag() + + ############################################################ + # Helpers + ############################################################ + + def _set_page(self, page): + """ + Set the current page. + + Args: + page: the page widget + """ + self.current_page = page + self.notebook.set_current_page( + self.notebook.page_num(self.current_page)) + + def _save_changes(self): + """ + Save changes to flow graph? + + Returns: + the response_id (see buttons variable below) + """ + buttons = ( + 'Close without saving', Gtk.ResponseType.CLOSE, + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK + ) + return MessageDialogWrapper( + self, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, 'Unsaved Changes!', + 'Would you like to save changes before closing?', Gtk.ResponseType.OK, buttons + ).run_and_destroy() + + def _get_files(self): + """ + Get the file names for all the pages, in order. + + Returns: + list of file paths + """ + return [page.file_path for page in self.get_pages()] + + def get_pages(self): + """ + Get a list of all pages in the notebook. + + Returns: + list of pages + """ + return [self.notebook.get_nth_page(page_num) + for page_num in range(self.notebook.get_n_pages())] diff --git a/grc/gui/Notebook.py b/grc/gui/Notebook.py new file mode 100644 index 0000000..6da39b4 --- /dev/null +++ b/grc/gui/Notebook.py @@ -0,0 +1,192 @@ +""" +Copyright 2008, 2009, 2011 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + +import os +import logging + +from gi.repository import Gtk, Gdk, GObject + +from . import Actions +from .StateCache import StateCache +from .Constants import MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT +from .DrawingArea import DrawingArea + + +log = logging.getLogger(__name__) + + +class Notebook(Gtk.Notebook): + def __init__(self): + Gtk.Notebook.__init__(self) + log.debug("notebook()") + self.app = Gtk.Application.get_default() + self.current_page = None + + self.set_show_border(False) + self.set_scrollable(True) + self.connect('switch-page', self._handle_page_change) + + self.add_events(Gdk.EventMask.SCROLL_MASK) + self.connect('scroll-event', self._handle_scroll) + self._ignore_consecutive_scrolls = 0 + + def _handle_page_change(self, notebook, page, page_num): + """ + Handle a page change. When the user clicks on a new tab, + reload the flow graph to update the vars window and + call handle states (select nothing) to update the buttons. + + Args: + notebook: the notebook + page: new page + page_num: new page number + """ + self.current_page = self.get_nth_page(page_num) + Actions.PAGE_CHANGE() + + def _handle_scroll(self, widget, event): + # Not sure how to handle this at the moment. + natural = True + # Slow it down + if self._ignore_consecutive_scrolls == 0: + if event.direction in (Gdk.ScrollDirection.UP, Gdk.ScrollDirection.LEFT): + if natural: + self.prev_page() + else: + self.next_page() + elif event.direction in (Gdk.ScrollDirection.DOWN, Gdk.ScrollDirection.RIGHT): + if natural: + self.next_page() + else: + self.prev_page() + self._ignore_consecutive_scrolls = 3 + else: + self._ignore_consecutive_scrolls -= 1 + return False + + +class Page(Gtk.HBox): + """A page in the notebook.""" + + def __init__(self, main_window, flow_graph, file_path=''): + """ + Page constructor. + + Args: + main_window: main window + file_path: path to a flow graph file + """ + Gtk.HBox.__init__(self) + + self.main_window = main_window + self.flow_graph = flow_graph + self.file_path = file_path + + self.process = None + self.saved = True + if not self.file_path: + self.saved = False + + # import the file + initial_state = flow_graph.parent_platform.parse_flow_graph(file_path) + flow_graph.import_data(initial_state) + self.state_cache = StateCache(initial_state) + + # tab box to hold label and close button + self.label = Gtk.Label() + image = Gtk.Image.new_from_icon_name('window-close', Gtk.IconSize.MENU) + image_box = Gtk.HBox(homogeneous=False, spacing=0) + image_box.pack_start(image, True, False, 0) + button = Gtk.Button() + button.connect("clicked", self._handle_button) + button.set_relief(Gtk.ReliefStyle.NONE) + button.add(image_box) + + tab = self.tab = Gtk.HBox(homogeneous=False, spacing=0) + tab.pack_start(self.label, False, False, 0) + tab.pack_start(button, False, False, 0) + tab.show_all() + + # setup scroll window and drawing area + self.drawing_area = DrawingArea(flow_graph) + flow_graph.drawing_area = self.drawing_area + + self.viewport = Gtk.Viewport() + self.viewport.add(self.drawing_area) + + self.scrolled_window = Gtk.ScrolledWindow() + self.scrolled_window.set_size_request( + MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) + self.scrolled_window.set_policy( + Gtk.PolicyType.ALWAYS, Gtk.PolicyType.ALWAYS) + self.scrolled_window.connect( + 'key-press-event', self._handle_scroll_window_key_press) + + self.scrolled_window.add(self.viewport) + self.pack_start(self.scrolled_window, True, True, 0) + self.show_all() + + def _handle_scroll_window_key_press(self, widget, event): + is_ctrl_pg = ( + event.state & Gdk.ModifierType.CONTROL_MASK and + event.keyval in (Gdk.KEY_Page_Up, Gdk.KEY_Page_Down) + ) + if is_ctrl_pg: + return self.get_parent().event(event) + + def get_generator(self): + """ + Get the generator object for this flow graph. + + Returns: + generator + """ + platform = self.flow_graph.parent_platform + return platform.Generator(self.flow_graph, os.path.dirname(self.file_path)) + + def _handle_button(self, button): + """ + The button was clicked. + Make the current page selected, then close. + + Args: + the: button + """ + self.main_window.page_to_be_closed = self + Actions.FLOW_GRAPH_CLOSE() + + def set_markup(self, markup): + """ + Set the markup in this label. + + Args: + markup: the new markup text + """ + self.label.set_markup(markup) + + def set_tooltip(self, text): + """ + Set the tooltip text in this label. + + Args: + text: the new tooltip text + """ + self.label.set_tooltip_text(text) + + def get_read_only(self): + """ + Get the read-only state of the file. + Always false for empty path. + + Returns: + true for read-only + """ + if not self.file_path: + return False + return (os.path.exists(self.file_path) and + not os.access(self.file_path, os.W_OK)) diff --git a/grc/gui/ParamWidgets.py b/grc/gui/ParamWidgets.py new file mode 100644 index 0000000..132efad --- /dev/null +++ b/grc/gui/ParamWidgets.py @@ -0,0 +1,419 @@ +# Copyright 2007-2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +import configparser +import os +import subprocess + +from gi.repository import Gdk, Gtk + +from . import Actions, Constants, Dialogs, Utils +from .canvas.colors import DARK_THEME_STYLES, LIGHT_THEME_STYLES + + +def have_dark_theme(): + """ + Returns true if the currently selected theme is a dark one. + """ + + def is_dark_theme(theme_name): + """ + Check if a theme is dark based on its name. + """ + return theme_name and (theme_name in Constants.GTK_DARK_THEMES or "dark" in theme_name.lower()) + + # GoGoGo + config = configparser.ConfigParser() + config.read(os.path.expanduser(Constants.GTK_SETTINGS_INI_PATH)) + prefer_dark = config.get( + 'Settings', Constants.GTK_INI_PREFER_DARK_KEY, fallback=None) + theme_name = config.get( + 'Settings', Constants.GTK_INI_THEME_NAME_KEY, fallback=None) + if prefer_dark in ('1', 'yes', 'true', 'on') or is_dark_theme(theme_name): + return True + try: + theme = subprocess.check_output( + ["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"], + stderr=subprocess.DEVNULL + ).decode('utf-8').strip().replace("'", "") + except: + return False + return is_dark_theme(theme) + + +def add_style_provider(): + """ + Load GTK styles + """ + style_provider = Gtk.CssProvider() + dark_theme = have_dark_theme() + style_provider.load_from_data( + DARK_THEME_STYLES if dark_theme else LIGHT_THEME_STYLES) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + style_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + +add_style_provider() + + +class InputParam(Gtk.HBox): + """The base class for an input parameter inside the input parameters dialog.""" + + expand = False + + def __init__(self, param, changed_callback=None, editing_callback=None, transient_for=None): + Gtk.HBox.__init__(self) + + self.param = param + self._changed_callback = changed_callback + self._editing_callback = editing_callback + self._transient_for = transient_for + + self.label = Gtk.Label() + self.label.set_size_request(Utils.scale_scalar(150), -1) + self.label.show() + self.pack_start(self.label, False, False, 0) + + self.dtype_label = None + ignore_dtype_labels = [ + "dir_select", + "enum", + "file_open", + "file_save", + "gui_hint", + "id", + "_multiline", + "_multiline_python_external", + "raw" + ] + if self.param.dtype not in ignore_dtype_labels: + self.dtype_label = Gtk.Label() + self.dtype_label.set_size_request(Utils.scale_scalar(50), -1) + self.dtype_label.show() + self.pack_end(self.dtype_label, False, False, 10) + + self.tp = None + self._have_pending_changes = False + + self.connect('show', self._update_gui) + + def set_color(self, css_name): + pass + + def set_tooltip_text(self, text): + pass + + def get_text(self): + raise NotImplementedError() + + def _update_gui(self, *args): + """ + Set the markup, color, tooltip, show/hide. + """ + self.label.set_markup( + self.param.format_label_markup(self._have_pending_changes)) + if Actions.TOGGLE_SHOW_FIELD_COLORS.get_active(): + self.set_color('dtype_' + self.param.dtype) + if self.dtype_label is not None: + self.dtype_label.set_markup(self.param.format_dtype_markup()) + + self.set_tooltip_text(self.param.format_tooltip_text()) + + if self.param.hide == 'all': + self.hide() + else: + self.show_all() + + def _mark_changed(self, *args): + """ + Mark this param as modified on change, but validate only on focus-lost + """ + self._have_pending_changes = True + self._update_gui() + if self._editing_callback: + self._editing_callback(self, None) + + def _apply_change(self, *args): + """ + Handle a gui change by setting the new param value, + calling the callback (if applicable), and updating. + """ + # set the new value + self.param.set_value(self.get_text()) + # call the callback + if self._changed_callback: + self._changed_callback(self, None) + else: + self.param.validate() + # gui update + self._have_pending_changes = False + self._update_gui() + + def _handle_key_press(self, widget, event): + if event.keyval == Gdk.KEY_Return and event.get_state() & Gdk.ModifierType.CONTROL_MASK: + self._apply_change(widget, event) + return True + return False + + def apply_pending_changes(self): + if self._have_pending_changes: + self._apply_change() + + +class EntryParam(InputParam): + """Provide an entry box for strings and numbers.""" + + def __init__(self, *args, **kwargs): + InputParam.__init__(self, *args, **kwargs) + self._input = Gtk.Entry() + self._input.set_text(self.param.get_value()) + self._input.connect('changed', self._mark_changed) + self._input.connect('focus-out-event', self._apply_change) + self._input.connect('key-press-event', self._handle_key_press) + self.pack_start(self._input, True, True, 0) + + def get_text(self): + return self._input.get_text() + + def set_color(self, css_name): + self._input.set_name(css_name) + + def set_tooltip_text(self, text): + self._input.set_tooltip_text(text) + + +class MultiLineEntryParam(InputParam): + """Provide an multi-line box for strings.""" + + expand = True + + def __init__(self, *args, **kwargs): + InputParam.__init__(self, *args, **kwargs) + self._buffer = Gtk.TextBuffer() + self._buffer.set_text(self.param.get_value()) + self._buffer.connect('changed', self._mark_changed) + + self._view = Gtk.TextView() + self._view.set_buffer(self._buffer) + self._view.connect('focus-out-event', self._apply_change) + self._view.connect('key-press-event', self._handle_key_press) + + self._sw = Gtk.ScrolledWindow() + self._sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + self._sw.set_shadow_type(type=Gtk.ShadowType.IN) + self._sw.add(self._view) + + self.pack_start(self._sw, True, True, True) + + def get_text(self): + buf = self._buffer + text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), + include_hidden_chars=False) + return text.strip() + + def set_color(self, css_name): + self._view.set_name(css_name) + + def set_tooltip_text(self, text): + self._view.set_tooltip_text(text) + + +class PythonEditorParam(InputParam): + def __init__(self, *args, **kwargs): + InputParam.__init__(self, *args, **kwargs) + open_button = self._open_button = Gtk.Button(label='Open in Editor') + open_button.connect('clicked', self.open_editor) + self.pack_start(open_button, True, True, True) + chooser_button = self._chooser_button = Gtk.Button(label='Choose Editor') + chooser_button.connect('clicked', self.open_chooser) + self.pack_start(chooser_button, True, True, True) + + def open_editor(self, widget=None): + self.param.parent_flowgraph.install_external_editor( + self.param, parent=self._transient_for) + + def open_chooser(self, widget=None): + self.param.parent_flowgraph.remove_external_editor(param=self.param) + editor = Dialogs.choose_editor( + parent=self._transient_for, + config=self.param.parent_flowgraph.parent_platform.config, + ) + + def get_text(self): + pass # we never update the value from here + + def _apply_change(self, *args): + pass + + +class EnumParam(InputParam): + """Provide an entry box for Enum types with a drop down menu.""" + + def __init__(self, *args, **kwargs): + InputParam.__init__(self, *args, **kwargs) + self._input = Gtk.ComboBoxText() + for option_name in self.param.options.values(): + self._input.append_text(option_name) + + self.param_values = list(self.param.options) + self._input.set_active(self.param_values.index(self.param.get_value())) + self._input.connect('changed', self._editing_callback) + self._input.connect('changed', self._apply_change) + self.pack_start(self._input, False, False, 0) + + def get_text(self): + return self.param_values[self._input.get_active()] + + def set_tooltip_text(self, text): + self._input.set_tooltip_text(text) + + +class EnumEntryParam(InputParam): + """Provide an entry box and drop down menu for Raw Enum types.""" + + def __init__(self, *args, **kwargs): + InputParam.__init__(self, *args, **kwargs) + self._input = Gtk.ComboBoxText.new_with_entry() + for option_name in self.param.options.values(): + self._input.append_text(option_name) + + self.param_values = list(self.param.options) + value = self.param.get_value() + try: + self._input.set_active(self.param_values.index(value)) + except ValueError: + self._input.set_active(-1) + self._input.get_child().set_text(value) + + self._input.connect('changed', self._apply_change) + self._input.get_child().connect('changed', self._mark_changed) + self._input.get_child().connect('focus-out-event', self._apply_change) + self._input.get_child().connect('key-press-event', self._handle_key_press) + self.pack_start(self._input, False, False, 0) + + @property + def has_custom_value(self): + return self._input.get_active() == -1 + + def get_text(self): + if self.has_custom_value: + return self._input.get_child().get_text() + else: + return self.param_values[self._input.get_active()] + + def set_tooltip_text(self, text): + if self.has_custom_value: # custom entry + self._input.get_child().set_tooltip_text(text) + else: + self._input.set_tooltip_text(text) + + def set_color(self, css_name): + self._input.get_child().set_name( + css_name if not self.has_custom_value else 'enum_custom' + ) + + +class FileParam(EntryParam): + """Provide an entry box for filename and a button to browse for a file.""" + + def __init__(self, *args, **kwargs): + EntryParam.__init__(self, *args, **kwargs) + self._open_button = Gtk.Button(label='...') + self._open_button.connect('clicked', self._handle_clicked) + self.pack_start(self._open_button, False, False, 0) + + def _handle_clicked(self, widget=None): + """ + If the button was clicked, open a file dialog in open/save format. + Replace the text in the entry with the new filename from the file dialog. + """ + # get the paths + file_path = self.param.is_valid() and self.param.get_evaluated() or '' + (dirname, basename) = os.path.isfile( + file_path) and os.path.split(file_path) or (file_path, '') + # check for qss theme default directory + if self.param.key == 'qt_qss_theme': + dirname = os.path.dirname(dirname) # trim filename + if not os.path.exists(dirname): + config = self.param.parent_platform.config + dirname = os.path.join( + config.install_prefix, '/share/gnuradio/themes') + if not os.path.exists(dirname): + dirname = os.getcwd() # fix bad paths + + # build the dialog + if self.param.dtype == 'file_open': + file_dialog = Gtk.FileChooserDialog( + title='Open a Data File...', action=Gtk.FileChooserAction.OPEN, + transient_for=self._transient_for, + ) + file_dialog.add_buttons( + 'gtk-cancel', Gtk.ResponseType.CANCEL, 'gtk-open', Gtk.ResponseType.OK) + elif self.param.dtype == 'file_save': + file_dialog = Gtk.FileChooserDialog( + title='Save a Data File...', action=Gtk.FileChooserAction.SAVE, + transient_for=self._transient_for, + ) + file_dialog.add_buttons( + 'gtk-cancel', Gtk.ResponseType.CANCEL, 'gtk-save', Gtk.ResponseType.OK) + file_dialog.set_do_overwrite_confirmation(True) + file_dialog.set_current_name(basename) # show the current filename + else: + raise ValueError( + "Can't open file chooser dialog for type " + repr(self.param.dtype)) + file_dialog.set_current_folder(dirname) # current directory + file_dialog.set_select_multiple(False) + file_dialog.set_local_only(True) + if Gtk.ResponseType.OK == file_dialog.run(): # run the dialog + file_path = file_dialog.get_filename() # get the file path + self._input.set_text(file_path) + self._editing_callback() + self._apply_change() + file_dialog.destroy() # destroy the dialog + + +class DirectoryParam(FileParam): + """Provide an entry box for a directory and a button to browse for it.""" + + def _handle_clicked(self, widget=None): + """ + Open the directory selector, when the button is clicked. + On success, update the entry. + """ + dirname = self.param.get_evaluated() if self.param.is_valid() else '' + + # Check if directory exists, if not fall back to workdir + if not os.path.isdir(dirname): + dirname = os.getcwd() + + if self.param.dtype == "dir_select": # Setup directory selection dialog, and fail for unexpected dtype + dir_dialog = Gtk.FileChooserDialog( + title='Select a Directory...', action=Gtk.FileChooserAction.SELECT_FOLDER, + transient_for=self._transient_for + ) + else: + raise ValueError( + "Can't open directory chooser dialog for type " + repr(self.param.dtype)) + + # Set dialog properties + dir_dialog.add_buttons( + 'gtk-cancel', Gtk.ResponseType.CANCEL, 'gtk-open', Gtk.ResponseType.OK) + dir_dialog.set_current_folder(dirname) + dir_dialog.set_local_only(True) + dir_dialog.set_select_multiple(False) + + # Show dialog and update parameter on success + if Gtk.ResponseType.OK == dir_dialog.run(): + path = dir_dialog.get_filename() + self._input.set_text(path) + self._editing_callback() + self._apply_change() + + # Cleanup dialog + dir_dialog.destroy() diff --git a/grc/gui/ParserErrorsDialog.py b/grc/gui/ParserErrorsDialog.py new file mode 100644 index 0000000..745dc47 --- /dev/null +++ b/grc/gui/ParserErrorsDialog.py @@ -0,0 +1,91 @@ +""" +Copyright 2013 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from gi.repository import Gtk, GObject + +from .Constants import MIN_DIALOG_WIDTH, MIN_DIALOG_HEIGHT + + +class ParserErrorsDialog(Gtk.Dialog): + """ + A dialog for viewing parser errors + """ + + def __init__(self, error_logs): + """ + Properties dialog constructor. + + Args: + block: a block instance + """ + GObject.GObject.__init__(self, title='Parser Errors', buttons=( + Gtk.STOCK_CLOSE, Gtk.ResponseType.ACCEPT)) + + self._error_logs = None + self.tree_store = Gtk.TreeStore(str) + self.update_tree_store(error_logs) + + column = Gtk.TreeViewColumn('XML Parser Errors by Filename') + renderer = Gtk.CellRendererText() + column.pack_start(renderer, True) + column.add_attribute(renderer, 'text', 0) + column.set_sort_column_id(0) + + self.tree_view = tree_view = Gtk.TreeView(self.tree_store) + tree_view.set_enable_search(False) # disable pop up search box + tree_view.set_search_column(-1) # really disable search + tree_view.set_reorderable(False) + tree_view.set_headers_visible(False) + tree_view.get_selection().set_mode(Gtk.SelectionMode.NONE) + tree_view.append_column(column) + + for row in self.tree_store: + tree_view.expand_row(row.path, False) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled_window.add(tree_view) + + self.vbox.pack_start(scrolled_window, True) + self.set_size_request(2 * MIN_DIALOG_WIDTH, MIN_DIALOG_HEIGHT) + self.show_all() + + def update_tree_store(self, error_logs): + """set up data model""" + self.tree_store.clear() + self._error_logs = error_logs + for filename, errors in error_logs.items(): + parent = self.tree_store.append(None, [str(filename)]) + try: + with open(filename, 'r') as fp: + code = fp.readlines() + except EnvironmentError: + code = None + for error in errors: + # http://lxml.de/api/lxml.etree._LogEntry-class.html + em = self.tree_store.append( + parent, ["Line {e.line}: {e.message}".format(e=error)]) + if code: + self.tree_store.append(em, ["\n".join( + "{} {}{}".format(line, code[line - 1].replace("\t", " ").strip("\n"), + " " * 20 + "" if line == error.line else "") + for line in range(error.line - 2, error.line + 3) if 0 < line <= len(code) + )]) + + def run(self): + """ + Run the dialog and get its response. + + Returns: + true if the response was accept + """ + response = Gtk.Dialog.run(self) + self.destroy() + return response == Gtk.ResponseType.ACCEPT diff --git a/grc/gui/Platform.py b/grc/gui/Platform.py new file mode 100644 index 0000000..3d0c019 --- /dev/null +++ b/grc/gui/Platform.py @@ -0,0 +1,65 @@ +""" +Copyright 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import sys +import os + +from .Config import Config +from . import canvas +from ..core.platform import Platform as CorePlatform +from collections import ChainMap + + +class Platform(CorePlatform): + + def __init__(self, *args, **kwargs): + CorePlatform.__init__(self, *args, **kwargs) + + # Ensure conf directories + gui_prefs_file = self.config.gui_prefs_file + if not os.path.exists(os.path.dirname(gui_prefs_file)): + os.mkdir(os.path.dirname(gui_prefs_file)) + + self._move_old_pref_file() + + def get_prefs_file(self): + return self.config.gui_prefs_file + + def _move_old_pref_file(self): + gui_prefs_file = self.config.gui_prefs_file + old_gui_prefs_file = os.environ.get( + 'GRC_PREFS_PATH', os.path.expanduser('~/.grc')) + if gui_prefs_file == old_gui_prefs_file: + return # prefs file overridden with env var + if os.path.exists(old_gui_prefs_file) and not os.path.exists(gui_prefs_file): + try: + import shutil + shutil.move(old_gui_prefs_file, gui_prefs_file) + except Exception as e: + print(e, file=sys.stderr) + + ############################################## + # Factories + ############################################## + Config = Config + FlowGraph = canvas.FlowGraph + Connection = canvas.Connection + + def new_block_class(self, **data): + cls = CorePlatform.new_block_class(self, **data) + return canvas.Block.make_cls_with_base(cls) + + block_classes_build_in = {key: canvas.Block.make_cls_with_base(cls) + for key, cls in CorePlatform.block_classes_build_in.items()} + block_classes = ChainMap({}, block_classes_build_in) + + port_classes = {key: canvas.Port.make_cls_with_base(cls) + for key, cls in CorePlatform.port_classes.items()} + param_classes = {key: canvas.Param.make_cls_with_base(cls) + for key, cls in CorePlatform.param_classes.items()} diff --git a/grc/gui/PropsDialog.py b/grc/gui/PropsDialog.py new file mode 100644 index 0000000..8a3e576 --- /dev/null +++ b/grc/gui/PropsDialog.py @@ -0,0 +1,325 @@ +""" +Copyright 2007, 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + +from gi.repository import Gtk, Gdk, GObject, Pango + +from . import Actions, Utils, Constants +from .Dialogs import SimpleTextDisplay + +from urllib.parse import urljoin, urlparse + + +class PropsDialog(Gtk.Dialog): + """ + A dialog to set block parameters, view errors, and view documentation. + """ + + def __init__(self, parent, block): + """ + Properties dialog constructor. + + Args:% + block: a block instance + """ + + Gtk.Dialog.__init__( + self, + title='Properties: ' + block.label, + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT, + Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, + Gtk.STOCK_APPLY, Gtk.ResponseType.APPLY, + ) + self.set_response_sensitive(Gtk.ResponseType.APPLY, False) + self.set_size_request(*Utils.scale( + (Constants.MIN_DIALOG_WIDTH, Constants.MIN_DIALOG_HEIGHT) + )) + + # Careful: 'block' can also be a connection! The naming is because + # property dialogs for connections were added much later. + self._block = block + self._hash = 0 + self._config = parent.config + + vpaned = Gtk.VPaned() + self.vbox.pack_start(vpaned, True, True, 0) + + # Notebook to hold param boxes + notebook = self.notebook = Gtk.Notebook() + notebook.set_show_border(False) + notebook.set_scrollable(True) # scroll arrows for page tabs + notebook.set_tab_pos(Gtk.PositionType.TOP) + vpaned.pack1(notebook, True) + + # Params boxes for block parameters + self._params_boxes = [] + self._build_param_tab_boxes() + + # Docs for the block + self._docs_text_display = doc_view = SimpleTextDisplay() + doc_view.get_buffer().create_tag('b', weight=Pango.Weight.BOLD) + self._docs_box = Gtk.ScrolledWindow() + self._docs_box.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + self._docs_vbox = Gtk.VBox(homogeneous=False, spacing=0) + # TODO: left align + self._docs_box.add(self._docs_vbox) + self._docs_link = Gtk.Label(use_markup=True) + self._docs_vbox.pack_start(self._docs_link, False, False, 0) + self._docs_vbox.pack_end(self._docs_text_display, True, True, 0) + notebook.append_page(self._docs_box, Gtk.Label(label="Documentation")) + + # Generated code for the block + if Actions.TOGGLE_SHOW_CODE_PREVIEW_TAB.get_active(): + self._code_text_display = code_view = SimpleTextDisplay() + code_view.set_wrap_mode(Gtk.WrapMode.NONE) + code_view.get_buffer().create_tag('b', weight=Pango.Weight.BOLD) + code_view.set_monospace(True) + # todo: set font size in non-deprecated way + # code_view.override_font(Pango.FontDescription('monospace %d' % Constants.FONT_SIZE)) + code_box = Gtk.ScrolledWindow() + code_box.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + code_box.add(self._code_text_display) + notebook.append_page(code_box, Gtk.Label(label="Generated Code")) + else: + self._code_text_display = None + + # Error Messages for the block + self._error_messages_text_display = SimpleTextDisplay() + self._error_box = Gtk.ScrolledWindow() + self._error_box.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + self._error_box.add(self._error_messages_text_display) + vpaned.pack2(self._error_box) + vpaned.set_position(int(0.65 * Constants.MIN_DIALOG_HEIGHT)) + + # Connect events + self.connect('key-press-event', self._handle_key_press) + self.connect('show', self.update_gui) + self.connect('response', self._handle_response) + self.show_all() # show all (performs initial gui update) + + def _build_param_tab_boxes(self): + categories = (p.category for p in self._block.params.values()) + + def unique_categories(): + seen = {Constants.DEFAULT_PARAM_TAB} + yield Constants.DEFAULT_PARAM_TAB + for cat in categories: + if cat in seen: + continue + yield cat + seen.add(cat) + + for category in unique_categories(): + label = Gtk.Label() + vbox = Gtk.VBox() + scroll_box = Gtk.ScrolledWindow() + scroll_box.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + scroll_box.add(vbox) + self.notebook.append_page(scroll_box, label) + self._params_boxes.append((category, label, vbox)) + + def _params_changed(self): + """ + Have the params in this dialog changed? + Ex: Added, removed, type change, hide change... + To the props dialog, the hide setting of 'none' and 'part' are identical. + Therefore, the props dialog only cares if the hide setting is/not 'all'. + Make a hash that uniquely represents the params' state. + + Returns: + true if changed + """ + old_hash = self._hash + new_hash = self._hash = hash(tuple( + (hash(param), param.name, param.dtype, param.hide == 'all',) + for param in self._block.params.values() + )) + return new_hash != old_hash + + def _handle_changed(self, *args): + """ + A change occurred within a param: + Rewrite/validate the block and update the gui. + """ + self._block.rewrite() + self._block.validate() + self.update_gui() + + def _activate_apply(self, *args): + self.set_response_sensitive(Gtk.ResponseType.APPLY, True) + + def update_gui(self, widget=None, force=False): + """ + Repopulate the parameters boxes (if changed). + Update all the input parameters. + Update the error messages box. + Hide the box if there are no errors. + Update the documentation block. + Hide the box if there are no docs. + """ + if force or self._params_changed(): + # hide params box before changing + for category, label, vbox in self._params_boxes: + vbox.hide() + # empty the params box + for child in vbox.get_children(): + vbox.remove(child) + # child.destroy() # disabled because it throws errors... + # repopulate the params box + box_all_valid = True + force_show_id = Actions.TOGGLE_SHOW_BLOCK_IDS.get_active() + + for param in self._block.params.values(): + if force_show_id and param.dtype == 'id': + param.hide = 'none' + # todo: why do we even rebuild instead of really hiding params? + if param.category != category or param.hide == 'all': + continue + box_all_valid = box_all_valid and param.is_valid() + + input_widget = param.get_input(self._handle_changed, self._activate_apply, + transient_for=self.get_transient_for()) + input_widget.show_all() + vbox.pack_start(input_widget, input_widget.expand, True, 1) + + label.set_markup('{name}'.format( + color='foreground="red"' if not box_all_valid else '', name=Utils.encode(category) + )) + vbox.show() # show params box with new params + + if self._block.is_valid(): + self._error_box.hide() + else: + self._error_box.show() + messages = '\n\n'.join(self._block.get_error_messages()) + self._error_messages_text_display.set_text(messages) + + self._update_docs_page() + self._update_generated_code_page() + + def _update_docs_page(self): + """Show documentation from XML and try to display best matching docstring""" + buf = self._docs_text_display.get_buffer() + buf.delete(buf.get_start_iter(), buf.get_end_iter()) + pos = buf.get_end_iter() + + in_tree = self._block.category and self._block.category[0] == "Core" + + # Add link to wiki page for this block, at the top, as long as it's not an OOT block + if self._block.is_connection: + self._docs_link.set_markup('Connection') + elif in_tree: + # For in-tree modules, use prefix as configured + prefix = self._config.wiki_block_docs_url_prefix + else: + prefix = "" + + suffix = None + if self._block.doc_url: + suffix = self._block.doc_url + elif in_tree: + suffix = self._block.label.replace(" ", "_") + + if suffix: + url = urljoin(prefix, suffix) + icon = "🗗 " + if urlparse(url).scheme not in ("", "file"): + icon += "🌐" + self._docs_link.set_markup(f'{icon} Visit Documentation Page') + elif self._block.is_connection: + self._docs_link.set_markup('Connection Properties') + else: + self._docs_link.set_markup('Out of Tree Block, No documentation URL specified') + + docstrings = self._block.documentation.copy() + if not docstrings: + return + + # show documentation string from block yaml + from_yaml = docstrings.pop('', '') + for line in from_yaml.splitlines(): + if line.lstrip() == line and line.endswith(':'): + buf.insert_with_tags_by_name(pos, line + '\n', 'b') + else: + buf.insert(pos, line + '\n') + if from_yaml: + buf.insert(pos, '\n') + + # if given the current parameters an exact match can be made + block_templates = getattr(self._block, 'templates', None) + if block_templates: + block_constructor = block_templates.render( + 'make').rsplit('.', 2)[-1] + block_class = block_constructor.partition('(')[0].strip() + if block_class in docstrings: + docstrings = {block_class: docstrings[block_class]} + + # show docstring(s) extracted from python sources + for cls_name, docstring in docstrings.items(): + buf.insert_with_tags_by_name(pos, cls_name + '\n', 'b') + buf.insert(pos, docstring + '\n\n') + pos.backward_chars(2) + buf.delete(pos, buf.get_end_iter()) + + def _update_generated_code_page(self): + if not self._code_text_display: + return # user disabled code preview + + buf = self._code_text_display.get_buffer() + block = self._block + key = block.key + + if key == 'epy_block': + src = block.params['_source_code'].get_value() + elif key == 'epy_module': + src = block.params['source_code'].get_value() + else: + src = '' + + def insert(header, text): + if not text: + return + buf.insert_with_tags_by_name(buf.get_end_iter(), header, 'b') + buf.insert(buf.get_end_iter(), text) + + buf.delete(buf.get_start_iter(), buf.get_end_iter()) + insert('# Imports\n', block.templates.render('imports').strip('\n')) + if block.is_variable: + insert('\n\n# Variables\n', block.templates.render('var_make')) + insert('\n\n# Blocks\n', block.templates.render('make')) + if src: + insert('\n\n# External Code ({}.py)\n'.format(block.name), src) + + def _handle_key_press(self, widget, event): + close_dialog = ( + event.keyval == Gdk.KEY_Return and + event.get_state() & Gdk.ModifierType.CONTROL_MASK == 0 and + not isinstance(widget.get_focus(), Gtk.TextView) + ) + if close_dialog: + self.response(Gtk.ResponseType.ACCEPT) + return True # handled here + + return False # forward the keypress + + def _handle_response(self, widget, response): + if response in (Gtk.ResponseType.APPLY, Gtk.ResponseType.ACCEPT): + for tab, label, vbox in self._params_boxes: + for child in vbox.get_children(): + child.apply_pending_changes() + self.set_response_sensitive(Gtk.ResponseType.APPLY, False) + return True + return False diff --git a/grc/gui/StateCache.py b/grc/gui/StateCache.py new file mode 100644 index 0000000..fff261a --- /dev/null +++ b/grc/gui/StateCache.py @@ -0,0 +1,96 @@ +""" +Copyright 2007 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + +from . import Actions +from .Constants import STATE_CACHE_SIZE + + +class StateCache(object): + """ + The state cache is an interface to a list to record data/states and to revert to previous states. + States are recorded into the list in a circular fassion by using an index for the current state, + and counters for the range where states are stored. + """ + + def __init__(self, initial_state): + """ + StateCache constructor. + + Args: + initial_state: the initial state (nested data) + """ + self.states = [None] * STATE_CACHE_SIZE # fill states + self.current_state_index = 0 + self.num_prev_states = 0 + self.num_next_states = 0 + self.states[0] = initial_state + self.update_actions() + + def save_new_state(self, state): + """ + Save a new state. + Place the new state at the next index and add one to the number of previous states. + + Args: + state: the new state + """ + self.current_state_index = ( + self.current_state_index + 1) % STATE_CACHE_SIZE + self.states[self.current_state_index] = state + self.num_prev_states = self.num_prev_states + 1 + if self.num_prev_states == STATE_CACHE_SIZE: + self.num_prev_states = STATE_CACHE_SIZE - 1 + self.num_next_states = 0 + self.update_actions() + + def get_current_state(self): + """ + Get the state at the current index. + + Returns: + the current state (nested data) + """ + self.update_actions() + return self.states[self.current_state_index] + + def get_prev_state(self): + """ + Get the previous state and decrement the current index. + + Returns: + the previous state or None + """ + if self.num_prev_states > 0: + self.current_state_index = ( + self.current_state_index + STATE_CACHE_SIZE - 1) % STATE_CACHE_SIZE + self.num_next_states = self.num_next_states + 1 + self.num_prev_states = self.num_prev_states - 1 + return self.get_current_state() + return None + + def get_next_state(self): + """ + Get the nest state and increment the current index. + + Returns: + the next state or None + """ + if self.num_next_states > 0: + self.current_state_index = ( + self.current_state_index + 1) % STATE_CACHE_SIZE + self.num_next_states = self.num_next_states - 1 + self.num_prev_states = self.num_prev_states + 1 + return self.get_current_state() + return None + + def update_actions(self): + """ + Update the undo and redo actions based on the number of next and prev states. + """ + Actions.FLOW_GRAPH_REDO.set_enabled(self.num_next_states != 0) + Actions.FLOW_GRAPH_UNDO.set_enabled(self.num_prev_states != 0) diff --git a/grc/gui/Utils.py b/grc/gui/Utils.py new file mode 100644 index 0000000..2629afa --- /dev/null +++ b/grc/gui/Utils.py @@ -0,0 +1,194 @@ +""" +Copyright 2008-2011,2015 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from sys import platform +import os +import numbers + +from gi.repository import GLib +import cairo + +from .canvas.colors import FLOWGRAPH_BACKGROUND_COLOR +from . import Constants + + +def get_rotated_coordinate(coor, rotation): + """ + Rotate the coordinate by the given rotation. + + Args: + coor: the coordinate x, y tuple + rotation: the angle in degrees + + Returns: + the rotated coordinates + """ + # handles negative angles + rotation = (rotation + 360) % 360 + if rotation not in Constants.POSSIBLE_ROTATIONS: + raise ValueError('unusable rotation angle "%s"' % str(rotation)) + # determine the number of degrees to rotate + cos_r, sin_r = { + 0: (1, 0), 90: (0, 1), 180: (-1, 0), 270: (0, -1), + }[rotation] + x, y = coor + return x * cos_r + y * sin_r, -x * sin_r + y * cos_r + + +def get_angle_from_coordinates(p1, p2): + """ + Given two points, calculate the vector direction from point1 to point2, directions are multiples of 90 degrees. + + Args: + (x1,y1): the coordinate of point 1 + (x2,y2): the coordinate of point 2 + + Returns: + the direction in degrees + """ + (x1, y1) = p1 + (x2, y2) = p2 + if y1 == y2: # 0 or 180 + return 0 if x2 > x1 else 180 + else: # 90 or 270 + return 270 if y2 > y1 else 90 + + +def align_to_grid(coor, mode=round): + def align(value): + return int(mode(value / (1.0 * Constants.CANVAS_GRID_SIZE)) * Constants.CANVAS_GRID_SIZE) + try: + return [align(c) for c in coor] + except TypeError: + x = coor + return align(coor) + + +def num_to_str(num): + """ Display logic for numbers """ + def eng_notation(value, fmt='g'): + """Convert a number to a string in engineering notation. E.g., 5e-9 -> 5n""" + template = '{:' + fmt + '}{}' + magnitude = abs(value) + for exp, symbol in zip(range(9, -15 - 1, -3), 'GMk munpf'): + factor = 10 ** exp + if magnitude >= factor: + return template.format(value / factor, symbol.strip()) + return template.format(value, '') + + if isinstance(num, numbers.Complex): + num = complex(num) # Cast to python complex + if num == 0: + return '0' + output = eng_notation(num.real) if num.real else '' + output += eng_notation(num.imag, '+g' if output else 'g') + \ + 'j' if num.imag else '' + return output + else: + return str(num) + + +def encode(value): + return GLib.markup_escape_text(value) + + +def make_screenshot(flow_graph, file_path, transparent_bg=False): + if not file_path: + return + + x_min, y_min, x_max, y_max = flow_graph.get_extents() + padding = Constants.CANVAS_GRID_SIZE + width = x_max - x_min + 2 * padding + height = y_max - y_min + 2 * padding + + if file_path.endswith('.png'): + # ImageSurface is pixel-based, so dimensions need to be integers + # We don't round up here, because our padding should allow for up + # to half a pixel size in loss of image area without optically bad + # effects + psurf = cairo.ImageSurface(cairo.FORMAT_ARGB32, + round(width), + round(height)) + elif file_path.endswith('.pdf'): + psurf = cairo.PDFSurface(file_path, width, height) + elif file_path.endswith('.svg'): + psurf = cairo.SVGSurface(file_path, width, height) + else: + raise ValueError('Unknown file format') + + cr = cairo.Context(psurf) + + if not transparent_bg: + cr.set_source_rgba(*FLOWGRAPH_BACKGROUND_COLOR) + cr.rectangle(0, 0, width, height) + cr.fill() + + cr.translate(padding - x_min, padding - y_min) + + flow_graph.create_labels(cr) + flow_graph.create_shapes() + flow_graph.draw(cr) + + if file_path.endswith('.png'): + psurf.write_to_png(file_path) + if file_path.endswith('.pdf') or file_path.endswith('.svg'): + cr.show_page() + psurf.finish() + + +def scale(coor, reverse=False): + factor = Constants.DPI_SCALING if not reverse else 1 / Constants.DPI_SCALING + return tuple(int(x * factor) for x in coor) + + +def scale_scalar(coor, reverse=False): + factor = Constants.DPI_SCALING if not reverse else 1 / Constants.DPI_SCALING + return int(coor * factor) + + +def get_modifier_key(angle_brackets=False): + """ + Get the modifier key based on platform. + + Args: + angle_brackets: if return the modifier key with <> or not + + Returns: + return the string with the modifier key + """ + if platform == "darwin": + if angle_brackets: + return "" + else: + return "Meta" + else: + if angle_brackets: + return "" + else: + return "Ctrl" + + +_nproc = None + + +def get_cmake_nproc(): + """ Get number of cmake processes for C++ flowgraphs """ + global _nproc # Cached result + if _nproc: + return _nproc + try: + # See https://docs.python.org/3.8/library/os.html#os.cpu_count + _nproc = len(os.sched_getaffinity(0)) + except: + _nproc = os.cpu_count() + if not _nproc: + _nproc = 1 + + _nproc = max(_nproc // 2 - 1, 1) + return _nproc diff --git a/grc/gui/VariableEditor.py b/grc/gui/VariableEditor.py new file mode 100644 index 0000000..175b391 --- /dev/null +++ b/grc/gui/VariableEditor.py @@ -0,0 +1,358 @@ +""" +Copyright 2015, 2016 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from gi.repository import Gtk, Gdk, GObject + +from . import Actions, Constants, Utils + +BLOCK_INDEX = 0 +ID_INDEX = 1 + + +class VariableEditorContextMenu(Gtk.Menu): + """ A simple context menu for our variable editor """ + + def __init__(self, var_edit): + Gtk.Menu.__init__(self) + + self.imports = Gtk.MenuItem(label="Add _Import") + self.imports.connect( + 'activate', var_edit.handle_action, var_edit.ADD_IMPORT) + self.add(self.imports) + + self.variables = Gtk.MenuItem(label="Add _Variable") + self.variables.connect( + 'activate', var_edit.handle_action, var_edit.ADD_VARIABLE) + self.add(self.variables) + self.add(Gtk.SeparatorMenuItem()) + + self.enable = Gtk.MenuItem(label="_Enable") + self.enable.connect( + 'activate', var_edit.handle_action, var_edit.ENABLE_BLOCK) + self.disable = Gtk.MenuItem(label="_Disable") + self.disable.connect( + 'activate', var_edit.handle_action, var_edit.DISABLE_BLOCK) + self.add(self.enable) + self.add(self.disable) + self.add(Gtk.SeparatorMenuItem()) + + self.delete = Gtk.MenuItem(label="_Delete") + self.delete.connect( + 'activate', var_edit.handle_action, var_edit.DELETE_BLOCK) + self.add(self.delete) + self.add(Gtk.SeparatorMenuItem()) + + self.properties = Gtk.MenuItem(label="_Properties...") + self.properties.connect( + 'activate', var_edit.handle_action, var_edit.OPEN_PROPERTIES) + self.add(self.properties) + self.show_all() + + def update_sensitive(self, selected, enabled=False): + self.delete.set_sensitive(selected) + self.properties.set_sensitive(selected) + self.enable.set_sensitive(selected and not enabled) + self.disable.set_sensitive(selected and enabled) + + +class VariableEditor(Gtk.VBox): + + # Actions that are handled by the editor + ADD_IMPORT = 0 + ADD_VARIABLE = 1 + OPEN_PROPERTIES = 2 + DELETE_BLOCK = 3 + DELETE_CONFIRM = 4 + ENABLE_BLOCK = 5 + DISABLE_BLOCK = 6 + + __gsignals__ = { + 'create_new_block': (GObject.SignalFlags.RUN_FIRST, None, (str,)), + 'remove_block': (GObject.SignalFlags.RUN_FIRST, None, (str,)) + } + + def __init__(self): + Gtk.VBox.__init__(self) + config = Gtk.Application.get_default().config + + self._block = None + self._mouse_button_pressed = False + self._imports = [] + self._variables = [] + + # Only use the model to store the block reference and name. + # Generate everything else dynamically + self.treestore = Gtk.TreeStore(GObject.TYPE_PYOBJECT, # Block reference + GObject.TYPE_STRING) # Category and block name + self.treeview = Gtk.TreeView(model=self.treestore) + self.treeview.set_enable_search(False) + self.treeview.set_search_column(-1) + # self.treeview.set_enable_search(True) + # self.treeview.set_search_column(ID_INDEX) + self.treeview.get_selection().set_mode(Gtk.SelectionMode.SINGLE) + self.treeview.set_headers_visible(True) + self.treeview.connect('button-press-event', + self._handle_mouse_button_press) + self.treeview.connect('button-release-event', + self._handle_mouse_button_release) + self.treeview.connect('motion-notify-event', + self._handle_motion_notify) + self.treeview.connect('key-press-event', self._handle_key_button_press) + + # Block Name or Category + self.id_cell = Gtk.CellRendererText() + self.id_cell.connect('edited', self._handle_name_edited_cb) + id_column = Gtk.TreeViewColumn("ID", self.id_cell, text=ID_INDEX) + id_column.set_name("id") + id_column.set_resizable(True) + id_column.set_max_width(Utils.scale_scalar(300)) + id_column.set_min_width(Utils.scale_scalar(80)) + id_column.set_fixed_width(Utils.scale_scalar(120)) + id_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + id_column.set_cell_data_func(self.id_cell, self.set_properties) + self.id_column = id_column + self.treeview.append_column(id_column) + self.treestore.set_sort_column_id(ID_INDEX, Gtk.SortType.ASCENDING) + # For forcing resize + self._col_width = 0 + + # Block Value + self.value_cell = Gtk.CellRendererText() + self.value_cell.connect('edited', self._handle_value_edited_cb) + value_column = Gtk.TreeViewColumn("Value", self.value_cell) + value_column.set_name("value") + value_column.set_resizable(False) + value_column.set_expand(True) + value_column.set_min_width(Utils.scale_scalar(100)) + value_column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) + value_column.set_cell_data_func(self.value_cell, self.set_value) + self.value_column = value_column + self.treeview.append_column(value_column) + + # Block Actions (Add, Remove) + self.action_cell = Gtk.CellRendererPixbuf() + value_column.pack_start(self.action_cell, False) + value_column.set_cell_data_func(self.action_cell, self.set_icon) + + # Make the scrolled window to hold the tree view + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy( + Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled_window.add(self.treeview) + scrolled_window.set_size_request( + Constants.DEFAULT_BLOCKS_WINDOW_WIDTH, -1) + self.pack_start(scrolled_window, True, True, 0) + + # Context menus + self._context_menu = VariableEditorContextMenu(self) + self._confirm_delete = config.variable_editor_confirm_delete() + + # Sets cell contents + def set_icon(self, col, cell, model, iter, data): + block = model.get_value(iter, BLOCK_INDEX) + cell.set_property('icon-name', 'window-close' if block else 'list-add') + + def set_value(self, col, cell, model, iter, data): + sp = cell.set_property + block = model.get_value(iter, BLOCK_INDEX) + + # Set the default properties for this column first. + # Some set in set_properties() may be overridden (editable for advanced variable blocks) + self.set_properties(col, cell, model, iter, data) + + # Set defaults + value = None + self.set_tooltip_text(None) + + # Block specific values + if block: + if block.key == 'import': + value = block.params['imports'].get_value() + elif block.key != "variable": + value = "" + sp('editable', False) + sp('foreground', '#0D47A1') + else: + value = block.params['value'].get_value() + + # Check if there are errors in the blocks. + # Show the block error as a tooltip + error_message = block.get_error_messages() + if len(error_message) > 0: + # Set the error message to the last error in the list. + # This should be the first message generated + self.set_tooltip_text(error_message[-1]) + else: + # Evaluate and show the value (if it is a variable) + if block.is_variable: + value = str(block.evaluate(block.value)) + + # Always set the text value. + sp('text', value) + + def set_properties(self, col, cell, model, iter, data): + sp = cell.set_property + block = model.get_value(iter, BLOCK_INDEX) + # Set defaults + sp('sensitive', True) + sp('editable', False) + sp('foreground', None) + + # Block specific changes + if block: + if not block.enabled: + # Disabled block. But, this should still be editable + sp('editable', True) + sp('foreground', 'gray') + else: + sp('editable', True) + if block.get_error_messages(): + sp('foreground', 'red') + + def update_gui(self, blocks): + self._imports = [block for block in blocks if block.is_import] + self._variables = [block for block in blocks if block.is_variable] + self._rebuild() + self.treeview.expand_all() + + def _rebuild(self, *args): + self.treestore.clear() + imports = self.treestore.append(None, [None, 'Imports']) + variables = self.treestore.append(None, [None, 'Variables']) + for block in self._imports: + self.treestore.append( + imports, [block, block.params['id'].get_value()]) + for block in sorted(self._variables, key=lambda v: v.name): + self.treestore.append( + variables, [block, block.params['id'].get_value()]) + + def _handle_name_edited_cb(self, cell, path, new_text): + block = self.treestore[path][BLOCK_INDEX] + block.params['id'].set_value(new_text) + Actions.VARIABLE_EDITOR_UPDATE() + + def _handle_value_edited_cb(self, cell, path, new_text): + block = self.treestore[path][BLOCK_INDEX] + if block.is_import: + block.params['imports'].set_value(new_text) + else: + block.params['value'].set_value(new_text) + Actions.VARIABLE_EDITOR_UPDATE() + + def handle_action(self, item, key, event=None): + """ + Single handler for the different actions that can be triggered by the context menu, + key presses or mouse clicks. Also triggers an update of the flow graph and editor. + """ + if key == self.ADD_IMPORT: + self.emit('create_new_block', 'import') + elif key == self.ADD_VARIABLE: + self.emit('create_new_block', 'variable') + elif key == self.OPEN_PROPERTIES: + # TODO: This probably isn't working because the action doesn't expect a parameter + # Actions.BLOCK_PARAM_MODIFY() + pass + elif key == self.DELETE_BLOCK: + self.emit('remove_block', self._block.name) + elif key == self.DELETE_CONFIRM: + if self._confirm_delete: + # Create a context menu to confirm the delete operation + confirmation_menu = Gtk.Menu() + block_id = self._block.params['id'].get_value().replace( + "_", "__") + confirm = Gtk.MenuItem(label="Delete {}".format(block_id)) + confirm.connect('activate', self.handle_action, + self.DELETE_BLOCK) + confirmation_menu.add(confirm) + confirmation_menu.show_all() + confirmation_menu.popup( + None, None, None, None, event.button, event.time) + else: + self.handle_action(None, self.DELETE_BLOCK, None) + elif key == self.ENABLE_BLOCK: + self._block.state = 'enabled' + elif key == self.DISABLE_BLOCK: + self._block.state = 'disabled' + Actions.VARIABLE_EDITOR_UPDATE() + + def _handle_mouse_button_press(self, widget, event): + """ + Handles mouse button for several different events: + - Double Click to open properties for advanced blocks + - Click to add/remove blocks + """ + # Save the column width to see if it changes on button_release + self._mouse_button_pressed = True + self._col_width = self.id_column.get_width() + + path = widget.get_path_at_pos(int(event.x), int(event.y)) + if path: + # If there is a valid path, then get the row, column and block selected. + row = self.treestore[path[0]] + col = path[1] + self._block = row[BLOCK_INDEX] + + if event.button == 1 and col.get_name() == "value": + # Make sure this has a block (not the import/variable rows) + if self._block and event.type == Gdk.EventType._2BUTTON_PRESS: + # Open the advanced dialog if it is a gui variable + if self._block.key not in ("variable", "import"): + self.handle_action( + None, self.OPEN_PROPERTIES, event=event) + return True + if event.type == Gdk.EventType.BUTTON_PRESS: + # User is adding/removing blocks + # Make sure this is the action cell (Add/Remove Icons) + if path[2] > col.cell_get_position(self.action_cell)[0]: + if row[1] == "Imports": + # Add a new import block. + self.handle_action( + None, self.ADD_IMPORT, event=event) + elif row[1] == "Variables": + # Add a new variable block + self.handle_action( + None, self.ADD_VARIABLE, event=event) + else: + self.handle_action( + None, self.DELETE_CONFIRM, event=event) + return True + elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: + if self._block: + self._context_menu.update_sensitive( + True, enabled=self._block.enabled) + else: + self._context_menu.update_sensitive(False) + self._context_menu.popup( + None, None, None, None, event.button, event.time) + + # Null handler. Stops the treeview from handling double click events. + if event.type == Gdk.EventType._2BUTTON_PRESS: + return True + return False + + def _handle_mouse_button_release(self, widget, event): + self._mouse_button_pressed = False + return False + + def _handle_motion_notify(self, widget, event): + # Check to see if the column size has changed + if self._mouse_button_pressed and self.id_column.get_width() != self._col_width: + self.value_column.queue_resize() + return False + + def _handle_key_button_press(self, widget, event): + model, path = self.treeview.get_selection().get_selected_rows() + if path and self._block: + if self._block.enabled and event.string == "d": + self.handle_action(None, self.DISABLE_BLOCK, None) + return True + elif not self._block.enabled and event.string == "e": + self.handle_action(None, self.ENABLE_BLOCK, None) + return True + return False diff --git a/grc/gui/__init__.py b/grc/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grc/gui/canvas/__init__.py b/grc/gui/canvas/__init__.py new file mode 100644 index 0000000..60d46fa --- /dev/null +++ b/grc/gui/canvas/__init__.py @@ -0,0 +1,11 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +from .block import Block +from .connection import Connection +from .flowgraph import FlowGraph +from .param import Param +from .port import Port diff --git a/grc/gui/canvas/block.py b/grc/gui/canvas/block.py new file mode 100644 index 0000000..c80e2b8 --- /dev/null +++ b/grc/gui/canvas/block.py @@ -0,0 +1,425 @@ +""" +Copyright 2007, 2008, 2009 Free Software Foundation, Inc. +Copyright 2020-2021 GNU Radio Contributors +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import math + +from gi.repository import Gtk, Pango, PangoCairo + +from . import colors +from .drawable import Drawable +from .. import Actions, Utils, Constants +from ...core import utils +from ...core.blocks import Block as CoreBlock + + +class Block(CoreBlock, Drawable): + """The graphical signal block.""" + + def __init__(self, parent, **n): + """ + Block constructor. + Add graphics related params to the block. + """ + super(self.__class__, self).__init__(parent, **n) + + self.states.update(coordinate=(0, 0), rotation=0) + self.width = self.height = 0 + Drawable.__init__(self) # needs the states and initial sizes + + self._surface_layouts = [ + None, # title + None, # params + ] + self._surface_layouts_offsets = 0, 0 + self._comment_layout = None + + self._area = [] + self._border_color = self._bg_color = colors.BLOCK_ENABLED_COLOR + self._font_color = list(colors.FONT_COLOR) + + @property + def coordinate(self): + """ + Get the coordinate from the position param. + + Returns: + the coordinate tuple (x, y) or (0, 0) if failure + """ + return Utils.scale(self.states['coordinate']) + + @coordinate.setter + def coordinate(self, coor): + """ + Set the coordinate into the position param. + + Args: + coor: the coordinate tuple (x, y) + """ + coor = Utils.scale(coor, reverse=True) + if Actions.TOGGLE_SNAP_TO_GRID.get_active(): + offset_x, offset_y = ( + 0, self.height / 2) if self.is_horizontal() else (self.height / 2, 0) + coor = ( + Utils.align_to_grid(coor[0] + offset_x) - offset_x, + Utils.align_to_grid(coor[1] + offset_y) - offset_y + ) + self.states['coordinate'] = coor + + @property + def rotation(self): + """ + Get the rotation from the position param. + + Returns: + the rotation in degrees or 0 if failure + """ + return self.states['rotation'] + + @rotation.setter + def rotation(self, rot): + """ + Set the rotation into the position param. + + Args: + rot: the rotation in degrees + """ + self.states['rotation'] = rot + + def _update_colors(self): + def get_bg(): + """ + Get the background color for this block + + Explicit is better than a chain of if/else expressions, + so this was extracted into a nested function. + """ + if self.is_dummy_block: + return colors.MISSING_BLOCK_BACKGROUND_COLOR + if self.state == 'bypassed': + return colors.BLOCK_BYPASSED_COLOR + if self.state == 'enabled': + if self.deprecated: + return colors.BLOCK_DEPRECATED_BACKGROUND_COLOR + return colors.BLOCK_ENABLED_COLOR + return colors.BLOCK_DISABLED_COLOR + + def get_border(): + """ + Get the border color for this block + """ + if self.is_dummy_block: + return colors.MISSING_BLOCK_BORDER_COLOR + if self.deprecated: + return colors.BLOCK_DEPRECATED_BORDER_COLOR + if self.state == 'enabled': + return colors.BORDER_COLOR + return colors.BORDER_COLOR_DISABLED + + self._bg_color = get_bg() + self._font_color[-1] = 1.0 if self.state == 'enabled' else 0.4 + self._border_color = get_border() + + def create_shapes(self): + """Update the block, parameters, and ports when a change occurs.""" + if self.is_horizontal(): + self._area = (0, 0, self.width, self.height) + elif self.is_vertical(): + self._area = (0, 0, self.height, self.width) + self.bounds_from_area(self._area) + + bussified = self.current_bus_structure['source'], self.current_bus_structure['sink'] + for ports, has_busses in zip((self.active_sources, self.active_sinks), bussified): + if not ports: + continue + port_separation = Constants.PORT_SEPARATION if not has_busses else ports[ + 0].height + Constants.PORT_SPACING + offset = (self.height - (len(ports) - 1) * + port_separation - ports[0].height) / 2 + for port in ports: + port.create_shapes() + port.coordinate = { + 0: (+self.width, offset), + 90: (offset, -port.width), + 180: (-port.width, offset), + 270: (offset, +self.width), + }[port.connector_direction] + + offset += Constants.PORT_SEPARATION if not has_busses else port.height + \ + Constants.PORT_SPACING + + def create_labels(self, cr=None): + """Create the labels for the signal block.""" + + # (Re-)creating layouts here, because layout.context_changed() doesn't seems to work (after zoom) + title_layout, params_layout = self._surface_layouts = [ + Gtk.DrawingArea().create_pango_layout(''), # title + Gtk.DrawingArea().create_pango_layout(''), # params + ] + + if cr: # to fix up extents after zooming + PangoCairo.update_layout(cr, title_layout) + PangoCairo.update_layout(cr, params_layout) + + title_layout.set_markup( + '{label}'.format( + foreground='foreground="red"' if not self.is_valid() else '', font=Constants.BLOCK_FONT, + label=Utils.encode(self.label) + ) + ) + title_width, title_height = title_layout.get_size() + + force_show_id = Actions.TOGGLE_SHOW_BLOCK_IDS.get_active() + + # update the params layout + if not self.is_dummy_block: + markups = [param.format_block_surface_markup() + for param in self.params.values() if (param.hide not in ('all', 'part') or (param.dtype == 'id' and force_show_id))] + else: + markups = ['key: {key}'.format( + font=Constants.PARAM_FONT, key=self.key)] + + params_layout.set_spacing(Constants.LABEL_SEPARATION * Pango.SCALE) + params_layout.set_markup('\n'.join(markups)) + params_width, params_height = params_layout.get_size() if markups else (0, 0) + + label_width = max(title_width, params_width) / Pango.SCALE + label_height = title_height / Pango.SCALE + if markups: + label_height += Constants.LABEL_SEPARATION + params_height / Pango.SCALE + + # calculate width and height needed + width = label_width + 2 * Constants.BLOCK_LABEL_PADDING + height = label_height + 2 * Constants.BLOCK_LABEL_PADDING + + self._update_colors() + self.create_port_labels() + + def get_min_height_for_ports(ports): + min_height = 2 * Constants.PORT_BORDER_SEPARATION + \ + len(ports) * Constants.PORT_SEPARATION + # If any of the ports are bus ports - make the min height larger + if any([p.dtype == 'bus' for p in ports]): + min_height = 2 * Constants.PORT_BORDER_SEPARATION + sum( + port.height + Constants.PORT_SPACING for port in ports if port.dtype == 'bus' + ) - Constants.PORT_SPACING + + else: + if ports: + min_height -= ports[-1].height + return min_height + + height = max(height, + get_min_height_for_ports(self.active_sinks), + get_min_height_for_ports(self.active_sources)) + + self.width, self.height = width, height = Utils.align_to_grid( + (width, height)) + + self._surface_layouts_offsets = [ + (0, (height - label_height) / 2.0), + (0, (height - label_height) / 2.0 + + Constants.LABEL_SEPARATION + title_height / Pango.SCALE) + ] + + title_layout.set_width(width * Pango.SCALE) + title_layout.set_alignment(Pango.Alignment.CENTER) + params_layout.set_indent((width - label_width) / 2.0 * Pango.SCALE) + + self.create_comment_layout() + + def create_port_labels(self): + for ports in (self.active_sinks, self.active_sources): + max_width = 0 + for port in ports: + port.create_labels() + max_width = max(max_width, port.width_with_label) + for port in ports: + port.width = max_width + + def create_comment_layout(self): + markups = [] + + # Show the flow graph complexity on the top block if enabled + if Actions.TOGGLE_SHOW_FLOWGRAPH_COMPLEXITY.get_active() and self.key == "options": + complexity = utils.flow_graph_complexity.calculate(self.parent) + markups.append( + '' + 'Complexity: {num}bal'.format( + num=Utils.num_to_str(complexity), font=Constants.BLOCK_FONT) + ) + comment = self.comment # Returns None if there are no comments + if comment: + if markups: + markups.append('') + + markups.append('{comment}'.format( + foreground='#444' if self.enabled else '#888', font=Constants.BLOCK_FONT, comment=Utils.encode(comment) + )) + if markups: + layout = self._comment_layout = Gtk.DrawingArea().create_pango_layout('') + layout.set_markup(''.join(markups)) + else: + self._comment_layout = None + + def draw(self, cr): + """ + Draw the signal block with label and inputs/outputs. + """ + border_color = colors.HIGHLIGHT_COLOR if self.highlighted else self._border_color + cr.translate(*self.coordinate) + + for port in self.active_ports(): # ports first + cr.save() + port.draw(cr) + cr.restore() + + cr.rectangle(*self._area) + cr.set_source_rgba(*self._bg_color) + cr.fill_preserve() + cr.set_source_rgba(*border_color) + cr.stroke() + + # title and params label + if self.is_vertical(): + cr.rotate(-math.pi / 2) + cr.translate(-self.width, 0) + cr.set_source_rgba(*self._font_color) + for layout, offset in zip(self._surface_layouts, self._surface_layouts_offsets): + cr.save() + cr.translate(*offset) + PangoCairo.update_layout(cr, layout) + PangoCairo.show_layout(cr, layout) + cr.restore() + + def what_is_selected(self, coor, coor_m=None): + """ + Get the element that is selected. + + Args: + coor: the (x,y) tuple + coor_m: the (x_m, y_m) tuple + + Returns: + this block, a port, or None + """ + for port in self.active_ports(): + port_selected = port.what_is_selected( + coor=[a - b for a, b in zip(coor, self.coordinate)], + coor_m=[ + a - b for a, b in zip(coor, self.coordinate)] if coor_m is not None else None + ) + if port_selected: + return port_selected + return Drawable.what_is_selected(self, coor, coor_m) + + def draw_comment(self, cr): + if not self._comment_layout: + return + x, y = self.coordinate + + if self.is_horizontal(): + y += self.height + Constants.BLOCK_LABEL_PADDING + else: + x += self.height + Constants.BLOCK_LABEL_PADDING + + cr.save() + cr.translate(x, y) + PangoCairo.update_layout(cr, self._comment_layout) + PangoCairo.show_layout(cr, self._comment_layout) + cr.restore() + + def get_extents(self): + extent = Drawable.get_extents(self) + x, y = self.coordinate + for port in self.active_ports(): + extent = (min_or_max(xy, offset + p_xy) for offset, min_or_max, xy, p_xy in zip( + (x, y, x, y), (min, min, max, max), extent, port.get_extents() + )) + return tuple(extent) + + def get_extents_comment(self): + x, y = self.coordinate + if not self._comment_layout: + return x, y, x, y + if self.is_horizontal(): + y += self.height + Constants.BLOCK_LABEL_PADDING + else: + x += self.height + Constants.BLOCK_LABEL_PADDING + w, h = self._comment_layout.get_pixel_size() + return x, y, x + w, y + h + + ############################################## + # Controller Modify + ############################################## + def type_controller_modify(self, direction): + """ + Change the type controller. + + Args: + direction: +1 or -1 + + Returns: + true for change + """ + type_templates = ' '.join(p.dtype for p in self.params.values()) + ' ' + type_templates += ' '.join(p.get_raw('dtype') + for p in (self.sinks + self.sources)) + type_param = None + for key, param in self.params.items(): + if not param.is_enum(): + continue + # Priority to the type controller + if param.key in type_templates: + type_param = param + break + # Use param if type param is unset + if not type_param: + type_param = param + if not type_param: + return False + + # Try to increment the enum by direction + try: + values = list(type_param.options) + old_index = values.index(type_param.get_value()) + new_index = (old_index + direction + len(values)) % len(values) + type_param.set_value(values[new_index]) + return True + except IndexError: + return False + + def port_controller_modify(self, direction): + """ + Change the port controller. + + Args: + direction: +1 or -1 + + Returns: + true for change + """ + changed = False + # Concat the nports string from the private nports settings of all ports + nports_str = ' '.join(str(port.get_raw('multiplicity')) + for port in self.ports()) + # Modify all params whose keys appear in the nports string + for key, param in self.params.items(): + if param.is_enum() or param.key not in nports_str: + continue + # Try to increment the port controller by direction + try: + value = param.get_evaluated() + direction + if value > 0: + param.set_value(value) + changed = True + except ValueError: + # Should we be logging something here + pass + return changed diff --git a/grc/gui/canvas/colors.py b/grc/gui/canvas/colors.py new file mode 100644 index 0000000..89f5025 --- /dev/null +++ b/grc/gui/canvas/colors.py @@ -0,0 +1,114 @@ +""" +Copyright 2008,2013 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from gi.repository import Gtk, Gdk, cairo +# import pycairo + +from .. import Constants + + +def get_color(color_code): + color = Gdk.RGBA() + color.parse(color_code) + return color.red, color.green, color.blue, color.alpha + # chars_per_color = 2 if len(color_code) > 4 else 1 + # offsets = range(1, 3 * chars_per_color + 1, chars_per_color) + # return tuple(int(color_code[o:o + 2], 16) / 255.0 for o in offsets) + +################################################################################# +# fg colors +################################################################################# + + +HIGHLIGHT_COLOR = get_color('#00FFFF') +BORDER_COLOR = get_color('#616161') +BORDER_COLOR_DISABLED = get_color('#888888') +FONT_COLOR = get_color('#000000') + +# Missing blocks stuff +MISSING_BLOCK_BACKGROUND_COLOR = get_color('#FFF2F2') +MISSING_BLOCK_BORDER_COLOR = get_color('#FF0000') + +# Deprecated blocks +# a light warm yellow +BLOCK_DEPRECATED_BACKGROUND_COLOR = get_color('#FED86B') +# orange +BLOCK_DEPRECATED_BORDER_COLOR = get_color('#FF540B') + +# Flow graph color constants +FLOWGRAPH_BACKGROUND_COLOR = get_color('#FFFFFF') +COMMENT_BACKGROUND_COLOR = get_color('#F3F3F3') +FLOWGRAPH_EDGE_COLOR = COMMENT_BACKGROUND_COLOR + +# Block color constants +BLOCK_ENABLED_COLOR = get_color('#F1ECFF') +BLOCK_DISABLED_COLOR = get_color('#CCCCCC') +BLOCK_BYPASSED_COLOR = get_color('#F4FF81') + +# Connection color constants +CONNECTION_ENABLED_COLOR = get_color('#000000') +CONNECTION_DISABLED_COLOR = get_color('#BBBBBB') +CONNECTION_ERROR_COLOR = get_color('#FF0000') + +DEFAULT_DOMAIN_COLOR = get_color('#777777') + + +################################################################################# +# port colors +################################################################################# + +PORT_TYPE_TO_COLOR = {key: get_color( + color) for name, key, sizeof, color in Constants.CORE_TYPES} +PORT_TYPE_TO_COLOR.update((key, get_color(color)) + for key, (_, color) in Constants.ALIAS_TYPES.items()) + + +################################################################################# +# param box colors +################################################################################# +DARK_THEME_STYLES = b""" + #dtype_complex { background-color: #3399FF; } + #dtype_real { background-color: #FF8C69; } + #dtype_float { background-color: #FF8C69; } + #dtype_int { background-color: #00FF99; } + + #dtype_complex_vector { background-color: #3399AA; } + #dtype_real_vector { background-color: #CC8C69; } + #dtype_float_vector { background-color: #CC8C69; } + #dtype_int_vector { background-color: #00CC99; } + + #dtype_bool { background-color: #00FF99; } + #dtype_hex { background-color: #00FF99; } + #dtype_string { background-color: #CC66CC; } + #dtype_id { background-color: #DDDDDD; } + #dtype_stream_id { background-color: #DDDDDD; } + #dtype_raw { background-color: #DDDDDD; } + + #enum_custom { background-color: #EEEEEE; } + """ +LIGHT_THEME_STYLES = b""" + #dtype_complex { background-color: #3399FF; } + #dtype_real { background-color: #FF8C69; } + #dtype_float { background-color: #FF8C69; } + #dtype_int { background-color: #00FF99; } + + #dtype_complex_vector { background-color: #3399AA; } + #dtype_real_vector { background-color: #CC8C69; } + #dtype_float_vector { background-color: #CC8C69; } + #dtype_int_vector { background-color: #00CC99; } + + #dtype_bool { background-color: #00FF99; } + #dtype_hex { background-color: #00FF99; } + #dtype_string { background-color: #CC66CC; } + #dtype_id { background-color: #DDDDDD; } + #dtype_stream_id { background-color: #DDDDDD; } + #dtype_raw { background-color: #FFFFFF; } + + #enum_custom { background-color: #EEEEEE; } + """ diff --git a/grc/gui/canvas/connection.py b/grc/gui/canvas/connection.py new file mode 100644 index 0000000..0195c23 --- /dev/null +++ b/grc/gui/canvas/connection.py @@ -0,0 +1,245 @@ +""" +Copyright 2007, 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +from argparse import Namespace +from math import pi + +import cairo + +from . import colors +from .drawable import Drawable +from .. import Utils +from ..Constants import ( + CONNECTOR_ARROW_BASE, + CONNECTOR_ARROW_HEIGHT, + GR_MESSAGE_DOMAIN, + LINE_SELECT_SENSITIVITY, +) +from ...core.Connection import Connection as CoreConnection +from ...core.utils.descriptors import nop_write + + +class Connection(CoreConnection, Drawable): + """ + A graphical connection for ports. + The connection has 2 parts, the arrow and the wire. + The coloring of the arrow and wire exposes the status of 3 states: + enabled/disabled, valid/invalid, highlighted/non-highlighted. + The wire coloring exposes the enabled and highlighted states. + The arrow coloring exposes the enabled and valid states. + """ + + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + Drawable.__init__(self) + + self._line = [] + self._line_width_factor = 1.0 + self._color1 = self._color2 = None + + self._current_port_rotations = self._current_coordinates = None + + self._rel_points = None # connection coordinates relative to sink/source + self._arrow_rotation = 0.0 # rotation of the arrow in radians + self._line_path = None + # simple cairo context for curved line and computing what_is_selected + cr = cairo.Context(cairo.RecordingSurface(cairo.CONTENT_ALPHA, None)) + cr.set_line_width(cr.get_line_width() * LINE_SELECT_SENSITIVITY) + self._line_path_cr = cr + + @nop_write + @property + def coordinate(self): + return self.source_port.connector_coordinate_absolute + + @nop_write + @property + def rotation(self): + """ + Get the 0 degree rotation. + Rotations are irrelevant in connection. + + Returns: + 0 + """ + return 0 + + def create_shapes(self): + """Pre-calculate relative coordinates.""" + source = self.source_port + sink = self.sink_port + rotate = Utils.get_rotated_coordinate + + # first two components relative to source connector, rest relative to sink connector + self._rel_points = [ + # line from 0,0 to here, bezier curve start + rotate((15, 0), source.rotation), + rotate((50, 0), source.rotation), # bezier curve control point 1 + rotate((-50, 0), sink.rotation), # bezier curve control point 2 + rotate((-15, 0), sink.rotation), # bezier curve end + rotate((-CONNECTOR_ARROW_HEIGHT, 0), + sink.rotation), # line to arrow head + ] + self._current_coordinates = None # triggers _make_path() + + def get_domain_color(domain_id): + domain = self.parent_platform.domains.get(domain_id, None) + return colors.get_color(domain.color) if domain else colors.DEFAULT_DOMAIN_COLOR + + if source.domain == GR_MESSAGE_DOMAIN: + self._line_width_factor = 1.0 + self._color1 = None + self._color2 = colors.CONNECTION_ENABLED_COLOR + else: + if source.domain != sink.domain: + self._line_width_factor = 2.0 + self._color1 = get_domain_color(source.domain) + self._color2 = get_domain_color(sink.domain) + + self._arrow_rotation = -sink.rotation / 180 * pi + + if not self._bounding_points: + self._make_path() # no cr set --> only sets bounding_points for extent + + def _make_path(self, cr=None): + x_pos, y_pos = self.source_port.connector_coordinate_absolute + # x_start, y_start = self.source_port.get_connector_coordinate() + x_end, y_end = self.sink_port.connector_coordinate_absolute + + # sink connector relative to sink connector + x_e, y_e = x_end - x_pos, y_end - y_pos + + # make rel_point all relative to source connector + p0 = 0, 0 # x_start - x_pos, y_start - y_pos + p1, p2, (dx_e1, dy_e1), (dx_e2, dy_e2), (dx_e3, + dy_e3) = self._rel_points + p3 = x_e + dx_e1, y_e + dy_e1 + p4 = x_e + dx_e2, y_e + dy_e2 + p5 = x_e + dx_e3, y_e + dy_e3 + self._bounding_points = p0, p1, p4, p5 # ignores curved part =( + + if cr: + cr.move_to(*p0) + cr.line_to(*p1) + cr.curve_to(*(p2 + p3 + p4)) + cr.line_to(*p5) + self._line_path = cr.copy_path() + self._line_path_cr.new_path() + self._line_path_cr.append_path(self._line_path) + + def draw(self, cr): + """ + Draw the connection. + """ + sink = self.sink_port + source = self.source_port + + # check for changes + port_rotations = (source.rotation, sink.rotation) + if self._current_port_rotations != port_rotations: + self.create_shapes() # triggers _make_path() call below + self._current_port_rotations = port_rotations + + new_coordinates = (source.parent_block.coordinate, + sink.parent_block.coordinate) + if self._current_coordinates != new_coordinates: + self._make_path(cr) + self._current_coordinates = new_coordinates + + color1, color2 = ( + None if color is None else + colors.HIGHLIGHT_COLOR if self.highlighted else + colors.CONNECTION_DISABLED_COLOR if not self.enabled else + colors.CONNECTION_ERROR_COLOR if not self.is_valid() else + color + for color in (self._color1, self._color2) + ) + + cr.translate(*self.coordinate) + cr.set_line_width(self._line_width_factor * cr.get_line_width()) + cr.new_path() + cr.append_path(self._line_path) + + arrow_pos = cr.get_current_point() + + if color1: # not a message connection + cr.set_source_rgba(*color1) + cr.stroke_preserve() + + if color1 != color2: + cr.save() + cr.set_dash([5.0, 5.0], 5.0 if color1 else 0.0) + cr.set_source_rgba(*color2) + cr.stroke() + cr.restore() + else: + cr.new_path() + + cr.move_to(*arrow_pos) + cr.set_source_rgba(*color2) + cr.rotate(self._arrow_rotation) + cr.rel_move_to(CONNECTOR_ARROW_HEIGHT, 0) + cr.rel_line_to(-CONNECTOR_ARROW_HEIGHT, -CONNECTOR_ARROW_BASE / 2) + cr.rel_line_to(0, CONNECTOR_ARROW_BASE) + cr.close_path() + cr.fill() + + def what_is_selected(self, coor, coor_m=None): + """ + Returns: + self if one of the areas/lines encompasses coor, else None. + """ + if coor_m: + return Drawable.what_is_selected(self, coor, coor_m) + + if self._line_path is None: + return + + x, y = [a - b for a, b in zip(coor, self.coordinate)] + hit = self._line_path_cr.in_stroke(x, y) + + if hit: + return self + + +class DummyCoreConnection(object): + def __init__(self, source_port, **kwargs): + self.parent_platform = source_port.parent_platform + self.source_port = source_port + self.sink_port = self._dummy_port = Namespace( + domain=source_port.domain, + rotation=0, + coordinate=(0, 0), + connector_coordinate_absolute=(0, 0), + connector_direction=0, + parent_block=Namespace(coordinate=(0, 0)), + ) + + self.enabled = True + self.highlighted = False, + self.is_valid = lambda: True + self.update(**kwargs) + + def update(self, coordinate=None, rotation=None, sink_port=None): + dp = self._dummy_port + self.sink_port = sink_port if sink_port else dp + if coordinate: + dp.coordinate = coordinate + dp.connector_coordinate_absolute = coordinate + dp.parent_block.coordinate = coordinate + if rotation is not None: + dp.rotation = rotation + dp.connector_direction = (180 + rotation) % 360 + + @property + def has_real_sink(self): + return self.sink_port is not self._dummy_port + + +DummyConnection = Connection.make_cls_with_base(DummyCoreConnection) diff --git a/grc/gui/canvas/drawable.py b/grc/gui/canvas/drawable.py new file mode 100644 index 0000000..a586a7e --- /dev/null +++ b/grc/gui/canvas/drawable.py @@ -0,0 +1,169 @@ +""" +Copyright 2007, 2008, 2009, 2016 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + +from ..Constants import LINE_SELECT_SENSITIVITY + + +class Drawable(object): + """ + GraphicalElement is the base class for all graphical elements. + It contains an X,Y coordinate, a list of rectangular areas that the element occupies, + and methods to detect selection of those areas. + """ + + @classmethod + def make_cls_with_base(cls, super_cls): + name = super_cls.__name__ + bases = (super_cls,) + cls.__bases__[1:] + namespace = cls.__dict__.copy() + return type(name, bases, namespace) + + def __init__(self): + """ + Make a new list of rectangular areas and lines, and set the coordinate and the rotation. + """ + self.coordinate = (0, 0) + self.rotation = 0 + self.highlighted = False + + self._bounding_rects = [] + self._bounding_points = [] + + def is_horizontal(self, rotation=None): + """ + Is this element horizontal? + If rotation is None, use this element's rotation. + + Args: + rotation: the optional rotation + + Returns: + true if rotation is horizontal + """ + rotation = rotation or self.rotation + return rotation in (0, 180) + + def is_vertical(self, rotation=None): + """ + Is this element vertical? + If rotation is None, use this element's rotation. + + Args: + rotation: the optional rotation + + Returns: + true if rotation is vertical + """ + rotation = rotation or self.rotation + return rotation in (90, 270) + + def rotate(self, rotation): + """ + Rotate all of the areas by 90 degrees. + + Args: + rotation: multiple of 90 degrees + """ + self.rotation = (self.rotation + rotation) % 360 + + def move(self, delta_coor): + """ + Move the element by adding the delta_coor to the current coordinate. + + Args: + delta_coor: (delta_x,delta_y) tuple + """ + x, y = self.coordinate + dx, dy = delta_coor + self.coordinate = (x + dx, y + dy) + + def create_labels(self, cr=None): + """ + Create labels (if applicable) and call on all children. + Call this base method before creating labels in the element. + """ + + def create_shapes(self): + """ + Create shapes (if applicable) and call on all children. + Call this base method before creating shapes in the element. + """ + + def draw(self, cr): + raise NotImplementedError() + + def bounds_from_area(self, area): + x1, y1, w, h = area + x2 = x1 + w + y2 = y1 + h + self._bounding_rects = [(x1, y1, x2, y2)] + self._bounding_points = [(x1, y1), (x2, y1), (x1, y2), (x2, y2)] + + def bounds_from_line(self, line): + self._bounding_rects = rects = [] + self._bounding_points = list(line) + last_point = line[0] + for x2, y2 in line[1:]: + (x1, y1), last_point = last_point, (x2, y2) + if x1 == x2: + x1, x2 = x1 - LINE_SELECT_SENSITIVITY, x2 + LINE_SELECT_SENSITIVITY + if y2 < y1: + y1, y2 = y2, y1 + elif y1 == y2: + y1, y2 = y1 - LINE_SELECT_SENSITIVITY, y2 + LINE_SELECT_SENSITIVITY + if x2 < x1: + x1, x2 = x2, x1 + + rects.append((x1, y1, x2, y2)) + + def what_is_selected(self, coor, coor_m=None): + """ + One coordinate specified: + Is this element selected at given coordinate? + ie: is the coordinate encompassed by one of the areas or lines? + Both coordinates specified: + Is this element within the rectangular region defined by both coordinates? + ie: do any area corners or line endpoints fall within the region? + + Args: + coor: the selection coordinate, tuple x, y + coor_m: an additional selection coordinate. + + Returns: + self if one of the areas/lines encompasses coor, else None. + """ + x, y = [a - b for a, b in zip(coor, self.coordinate)] + + if not coor_m: + for x1, y1, x2, y2 in self._bounding_rects: + if x1 <= x <= x2 and y1 <= y <= y2: + return self + else: + x_m, y_m = [a - b for a, b in zip(coor_m, self.coordinate)] + if y_m < y: + y, y_m = y_m, y + if x_m < x: + x, x_m = x_m, x + + for x1, y1 in self._bounding_points: + if x <= x1 <= x_m and y <= y1 <= y_m: + return self + + def get_extents(self): + x_min, y_min = x_max, y_max = self.coordinate + x_min += min(x for x, y in self._bounding_points) + y_min += min(y for x, y in self._bounding_points) + x_max += max(x for x, y in self._bounding_points) + y_max += max(y for x, y in self._bounding_points) + return x_min, y_min, x_max, y_max + + def mouse_over(self): + pass + + def mouse_out(self): + pass diff --git a/grc/gui/canvas/flowgraph.py b/grc/gui/canvas/flowgraph.py new file mode 100644 index 0000000..e9f32b8 --- /dev/null +++ b/grc/gui/canvas/flowgraph.py @@ -0,0 +1,870 @@ +""" +Copyright 2007-2011, 2016q Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import ast +import functools +import random +from shutil import which as find_executable +from itertools import count + +from gi.repository import GLib, Gtk + +from . import colors +from .drawable import Drawable +from .connection import DummyConnection +from .. import Actions, Constants, Utils, Bars, Dialogs, MainWindow +from ..external_editor import ExternalEditor +from ...core import Messages +from ...core.FlowGraph import FlowGraph as CoreFlowgraph + + +class _ContextMenu(object): + """ + Help with drawing the right click context menu + """ + + def __init__(self, main_window): + self._menu = Gtk.Menu.new_from_model(Bars.ContextMenu()) + self._menu.attach_to_widget(main_window) + + # In GTK 3.22 Menu.popup was deprecated, we want to popup at the + # pointer, so use that new function instead if we can. + if Gtk.check_version(3, 22, 0) is None: + self.popup = self._menu.popup_at_pointer + + def popup(self, event): + self._menu.popup(None, None, None, None, event.button, event.time) + + +class FlowGraph(CoreFlowgraph, Drawable): + """ + FlowGraph is the data structure to store graphical signal blocks, + graphical inputs and outputs, + and the connections between inputs and outputs. + """ + + def __init__(self, parent, **kwargs): + """ + FlowGraph constructor. + Create a list for signal blocks and connections. Connect mouse handlers. + """ + super(self.__class__, self).__init__(parent, **kwargs) + Drawable.__init__(self) + + # We need to get the main window object so the context menu can be to the + # registered actions + app = Gtk.Application.get_default() + main_window = None + for window in app.get_windows(): + if isinstance(window, MainWindow.MainWindow): + main_window = window + break + + self.drawing_area = None + # important vars dealing with mouse event tracking + self.element_moved = False + self.mouse_pressed = False + self.press_coor = (0, 0) + # selected + self.selected_elements = set() + self._old_selected_port = None + self._new_selected_port = None + # current mouse hover element + self.element_under_mouse = None + # context menu + self._context_menu = _ContextMenu(main_window) + self.get_context_menu = lambda: self._context_menu + + self._new_connection = None + self._elements_to_draw = [] + self._external_updaters = {} + + def _get_unique_id(self, base_id=''): + """ + Get a unique id starting with the base id. + + Args: + base_id: the id starts with this and appends a count + + Returns: + a unique id + """ + block_ids = set(b.name for b in self.blocks) + for index in count(): + block_id = '{}_{}'.format(base_id, index) + if block_id not in block_ids: + break + return block_id + + def install_external_editor(self, param, parent=None): + target = (param.parent_block.name, param.key) + + if target in self._external_updaters: + editor = self._external_updaters[target] + else: + config = self.parent_platform.config + editor = (find_executable(config.editor) or + Dialogs.choose_editor(parent, config)) # todo: pass in parent + if not editor: + return + updater = functools.partial( + self.handle_external_editor_change, target=target) + editor = self._external_updaters[target] = ExternalEditor( + editor=editor, + name=target[0], value=param.get_value(), + callback=functools.partial(GLib.idle_add, updater) + ) + editor.start() + try: + editor.open_editor() + except Exception as e: + # Problem launching the editor. Need to select a new editor. + Messages.send( + '>>> Error opening an external editor. Please select a different editor.\n') + # Reset the editor to force the user to select a new one. + self.parent_platform.config.editor = '' + self.remove_external_editor(target=target) + + def remove_external_editor(self, target=None, param=None): + if target is None: + target = (param.parent_block.name, param.key) + if target in self._external_updaters: + self._external_updaters[target].stop() + del self._external_updaters[target] + + def handle_external_editor_change(self, new_value, target): + try: + block_id, param_key = target + self.get_block(block_id).params[param_key].set_value(new_value) + + except (IndexError, ValueError): # block no longer exists + self.remove_external_editor(target=target) + return + Actions.EXTERNAL_UPDATE() + + def add_new_block(self, key, coor=None): + """ + Add a block of the given key to this flow graph. + + Args: + key: the block key + coor: an optional coordinate or None for random + """ + id = self._get_unique_id(key) + scroll_pane = self.drawing_area.get_parent().get_parent() + # calculate the position coordinate + h_adj = scroll_pane.get_hadjustment() + v_adj = scroll_pane.get_vadjustment() + if coor is None: + coor = ( + int(random.uniform(.25, .75) * + h_adj.get_page_size() + h_adj.get_value()), + int(random.uniform(.25, .75) * + v_adj.get_page_size() + v_adj.get_value()), + ) + # get the new block + block = self.new_block(key) + block.coordinate = coor + block.params['id'].set_value(id) + Actions.ELEMENT_CREATE() + return id + + def make_connection(self): + """this selection and the last were ports, try to connect them""" + if self._new_connection and self._new_connection.has_real_sink: + self._old_selected_port = self._new_connection.source_port + self._new_selected_port = self._new_connection.sink_port + if self._old_selected_port and self._new_selected_port: + try: + self.connect(self._old_selected_port, self._new_selected_port) + Actions.ELEMENT_CREATE() + except Exception as e: + Messages.send_fail_connection(e) + self._old_selected_port = None + self._new_selected_port = None + return True + return False + + def update(self): + """ + Call the top level rewrite and validate. + Call the top level create labels and shapes. + """ + self.rewrite() + self.validate() + self.update_elements_to_draw() + self.create_labels() + self.create_shapes() + + def reload(self): + """ + Reload flow-graph (with updated blocks) + + Args: + page: the page to reload (None means current) + Returns: + False if some error occurred during import + """ + success = False + data = self.export_data() + if data: + self.unselect() + success = self.import_data(data) + self.update() + return success + + ########################################################################### + # Copy Paste + ########################################################################### + def copy_to_clipboard(self): + """ + Copy the selected blocks and connections into the clipboard. + + Returns: + the clipboard + """ + # get selected blocks + blocks = list(self.selected_blocks()) + if not blocks: + return None + # calc x and y min + x_min, y_min = blocks[0].coordinate + for block in blocks: + x, y = block.coordinate + x_min = min(x, x_min) + y_min = min(y, y_min) + # get connections between selected blocks + connections = list(filter( + lambda c: c.source_block in blocks and c.sink_block in blocks, + self.connections, + )) + clipboard = ( + (x_min, y_min), + [block.export_data() for block in blocks], + [connection.export_data() for connection in connections], + ) + return clipboard + + def paste_from_clipboard(self, clipboard): + """ + Paste the blocks and connections from the clipboard. + + Args: + clipboard: the nested data of blocks, connections + """ + (x_min, y_min), blocks_n, connections_n = clipboard + # recalc the position + scroll_pane = self.drawing_area.get_parent().get_parent() + h_adj = scroll_pane.get_hadjustment() + v_adj = scroll_pane.get_vadjustment() + x_off = h_adj.get_value() - x_min + h_adj.get_page_size() / 4 + y_off = v_adj.get_value() - y_min + v_adj.get_page_size() / 4 + + if len(self.get_elements()) <= 1: + x_off, y_off = 0, 0 + + # create blocks + pasted_blocks = {} + for block_n in blocks_n: + block_key = block_n.get('id') + if block_key == 'options': + continue + + block_name = block_n.get('name') + # Verify whether a block with this name exists before adding it + if block_name in (blk.name for blk in self.blocks): + block_n = block_n.copy() + block_n['name'] = self._get_unique_id(block_name) + + block = self.new_block(block_key) + if not block: + continue # unknown block was pasted (e.g. dummy block) + + block.import_data(**block_n) + pasted_blocks[block_name] = block # that is before any rename + + block.move((x_off, y_off)) + while any(Utils.align_to_grid(block.coordinate) == Utils.align_to_grid(other.coordinate) + for other in self.blocks if other is not block): + block.move((Constants.CANVAS_GRID_SIZE, + Constants.CANVAS_GRID_SIZE)) + # shift all following blocks + x_off += Constants.CANVAS_GRID_SIZE + y_off += Constants.CANVAS_GRID_SIZE + + self.selected_elements = set(pasted_blocks.values()) + + # update before creating connections + self.update() + # create connections + for src_block, src_port, dst_block, dst_port in connections_n: + source = pasted_blocks[src_block].get_source(src_port) + sink = pasted_blocks[dst_block].get_sink(dst_port) + connection = self.connect(source, sink) + self.selected_elements.add(connection) + + ########################################################################### + # Modify Selected + ########################################################################### + def type_controller_modify_selected(self, direction): + """ + Change the registered type controller for the selected signal blocks. + + Args: + direction: +1 or -1 + + Returns: + true for change + """ + return any([sb.type_controller_modify(direction) for sb in self.selected_blocks()]) + + def port_controller_modify_selected(self, direction): + """ + Change port controller for the selected signal blocks. + + Args: + direction: +1 or -1 + + Returns: + true for changed + """ + return any([sb.port_controller_modify(direction) for sb in self.selected_blocks()]) + + def change_state_selected(self, new_state): + """ + Enable/disable the selected blocks. + + Args: + new_state: a block state + + Returns: + true if changed + """ + changed = False + for block in self.selected_blocks(): + changed |= block.state != new_state + block.state = new_state + return changed + + def move_selected(self, delta_coordinate): + """ + Move the element and by the change in coordinates. + + Args: + delta_coordinate: the change in coordinates + """ + + # Determine selected blocks top left coordinate + blocks = list(self.selected_blocks()) + if not blocks: + return + + min_x, min_y = self.selected_block.coordinate + for selected_block in blocks: + x, y = selected_block.coordinate + min_x, min_y = min(min_x, x), min(min_y, y) + + # Sanitize delta_coordinate so that blocks don't move to negative coordinate + delta_coordinate = max( + delta_coordinate[0], -min_x), max(delta_coordinate[1], -min_y) + + # Move selected blocks + for selected_block in blocks: + selected_block.move(delta_coordinate) + self.element_moved = True + + def align_selected(self, calling_action=None): + """ + Align the selected blocks. + + Args: + calling_action: the action initiating the alignment + + Returns: + True if changed, otherwise False + """ + blocks = list(self.selected_blocks()) + if calling_action is None or not blocks: + return False + + # compute common boundary of selected objects + min_x, min_y = max_x, max_y = blocks[0].coordinate + for selected_block in blocks: + x, y = selected_block.coordinate + min_x, min_y = min(min_x, x), min(min_y, y) + x += selected_block.width + y += selected_block.height + max_x, max_y = max(max_x, x), max(max_y, y) + ctr_x, ctr_y = (max_x + min_x) / 2, (max_y + min_y) / 2 + + # align the blocks as requested + transform = { + Actions.BLOCK_VALIGN_TOP: lambda x, y, w, h: (x, min_y), + Actions.BLOCK_VALIGN_MIDDLE: lambda x, y, w, h: (x, ctr_y - h / 2), + Actions.BLOCK_VALIGN_BOTTOM: lambda x, y, w, h: (x, max_y - h), + Actions.BLOCK_HALIGN_LEFT: lambda x, y, w, h: (min_x, y), + Actions.BLOCK_HALIGN_CENTER: lambda x, y, w, h: (ctr_x - w / 2, y), + Actions.BLOCK_HALIGN_RIGHT: lambda x, y, w, h: (max_x - w, y), + }.get(calling_action, lambda *args: args) + + for selected_block in blocks: + x, y = selected_block.coordinate + w, h = selected_block.width, selected_block.height + selected_block.coordinate = transform(x, y, w, h) + + return True + + def rotate_selected(self, rotation): + """ + Rotate the selected blocks by multiples of 90 degrees. + + Args: + rotation: the rotation in degrees + + Returns: + true if changed, otherwise false. + """ + if not any(self.selected_blocks()): + return False + # initialize min and max coordinates + min_x, min_y = max_x, max_y = self.selected_block.coordinate + # rotate each selected block, and find min/max coordinate + for selected_block in self.selected_blocks(): + selected_block.rotate(rotation) + # update the min/max coordinate + x, y = selected_block.coordinate + min_x, min_y = min(min_x, x), min(min_y, y) + max_x, max_y = max(max_x, x), max(max_y, y) + # calculate center point of selected blocks + ctr_x, ctr_y = (max_x + min_x) / 2, (max_y + min_y) / 2 + # rotate the blocks around the center point + for selected_block in self.selected_blocks(): + x, y = selected_block.coordinate + x, y = Utils.get_rotated_coordinate( + (x - ctr_x, y - ctr_y), rotation) + selected_block.coordinate = (x + ctr_x, y + ctr_y) + return True + + def remove_selected(self): + """ + Remove selected elements + + Returns: + true if changed. + """ + changed = False + for selected_element in self.selected_elements: + self.remove_element(selected_element) + changed = True + return changed + + def update_selected(self): + """ + Remove deleted elements from the selected elements list. + Update highlighting so only the selected are highlighted. + """ + selected_elements = self.selected_elements + elements = self.get_elements() + # remove deleted elements + for selected in list(selected_elements): + if selected in elements: + continue + selected_elements.remove(selected) + if self._old_selected_port and self._old_selected_port.parent not in elements: + self._old_selected_port = None + if self._new_selected_port and self._new_selected_port.parent not in elements: + self._new_selected_port = None + # update highlighting + for element in elements: + element.highlighted = element in selected_elements + + ########################################################################### + # Draw stuff + ########################################################################### + + def update_elements_to_draw(self): + hide_disabled_blocks = Actions.TOGGLE_HIDE_DISABLED_BLOCKS.get_active() + hide_variables = Actions.TOGGLE_HIDE_VARIABLES.get_active() + + def draw_order(elem): + return elem.highlighted, elem.is_block, elem.enabled + + elements = sorted(self.get_elements(), key=draw_order) + del self._elements_to_draw[:] + + for element in elements: + if hide_disabled_blocks and not element.enabled: + continue # skip hidden disabled blocks and connections + if hide_variables and (element.is_variable or element.is_import): + continue # skip hidden disabled blocks and connections + self._elements_to_draw.append(element) + + def create_labels(self, cr=None): + for element in self._elements_to_draw: + element.create_labels(cr) + + def create_shapes(self): + # TODO - this is a workaround for bus ports not having a proper coordinate + # until the shape is drawn. The workaround is to draw blocks before connections + + for element in filter(lambda x: x.is_block, self._elements_to_draw): + element.create_shapes() + + for element in filter(lambda x: not x.is_block, self._elements_to_draw): + element.create_shapes() + + def _drawables(self): + # todo: cache that + show_comments = Actions.TOGGLE_SHOW_BLOCK_COMMENTS.get_active() + hide_disabled_blocks = Actions.TOGGLE_HIDE_DISABLED_BLOCKS.get_active() + for element in self._elements_to_draw: + if element.is_block and show_comments and element.enabled: + yield element.draw_comment + if self._new_connection is not None: + yield self._new_connection.draw + for element in self._elements_to_draw: + if element not in self.selected_elements: + yield element.draw + for element in self.selected_elements: + if element.enabled or not hide_disabled_blocks: + yield element.draw + + def draw(self, cr): + """Draw blocks connections comment and select rectangle""" + for draw_element in self._drawables(): + cr.save() + draw_element(cr) + cr.restore() + + draw_multi_select_rectangle = ( + self.mouse_pressed and + (not self.selected_elements or self.drawing_area.ctrl_mask) and + not self._new_connection + ) + if draw_multi_select_rectangle: + x1, y1 = self.press_coor + x2, y2 = self.coordinate + x, y = int(min(x1, x2)), int(min(y1, y2)) + w, h = int(abs(x1 - x2)), int(abs(y1 - y2)) + cr.set_source_rgba( + colors.HIGHLIGHT_COLOR[0], + colors.HIGHLIGHT_COLOR[1], + colors.HIGHLIGHT_COLOR[2], + 0.5, + ) + cr.rectangle(x, y, w, h) + cr.fill() + cr.rectangle(x, y, w, h) + cr.stroke() + + ########################################################################## + # selection handling + ########################################################################## + def update_selected_elements(self): + """ + Update the selected elements. + The update behavior depends on the state of the mouse button. + When the mouse button pressed the selection will change when + the control mask is set or the new selection is not in the current group. + When the mouse button is released the selection will change when + the mouse has moved and the control mask is set or the current group is empty. + Attempt to make a new connection if the old and ports are filled. + If the control mask is set, merge with the current elements. + """ + selected_elements = None + if self.mouse_pressed: + new_selections = self.what_is_selected(self.coordinate) + # update the selections if the new selection is not in the current selections + # allows us to move entire selected groups of elements + if not new_selections: + selected_elements = set() + elif self.drawing_area.ctrl_mask or self.selected_elements.isdisjoint(new_selections): + selected_elements = new_selections + + if self._old_selected_port: + self._old_selected_port.force_show_label = False + self.create_shapes() + self.drawing_area.queue_draw() + elif self._new_selected_port: + self._new_selected_port.force_show_label = True + + else: # called from a mouse release + if not self.element_moved and (not self.selected_elements or self.drawing_area.ctrl_mask) and not self._new_connection: + selected_elements = self.what_is_selected( + self.coordinate, self.press_coor) + + # this selection and the last were ports, try to connect them + if self.make_connection(): + return + + # update selected elements + if selected_elements is None: + return + + # if ctrl, set the selected elements to the union - intersection of old and new + if self.drawing_area.ctrl_mask: + self.selected_elements ^= selected_elements + else: + self.selected_elements.clear() + self.selected_elements.update(selected_elements) + Actions.ELEMENT_SELECT() + + def what_is_selected(self, coor, coor_m=None): + """ + What is selected? + At the given coordinate, return the elements found to be selected. + If coor_m is unspecified, return a list of only the first element found to be selected: + Iterate though the elements backwards since top elements are at the end of the list. + If an element is selected, place it at the end of the list so that is is drawn last, + and hence on top. Update the selected port information. + + Args: + coor: the coordinate of the mouse click + coor_m: the coordinate for multi select + + Returns: + the selected blocks and connections or an empty list + """ + selected_port = None + selected = set() + # check the elements + for element in reversed(self._elements_to_draw): + selected_element = element.what_is_selected(coor, coor_m) + if not selected_element: + continue + # update the selected port information + if selected_element.is_port: + if not coor_m: + selected_port = selected_element + selected_element = selected_element.parent_block + + selected.add(selected_element) + if not coor_m: + break + + if selected_port and selected_port.is_source: + selected.remove(selected_port.parent_block) + self._new_connection = DummyConnection( + selected_port, coordinate=coor) + self.drawing_area.queue_draw() + # update selected ports + if selected_port is not self._new_selected_port: + self._old_selected_port = self._new_selected_port + self._new_selected_port = selected_port + return selected + + def unselect(self): + """ + Set selected elements to an empty set. + """ + self.selected_elements.clear() + + def select_all(self): + """Select all blocks in the flow graph""" + self.selected_elements.clear() + self.selected_elements.update(self._elements_to_draw) + + def selected_blocks(self): + """ + Get a group of selected blocks. + + Returns: + sub set of blocks in this flow graph + """ + return (e for e in self.selected_elements.copy() if e.is_block) + + def selected_connections(self): + """ + Get a group of selected connections. + + Returns: + sub set of connections in this flow graph + """ + return (e for e in self.selected_elements.copy() if e.is_connection) + + @property + def selected_block(self): + """ + Get the selected block when a block or port is selected. + + Returns: + a block or None + """ + return next(self.selected_blocks(), None) + + @property + def selected_connection(self): + """ + Get the selected connection + + Returns: + a connection or None + """ + return next(self.selected_connections(), None) + + def get_selected_elements(self): + """ + Get the group of selected elements. + + Returns: + sub set of elements in this flow graph + """ + return self.selected_elements + + def get_selected_element(self): + """ + Get the selected element. + + Returns: + a block, port, or connection or None + """ + return next(iter(self.selected_elements), None) + + ########################################################################## + # Event Handlers + ########################################################################## + def handle_mouse_context_press(self, coordinate, event): + """ + The context mouse button was pressed: + If no elements were selected, perform re-selection at this coordinate. + Then, show the context menu at the mouse click location. + """ + selections = self.what_is_selected(coordinate) + if not selections.intersection(self.selected_elements): + self.coordinate = coordinate + self.mouse_pressed = True + self.update_selected_elements() + self.mouse_pressed = False + if self._new_connection: + self._new_connection = None + self.drawing_area.queue_draw() + self._context_menu.popup(event) + + def handle_mouse_selector_press(self, double_click, coordinate): + """ + The selector mouse button was pressed: + Find the selected element. Attempt a new connection if possible. + Open the block params window on a double click. + Update the selection state of the flow graph. + """ + self.press_coor = coordinate + self.coordinate = coordinate + self.mouse_pressed = True + if double_click: + self.unselect() + self.update_selected_elements() + + if double_click and self.selected_block: + self.mouse_pressed = False + Actions.BLOCK_PARAM_MODIFY() + + def handle_mouse_selector_release(self, coordinate): + """ + The selector mouse button was released: + Update the state, handle motion (dragging). + And update the selected flowgraph elements. + """ + self.coordinate = coordinate + self.mouse_pressed = False + if self.element_moved: + Actions.BLOCK_MOVE() + self.element_moved = False + self.update_selected_elements() + if self._new_connection: + self._new_connection = None + self.drawing_area.queue_draw() + + def handle_mouse_motion(self, coordinate): + """ + The mouse has moved, respond to mouse dragging or notify elements + Move a selected element to the new coordinate. + Auto-scroll the scroll bars at the boundaries. + """ + # to perform a movement, the mouse must be pressed + # (no longer checking pending events via Gtk.events_pending() - always true in Windows) + redraw = False + if not self.mouse_pressed or self._new_connection: + redraw = self._handle_mouse_motion_move(coordinate) + if self.mouse_pressed: + redraw = redraw or self._handle_mouse_motion_drag(coordinate) + if redraw: + self.drawing_area.queue_draw() + + def _handle_mouse_motion_move(self, coordinate): + # only continue if mouse-over stuff is enabled (just the auto-hide port label stuff for now) + redraw = False + for element in self._elements_to_draw: + over_element = element.what_is_selected(coordinate) + if not over_element: + continue + if over_element != self.element_under_mouse: # over sth new + if self.element_under_mouse: + redraw |= self.element_under_mouse.mouse_out() or False + self.element_under_mouse = over_element + redraw |= over_element.mouse_over() or False + break + else: + if self.element_under_mouse: + redraw |= self.element_under_mouse.mouse_out() or False + self.element_under_mouse = None + if not Actions.TOGGLE_AUTO_HIDE_PORT_LABELS.get_active(): + return + if redraw: + # self.create_labels() + self.create_shapes() + return redraw + + def _handle_mouse_motion_drag(self, coordinate): + redraw = False + # remove the connection if selected in drag event + if len(self.selected_elements) == 1 and self.get_selected_element().is_connection: + Actions.ELEMENT_DELETE() + redraw = True + + if self._new_connection: + e = self.element_under_mouse + if e and e.is_port and e.is_sink: + self._new_connection.update(sink_port=self.element_under_mouse) + else: + self._new_connection.update(coordinate=coordinate, rotation=0) + return True + # move the selected elements and record the new coordinate + x, y = coordinate + if not self.drawing_area.ctrl_mask: + X, Y = self.coordinate + dX, dY = x - X, y - Y + + if Actions.TOGGLE_SNAP_TO_GRID.get_active() or self.drawing_area.mod1_mask: + dX, dY = int(round(dX / Constants.CANVAS_GRID_SIZE) + ), int(round(dY / Constants.CANVAS_GRID_SIZE)) + dX, dY = dX * Constants.CANVAS_GRID_SIZE, dY * Constants.CANVAS_GRID_SIZE + else: + dX, dY = int(round(dX)), int(round(dY)) + + if dX != 0 or dY != 0: + self.move_selected((dX, dY)) + self.coordinate = (X + dX, Y + dY) + redraw = True + return redraw + + def get_extents(self): + show_comments = Actions.TOGGLE_SHOW_BLOCK_COMMENTS.get_active() + + def sub_extents(): + for element in self._elements_to_draw: + yield element.get_extents() + if element.is_block and show_comments and element.enabled: + yield element.get_extents_comment() + + extent = 10000000, 10000000, 0, 0 + cmps = (min, min, max, max) + for sub_extent in sub_extents(): + extent = [cmp(xy, e_xy) + for cmp, xy, e_xy in zip(cmps, extent, sub_extent)] + return tuple(extent) diff --git a/grc/gui/canvas/param.py b/grc/gui/canvas/param.py new file mode 100644 index 0000000..fbdb718 --- /dev/null +++ b/grc/gui/canvas/param.py @@ -0,0 +1,190 @@ +# Copyright 2007-2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + + +import numbers + +from .drawable import Drawable +from .. import ParamWidgets, Utils, Constants, Actions +from ...core.params import Param as CoreParam + + +class Param(CoreParam): + """The graphical parameter.""" + + make_cls_with_base = classmethod(Drawable.make_cls_with_base.__func__) + + def get_input(self, *args, **kwargs): + """ + Get the graphical gtk class to represent this parameter. + An enum requires and combo parameter. + A non-enum with options gets a combined entry/combo parameter. + All others get a standard entry parameter. + + Returns: + gtk input class + """ + dtype = self.dtype + if dtype in ('file_open', 'file_save'): + input_widget_cls = ParamWidgets.FileParam + + elif dtype == 'dir_select': + input_widget_cls = ParamWidgets.DirectoryParam + + elif dtype == 'enum': + input_widget_cls = ParamWidgets.EnumParam + + elif self.options: + input_widget_cls = ParamWidgets.EnumEntryParam + + elif dtype == '_multiline': + input_widget_cls = ParamWidgets.MultiLineEntryParam + + elif dtype == '_multiline_python_external': + input_widget_cls = ParamWidgets.PythonEditorParam + + else: + input_widget_cls = ParamWidgets.EntryParam + + return input_widget_cls(self, *args, **kwargs) + + def format_label_markup(self, have_pending_changes=False): + block = self.parent + # fixme: using non-public attribute here + has_callback = \ + hasattr(block, 'templates') and \ + any(self.key in callback for callback in block.templates.get('callbacks', '')) + + return '{label}'.format( + underline='underline="low"' if has_callback else '', + foreground='foreground="blue"' if have_pending_changes else + 'foreground="red"' if not self.is_valid() else '', + label=Utils.encode(self.name) + ) + + def format_dtype_markup(self): + return f'[{Utils.encode(self.dtype)}]' + + def format_tooltip_text(self): + errors = self.get_error_messages() + tooltip_lines = ['Key: ' + self.key, 'Type: ' + self.dtype] + if self.is_valid(): + value = self.get_evaluated() + if hasattr(value, "__len__"): + tooltip_lines.append('Length: {}'.format(len(value))) + value = str(value) + # ensure that value is a UTF-8 string + # Old PMTs could produce non-UTF-8 strings + value = value.encode('utf-8', 'backslashreplace').decode('utf-8') + if len(value) > 100: + value = '{}...{}'.format(value[:50], value[-50:]) + tooltip_lines.append('Value: ' + value) + elif len(errors) == 1: + tooltip_lines.append('Error: ' + errors[0]) + elif len(errors) > 1: + tooltip_lines.append('Error:') + tooltip_lines.extend(' * ' + msg for msg in errors) + return '\n'.join(tooltip_lines) + + ################################################## + # Truncate helper method + ################################################## + + def truncate(self, string, style=0): + max_len = max(27 - len(self.name), 3) + if len(string) > max_len: + if style < 0: # Front truncate + string = '...' + string[3 - max_len:] + elif style == 0: # Center truncate + string = string[:max_len // 2 - 3] + \ + '...' + string[-max_len // 2:] + elif style > 0: # Rear truncate + string = string[:max_len - 3] + '...' + return string + + def pretty_print(self): + """ + Get the repr (nice string format) for this param. + + Returns: + the string representation + """ + + ################################################## + # Simple conditions + ################################################## + value = self.get_value() + if not self.is_valid(): + return self.truncate(value) + if value in self.options: + return self.options[value] # its name + + ################################################## + # Split up formatting by type + ################################################## + # Default center truncate + truncate = 0 + e = self.get_evaluated() + t = self.dtype + if isinstance(e, bool): + return str(e) + elif isinstance(e, numbers.Complex): + dt_str = Utils.num_to_str(e) + elif isinstance(e, Constants.VECTOR_TYPES): + # Vector types + if len(e) > 8: + # Large vectors use code + dt_str = self.get_value() + truncate = 1 + else: + # Small vectors use eval + dt_str = ', '.join(map(Utils.num_to_str, e)) + elif t in ('file_open', 'file_save'): + dt_str = self.get_value() + truncate = -1 + else: + # Other types + dt_str = str(e) + # ensure that value is a UTF-8 string + # Old PMTs could produce non-UTF-8 strings + dt_str = dt_str.encode('utf-8', 'backslashreplace').decode('utf-8') + + # Done + return self.truncate(dt_str, truncate) + + def format_block_surface_markup(self): + """ + Get the markup for this param. + + Returns: + a pango markup string + """ + + # TODO: is this the correct way to do this? + is_evaluated = self.value != str(self.get_evaluated()) + show_value = Actions.TOGGLE_SHOW_PARAMETER_EVALUATION.get_active() + show_expr = Actions.TOGGLE_SHOW_PARAMETER_EXPRESSION.get_active() + + display_value = "" + + # Include the value defined by the user (after evaluation) + if not is_evaluated or show_value or not show_expr: + display_value += Utils.encode( + self.pretty_print().replace('\n', ' ')) + + # Include the expression that was evaluated to get the value + if is_evaluated and show_expr: + expr_string = "" + \ + Utils.encode(self.truncate(self.value)) + "" + + if display_value: # We are already displaying the value + display_value = expr_string + "=" + display_value + else: + display_value = expr_string + + return '{label}: {value}'.format( + foreground='foreground="red"' if not self.is_valid() else '', font=Constants.PARAM_FONT, + label=Utils.encode(self.name), value=display_value) diff --git a/grc/gui/canvas/port.py b/grc/gui/canvas/port.py new file mode 100644 index 0000000..f78fd62 --- /dev/null +++ b/grc/gui/canvas/port.py @@ -0,0 +1,219 @@ +""" +Copyright 2007, 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import math + +from gi.repository import Gtk, PangoCairo, Pango + +from . import colors +from .drawable import Drawable +from .. import Actions, Utils, Constants + +from ...core.utils.descriptors import nop_write +from ...core.ports import Port as CorePort + + +class Port(CorePort, Drawable): + """The graphical port.""" + + def __init__(self, parent, direction, **n): + """ + Port constructor. + Create list of connector coordinates. + """ + super(self.__class__, self).__init__(parent, direction, **n) + Drawable.__init__(self) + self._connector_coordinate = (0, 0) + self._hovering = False + self.force_show_label = False + + self._area = [] + self._bg_color = self._border_color = 0, 0, 0, 0 + self._font_color = list(colors.FONT_COLOR) + + self._line_width_factor = 1.0 + self._label_layout_offsets = 0, 0 + + self.width_with_label = self.height = 0 + + self.label_layout = None + + @property + def width(self): + return self.width_with_label if self._show_label else Constants.PORT_LABEL_HIDDEN_WIDTH + + @width.setter + def width(self, value): + self.width_with_label = value + self.label_layout.set_width(value * Pango.SCALE) + + def _update_colors(self): + """ + Get the color that represents this port's type. + Codes differ for ports where the vec length is 1 or greater than 1. + + Returns: + a hex color code. + """ + if not self.parent_block.enabled: + self._font_color[-1] = 0.4 + color = colors.BLOCK_DISABLED_COLOR + elif self.domain == Constants.GR_MESSAGE_DOMAIN: + color = colors.PORT_TYPE_TO_COLOR.get('message') + else: + self._font_color[-1] = 1.0 + color = colors.PORT_TYPE_TO_COLOR.get( + self.dtype) or colors.PORT_TYPE_TO_COLOR.get('') + if self.vlen > 1: + dark = (0, 0, 30 / 255.0, 50 / 255.0, + 70 / 255.0)[min(4, self.vlen)] + color = tuple(max(c - dark, 0) for c in color) + self._bg_color = color + self._border_color = tuple(max(c - 0.3, 0) for c in color) + + def create_shapes(self): + """Create new areas and labels for the port.""" + if self.is_horizontal(): + self._area = (0, 0, self.width, self.height) + elif self.is_vertical(): + self._area = (0, 0, self.height, self.width) + self.bounds_from_area(self._area) + + self._connector_coordinate = { + 0: (self.width, self.height / 2), + 90: (self.height / 2, 0), + 180: (0, self.height / 2), + 270: (self.height / 2, self.width) + }[self.connector_direction] + + def create_labels(self, cr=None): + """Create the labels for the socket.""" + self.label_layout = Gtk.DrawingArea().create_pango_layout('') + self.label_layout.set_alignment(Pango.Alignment.CENTER) + + if cr: + PangoCairo.update_layout(cr, self.label_layout) + + if self.domain in (Constants.GR_MESSAGE_DOMAIN, Constants.GR_STREAM_DOMAIN): + self._line_width_factor = 1.0 + else: + self._line_width_factor = 2.0 + + self._update_colors() + + layout = self.label_layout + layout.set_markup('{name}'.format( + name=Utils.encode(self.name), font=Constants.PORT_FONT + )) + label_width, label_height = self.label_layout.get_size() + + self.width = 2 * Constants.PORT_LABEL_PADDING + label_width / Pango.SCALE + self.height = (2 * Constants.PORT_LABEL_PADDING + label_height * + (3 if self.dtype == 'bus' else 1)) / Pango.SCALE + self._label_layout_offsets = [0, Constants.PORT_LABEL_PADDING] + + self.height += self.height % 2 # uneven height + + def draw(self, cr): + """ + Draw the socket with a label. + """ + if self.hidden: + return + + border_color = self._border_color + cr.set_line_width(self._line_width_factor * cr.get_line_width()) + cr.translate(*self.coordinate) + + cr.rectangle(*self._area) + cr.set_source_rgba(*self._bg_color) + cr.fill_preserve() + cr.set_source_rgba(*border_color) + cr.stroke() + + if not self._show_label: + return # this port is folded (no label) + + if self.is_vertical(): + cr.rotate(-math.pi / 2) + cr.translate(-self.width, 0) + cr.translate(*self._label_layout_offsets) + + cr.set_source_rgba(*self._font_color) + PangoCairo.update_layout(cr, self.label_layout) + PangoCairo.show_layout(cr, self.label_layout) + + @property + def connector_coordinate_absolute(self): + """the coordinate where connections may attach to""" + return [sum(c) for c in zip( + self._connector_coordinate, # relative to port + self.coordinate, # relative to block + self.parent_block.coordinate # abs + )] + + @property + def connector_direction(self): + """Get the direction that the socket points: 0,90,180,270.""" + if self.is_source: + return self.rotation + elif self.is_sink: + return (self.rotation + 180) % 360 + + @nop_write + @property + def rotation(self): + return self.parent_block.rotation + + def rotate(self, direction): + """ + Rotate the parent rather than self. + + Args: + direction: degrees to rotate + """ + self.parent_block.rotate(direction) + + def move(self, delta_coor): + """Move the parent rather than self.""" + self.parent_block.move(delta_coor) + + @property + def highlighted(self): + return self.parent_block.highlighted + + @highlighted.setter + def highlighted(self, value): + self.parent_block.highlighted = value + + @property + def _show_label(self): + """ + Figure out if the label should be hidden + + Returns: + true if the label should not be shown + """ + return self._hovering or self.force_show_label or not Actions.TOGGLE_AUTO_HIDE_PORT_LABELS.get_active() + + def mouse_over(self): + """ + Called from flow graph on mouse-over + """ + changed = not self._show_label + self._hovering = True + return changed + + def mouse_out(self): + """ + Called from flow graph on mouse-out + """ + label_was_shown = self._show_label + self._hovering = False + return label_was_shown != self._show_label diff --git a/grc/gui/external_editor.py b/grc/gui/external_editor.py new file mode 100644 index 0000000..522b6dc --- /dev/null +++ b/grc/gui/external_editor.py @@ -0,0 +1,74 @@ +""" +Copyright 2015 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import os +import sys +import time +import threading +import tempfile +import subprocess + + +class ExternalEditor(threading.Thread): + + def __init__(self, editor, name, value, callback): + threading.Thread.__init__(self) + self.daemon = True + self._stop_event = threading.Event() + + self.editor = editor + self.callback = callback + self.filename = self._create_tempfile(name, value) + + def _create_tempfile(self, name, value): + with tempfile.NamedTemporaryFile( + mode='wb', prefix=name + '_', suffix='.py', delete=False, + ) as fp: + fp.write(value.encode('utf-8')) + return fp.name + + def open_editor(self): + proc = subprocess.Popen(args=(self.editor, self.filename)) + proc.poll() + return proc + + def stop(self): + self._stop_event.set() + + def run(self): + filename = self.filename + # print "file monitor: started for", filename + last_change = os.path.getmtime(filename) + try: + while not self._stop_event.is_set(): + mtime = os.path.getmtime(filename) + if mtime > last_change: + # print "file monitor: reload trigger for", filename + last_change = mtime + with open(filename, 'rb') as fp: + data = fp.read().decode('utf-8') + self.callback(data) + time.sleep(1) + + except Exception as e: + print("file monitor crashed:", str(e), file=sys.stderr) + finally: + try: + os.remove(self.filename) + except OSError: + pass + + +if __name__ == '__main__': + e = ExternalEditor('/usr/bin/gedit', "test", "content", print) + e.open_editor() + e.start() + time.sleep(15) + e.stop() + e.join() diff --git a/grc/gui/icon.png b/grc/gui/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1c27323696ba0cfbeead115be9156101b428a448 GIT binary patch literal 4377 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
*XuquBsMcBU@We*p3e1nHGw%Kkd|7Tx; z(&F+5_%4=xgz;FFE=MQ3ykmD9<`*2yhXOaD-0QlHRBpwnTGkc%m*ajFw1e^(>bZfI z&ACrqgng&9!;w#--e0inqYYNovtV@yXujvgBoKWLmFLlh7l>|J@skq68~33h@gFEd zaW_}BwUQ`D5w+hjSXI+*Ga>(U;G!bb)CPPFtOs@hdH{_@7-%#RjW-#jstv3ZTpt1l zSD~*mZ~7qPhadcYLLt z$80=SK5CAAfY*C8u%ojF7ULw2Hc-_HSddykstqRtZ|@)rUT5s=@wDnngHWw>g+C4G zBS5%JPg9V)uwvs4I<*cKK-hNzDlNbvrE4h`AdD=^ry>JZl_}^Ok1!UbM-hQqf^sXC z{gA<>Ucvg&g9Xk 0 else 1.0 +DPI_SCALING = 1.0 # todo: figure out the GTK3 way (maybe cairo does this for us + +''' +# Gtk-themes classified as dark +GTK_DARK_THEMES = [ + 'Adwaita-dark', + 'HighContrastInverse', +] + +GTK_SETTINGS_INI_PATH = '~/.config/gtk-3.0/settings.ini' + +GTK_INI_PREFER_DARK_KEY = 'gtk-application-prefer-dark-theme' +GTK_INI_THEME_NAME_KEY = 'gtk-theme-name' +''' + + +def update_font_size(font_size): + global PORT_SEPARATION, BLOCK_FONT, PORT_FONT, PARAM_FONT, FONT_SIZE + + FONT_SIZE = font_size + BLOCK_FONT = "%s %f" % (FONT_FAMILY, font_size) + PORT_FONT = BLOCK_FONT + PARAM_FONT = "%s %f" % (FONT_FAMILY, font_size - 0.5) + + PORT_SEPARATION = PORT_SPACING + 2 * PORT_LABEL_PADDING + int(1.5 * font_size) + PORT_SEPARATION += -PORT_SEPARATION % (2 * CANVAS_GRID_SIZE) # even multiple + + +update_font_size(DEFAULT_FONT_SIZE) diff --git a/grc/gui_qt/Platform.py b/grc/gui_qt/Platform.py new file mode 100644 index 0000000..89408c8 --- /dev/null +++ b/grc/gui_qt/Platform.py @@ -0,0 +1,69 @@ +# TODO: This file is a modified copy of the old gui/Platform.py +""" +Copyright 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import sys +import os +from collections import ChainMap + +from .Config import Config +from .components.canvas.flowgraph import Flowgraph +from .components.canvas.block import Block +from .components.canvas.port import Port +from .components.canvas.connection import Connection +from ..core.platform import Platform as CorePlatform + + +class Platform(CorePlatform): + + def __init__(self, *args, **kwargs): + CorePlatform.__init__(self, *args, **kwargs) + + # Ensure conf directories + gui_prefs_file = self.config.gui_prefs_file + if not os.path.exists(os.path.dirname(gui_prefs_file)): + os.mkdir(os.path.dirname(gui_prefs_file)) + + self._move_old_pref_file() + + def get_prefs_file(self): + return self.config.gui_prefs_file + + def _move_old_pref_file(self): + gui_prefs_file = self.config.gui_prefs_file + old_gui_prefs_file = os.environ.get( + 'GRC_PREFS_PATH', os.path.expanduser('~/.grc')) + if gui_prefs_file == old_gui_prefs_file: + return # prefs file overridden with env var + if os.path.exists(old_gui_prefs_file) and not os.path.exists(gui_prefs_file): + try: + import shutil + shutil.move(old_gui_prefs_file, gui_prefs_file) + except Exception as e: + print(e, file=sys.stderr) + + ############################################## + # Factories + ############################################## + Config = Config + FlowGraph = Flowgraph + Connection = Connection + + def new_block_class(self, **data): + cls = CorePlatform.new_block_class(self, **data) + return Block.make_cls_with_base(cls) + + block_classes_build_in = {key: Block.make_cls_with_base(cls) + for key, cls in CorePlatform.block_classes_build_in.items()} + block_classes = ChainMap({}, block_classes_build_in) + + port_classes = {key: Port.make_cls_with_base(cls) + for key, cls in CorePlatform.port_classes.items()} + # param_classes = {key: Param.make_cls_with_base(cls) + # for key, cls in CorePlatform.param_classes.items()} diff --git a/grc/gui_qt/Utils.py b/grc/gui_qt/Utils.py new file mode 100644 index 0000000..c575498 --- /dev/null +++ b/grc/gui_qt/Utils.py @@ -0,0 +1,121 @@ +import numbers +import os +import logging +from pathlib import Path + +from qtpy import QtGui, QtCore +from qtpy.QtGui import QPageLayout +from qtpy.QtPrintSupport import QPrinter + +from . import Constants + +log = logging.getLogger(f"grc.application.{__name__}") + + +def get_rotated_coordinate(coor, rotation): + """ + Rotate the coordinate by the given rotation. + Args: + coor: the coordinate x, y tuple + rotation: the angle in degrees + Returns: + the rotated coordinates + """ + # handles negative angles + rotation = (rotation + 360) % 360 + if rotation not in Constants.POSSIBLE_ROTATIONS: + raise ValueError('unusable rotation angle "%s"' % str(rotation)) + # determine the number of degrees to rotate + cos_r, sin_r = { + 0: (1, 0), + 90: (0, 1), + 180: (-1, 0), + 270: (0, -1), + }[rotation] + x, y = coor + return x * cos_r + y * sin_r, -x * sin_r + y * cos_r + + +def num_to_str(num): + """ Display logic for numbers """ + def eng_notation(value, fmt='g'): + """Convert a number to a string in engineering notation. E.g., 5e-9 -> 5n""" + template = '{:' + fmt + '}{}' + magnitude = abs(value) + for exp, symbol in zip(range(9, -15 - 1, -3), 'GMk munpf'): + factor = 10 ** exp + if magnitude >= factor: + return template.format(value / factor, symbol.strip()) + return template.format(value, '') + + if isinstance(num, numbers.Complex): + num = complex(num) # Cast to python complex + if num == 0: + return '0' + output = eng_notation(num.real) if num.real else '' + output += eng_notation(num.imag, '+g' if output else 'g') + \ + 'j' if num.imag else '' + return output + else: + return str(num) + + +_nproc = None + + +def get_cmake_nproc(): + """ Get number of cmake processes for C++ flowgraphs """ + global _nproc # Cached result + if _nproc: + return _nproc + try: + # See https://docs.python.org/3.8/library/os.html#os.cpu_count + _nproc = len(os.sched_getaffinity(0)) + except: + _nproc = os.cpu_count() + if not _nproc: + _nproc = 1 + + _nproc = max(_nproc // 2 - 1, 1) + return _nproc + + +def make_screenshot(fg_view, file_path, transparent_bg=False): + if not file_path: + return + file_path = Path(file_path) + + target_rect = fg_view.scene().sceneRect() # Float + source_rect = target_rect.toRect() # Converted to int + + if file_path.suffix == ".png": + + pixmap = QtGui.QPixmap(source_rect.size()) + painter = QtGui.QPainter(pixmap) + + fg_view.render(painter, target_rect, source_rect) + pixmap.save(str(file_path), "PNG") + painter.end() + elif file_path.suffix == ".svg": + try: + from qtpy import QtSvg + except ImportError: + log.error("Missing (Python-)QtSvg! Please install it or export as PNG instead.") + return + + generator = QtSvg.QSvgGenerator() + generator.setFileName(str(file_path)) + painter = QtGui.QPainter(generator) + fg_view.render(painter, target_rect, source_rect) + painter.end() + elif file_path.suffix == ".pdf": + pdf_printer = QPrinter(QPrinter.PrinterMode.ScreenResolution) + pdf_printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) + page_layout = QPageLayout() + page_layout.setOrientation(QPageLayout.Landscape) + pdf_printer.setPageLayout(page_layout) + pdf_printer.setOutputFileName(str(file_path)) + painter = QtGui.QPainter(pdf_printer) + fg_view.render(painter, pdf_printer.pageRect(QPrinter.Unit.DevicePixel), source_rect) + painter.end() + return diff --git a/grc/gui_qt/__init__.py b/grc/gui_qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grc/gui_qt/base.py b/grc/gui_qt/base.py new file mode 100644 index 0000000..0b6c778 --- /dev/null +++ b/grc/gui_qt/base.py @@ -0,0 +1,147 @@ +# Copyright 2014-2020 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from __future__ import absolute_import, print_function + +# Standard modules +import abc +import logging +import weakref + +# Third-party modules +from qtpy import QtWidgets + + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +class Component(object): + ''' Abstract base class for all grc components. ''' + __metaclass__ = abc.ABCMeta + + def __init__(self): + ''' + Initializes the component's base class. + Sets up the references to the QApplication and the GNU Radio platform. + Calls createActions() to initialize the component's actions. + ''' + log.debug("Initializing {}".format(self.__class__.__name__)) + + # Application reference - Use weak references to avoid issues with circular references + # Platform and settings properties are accessed through this reference + self._app = weakref.ref(QtWidgets.QApplication.instance()) + + # Automatically create the actions, menus and toolbars. + # Child controllers need to call the register functions to integrate into the mainwindow + self.actions = {} + self.menus = {} + self.toolbars = {} + ''' + self.createActions(self.actions) + self.createMenus(self.actions, self.menus) + self.createToolbars(self.actions, self.toolbars) + ''' + + log.debug("Connecting signals") + self.connectSlots() + + # Properties + + @property + def app(self): + return self._app() + + @property + def settings(self): + return self._app().settings + + @property + def platform(self): + return self._app().platform + + # Required methods + + @abc.abstractmethod + def createActions(self, actions): + ''' Add actions to the component. ''' + raise NotImplementedError() + + @abc.abstractmethod + def createMenus(self, actions, menus): + ''' Add menus to the component. ''' + raise NotImplementedError() + + @abc.abstractmethod + def createToolbars(self, actions, toolbars): + ''' Add toolbars to the component. ''' + raise NotImplementedError() + + # Base methods + + def connectSlots(self, useToggled=True, toggledHandler='_toggled', + triggeredHandler="_triggered"): + ''' + Handles connecting signals from given actions to handlers + self - Calling class + actions - Dictionary of a QAction and unique key + + Dynamically build the connections for the signals by finding the correct + function to handle an action. Default behavior is to connect checkable actions to + the 'toggled' signal and normal actions to the 'triggered' signal. If 'toggled' is + not avaliable or useToggled is set to False then try to connect it to triggered. + Both toggled and triggered are called for checkable items, so there is no need for + both to be connected. + + void QAction::toggled ( bool checked ) [signal] + void QAction::triggered ( bool checked = false ) [signal] + - Checked is set for checkable actions + + Essentially the same as QMetaObject::connectSlotsByName, except the actions + and slots can be separated into a view and controller class + + ''' + actions = self.actions + for key in actions: + if useToggled and actions[key].isCheckable(): + # Try to use toggled rather than triggered + try: + handler = key + toggledHandler + actions[key].toggled.connect(getattr(self, handler)) + log.debug("<{0}.toggled> connected to handler <{1}>".format(key, handler)) + # Successful connection. Jump to the next action. + continue + except: + # Default to the triggered handler + log.warning("Could not connect <{0}.toggled> to handler <{1}>".format(key, handler)) + + # Try and bind the 'triggered' signal to a handler. + try: + handler = key + triggeredHandler + actions[key].triggered.connect(getattr(self, handler)) + log.debug("<{0}.triggered> connected to handler <{1}>".format(key, handler)) + except: + try: + log.warning("Handler not implemented for <{0}.triggered> in {1}".format( + key, type(self).__name__)) + actions[key].triggered.connect(getattr(self, 'notImplemented')) + except: + # This should never happen + log.error("Class cannot handle <{0}.triggered>".format(key)) + + def notImplemented(self): + log.warning('Not implemented') diff --git a/grc/gui_qt/components/__init__.py b/grc/gui_qt/components/__init__.py new file mode 100644 index 0000000..611751b --- /dev/null +++ b/grc/gui_qt/components/__init__.py @@ -0,0 +1,11 @@ + +from .block_library import BlockLibrary +from .example_browser import ExampleBrowser, Worker +from .wiki_tab import WikiTab +from .console import Console +from .flowgraph_view import FlowgraphView +from .undoable_actions import ChangeStateAction +from .variable_editor import VariableEditor + +# Import last since there are dependencies +from .window import MainWindow diff --git a/grc/gui_qt/components/block_library.py b/grc/gui_qt/components/block_library.py new file mode 100644 index 0000000..2975077 --- /dev/null +++ b/grc/gui_qt/components/block_library.py @@ -0,0 +1,358 @@ +# Copyright 2014-2020 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + + +# Standard modules +import logging + +from qtpy.QtCore import QUrl, Qt, QVariant +from qtpy.QtWidgets import (QLineEdit, QTreeView, QMenu, QDockWidget, QWidget, + QAction, QVBoxLayout, QAbstractItemView, QCompleter, QToolButton) +from qtpy.QtGui import QStandardItem, QStandardItemModel + +# Custom modules +from .. import base +from .canvas.block import Block + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +class BlockSearchBar(QLineEdit): + def __init__(self, parent): + QLineEdit.__init__(self) + QLineEdit.setClearButtonEnabled(self, True) + self.parent = parent + self.setObjectName("block_library::search_bar") + # We must use a queued connection here, otherwise + # the searchbar entry will not be deleted if we selected an item from + # the popup window by key_down and key_enter + self.returnPressed.connect(self.on_return_pressed, Qt.QueuedConnection) + # Find clear Button inside QLineEdit + clr_btn = self.findChildren(QToolButton) + if len(clr_btn) > 0: + clr_btn[0].triggered.connect(self.clr) + + def clr(self): + self.parent.reset() + + def first_block_in_results(self): + model = self.completer().completionModel() + index = model.index(0, 0) + return model.data(index) + + def on_return_pressed(self): + block_key = None + label = self.text() + if label in self.parent._block_tree_flat: + block_key = self.parent._block_tree_flat[label].key + else: + label = self.first_block_in_results() + if label in self.parent._block_tree_flat: + block_key = self.parent._block_tree_flat[label].key + if block_key: + self.parent.add_block(block_key) + else: + log.info(f"No block named {label}") + self.clr() + + +def get_items(model): + items = [] + for i in range(0, model.rowCount()): + index = model.index(i, 0) + items.append(model.data(index)) + return items + + +class LibraryView(QTreeView): + def __init__(self, parent): + QTreeView.__init__(self, parent) + self.library = parent.parent() + self.contextMenu = QMenu() + self.example_action = QAction("Examples...") + self.add_to_fg_action = QAction("Add to flowgraph") + self.example_action.triggered.connect(self.view_examples) + self.add_to_fg_action.triggered.connect(self.add_block) + self.contextMenu.addAction(self.example_action) + self.contextMenu.addAction(self.add_to_fg_action) + + # TODO: Use selectionChanged() or something instead + # so we can use arrow keys too + def updateDocTab(self): + label = self.model().data(self.currentIndex()) + if label in self.parent().parent()._block_tree_flat: + prefix = str( + self.parent().parent().platform.config.wiki_block_docs_url_prefix + ) + self.parent().parent().app.WikiTab.setURL( + QUrl(prefix + label.replace(" ", "_")) + ) + + def contextMenuEvent(self, event): + key = self.model().data(self.currentIndex(), Qt.UserRole) + if key: # Modules and categories don't have UserRole data + self.contextMenu.exec_(self.mapToGlobal(event.pos())) + + def view_examples(self): + key = self.model().data(self.currentIndex(), Qt.UserRole) + self.library.app.MainWindow.example_browser_triggered(key_filter=key) + + def add_block(self) -> None: + key = self.model().data(self.currentIndex(), Qt.UserRole) + self.library.add_block(key) + + +class BlockLibrary(QDockWidget, base.Component): + def __init__(self): + super(BlockLibrary, self).__init__() + + self.qsettings = self.app.qsettings + self.blocklibrary_expanded = self.qsettings.value('grc/blocklibrary_expanded', type=bool) + + self.setObjectName("block_library") + self.setWindowTitle("Block Library") + + # TODO: Pull from preferences and revert to default if not found? + self.resize(400, 300) + self.setFloating(False) + + # GUI Widgets + + # Create the layout widget + container = QWidget(self) + container.setObjectName("block_library::container") + self._container = container + + layout = QVBoxLayout(container) + layout.setObjectName("block_library::layout") + layout.setSpacing(0) + layout.setContentsMargins(5, 0, 5, 5) + self._layout = layout + + # Setup the model for holding block data + self._model = QStandardItemModel() + + library = LibraryView(container) + library.setObjectName("block_library::library") + library.setModel(self._model) + library.setDragEnabled(True) + library.setDragDropMode(QAbstractItemView.DragOnly) + # library.setColumnCount(1) + library.setHeaderHidden(True) + # Expand categories with a single click is build in + library.selectionModel().selectionChanged.connect(library.updateDocTab) + # library.headerItem().setText(0, "Blocks") + library.doubleClicked.connect( + lambda block: self.add_block(block.data(Qt.UserRole)) + ) + self._library = library + + search_bar = BlockSearchBar(self) + search_bar.setPlaceholderText("Find a block") + self._search_bar = search_bar + + # Add widgets to the component + layout.addWidget(search_bar) + layout.addSpacing(5) + layout.addWidget(library) + container.setLayout(layout) + self.setWidget(container) + + # Translation support + + # self.setWindowTitle(_translate("blockLibraryDock", "Library", None)) + # library.headerItem().setText(0, _translate("blockLibraryDock", "Blocks", None)) + # QMetaObject.connectSlotsByName(blockLibraryDock) + + # Loading blocks + + # Keep as a separate function so it can be called at a later point (Reloading blocks) + self._block_tree_flat = {} + self.load_blocks() + self.populate_tree(self._block_tree) + + completer = QCompleter(self._block_tree_flat.keys()) + completer.setCompletionMode(QCompleter.PopupCompletion) + completer.setModelSorting(QCompleter.CaseInsensitivelySortedModel) + completer.setCaseSensitivity(Qt.CaseInsensitive) + completer.setFilterMode(Qt.MatchContains) + self._search_bar.setCompleter(completer) + self._search_bar.textChanged.connect(self.show_search_results) + + # TODO: Move to the base controller and set actions as class attributes + # Automatically create the actions, menus and toolbars. + # Child controllers need to call the register functions to integrate into the mainwindow + self.actions = {} + self.menus = {} + self.toolbars = {} + self.createActions(self.actions) + self.createMenus(self.actions, self.menus) + self.createToolbars(self.actions, self.toolbars) + self.connectSlots() + + # Register the dock widget through the AppController. + # The AppController then tries to find a saved dock location from the preferences + # before calling the MainWindow Controller to add the widget. + self.app.registerDockWidget( + self, location=self.settings.window.BLOCK_LIBRARY_DOCK_LOCATION + ) + + # Register the menus + # self.app.registerMenu(self.menus["library"]) + + # Dict representing which examples contain various blocks + self.examples_w_block = {} + + if not self.qsettings.value("appearance/display_blocklibrary", True, type=bool): + self.hide() + + def createActions(self, actions): + pass + + def createMenus(self, actions, menus): + pass + + def createToolbars(self, actions, toolbars): + pass + + def show_search_results(self): + if self._search_bar.text() == '': + self.populate_tree(self._block_tree) + else: + self.populate_tree(self._block_tree, get_items(self._search_bar.completer().completionModel())) + + def reset(self): + """Reset the filter (show all blocks in the tree view)""" + self._search_bar.clear() + self.populate_tree(self._block_tree) + + def load_blocks(self): + """Load the block tree from the platform and populate the widget.""" + # Loop through all of the blocks and create the nested hierarchy (this can be unlimited nesting) + # This takes advantage of Python's use of references to move through the nested layers + + log.info("Loading blocks") + block_tree = {} + for block in self.platform.blocks.values(): + if block.category: + # Blocks with None category should be left out for whatever reason (e.g. not installed) + # print(block.category) # in list form, e.g. ['Core', 'Digital Television', 'ATSC'] + # print(block.label) # label GRC uses to name block + # print(block.key) # actual block name (i.e. class name) + + # Create a copy of the category list so things can be removed without changing the original list + category = block.category[:] + + # Get a reference to the main block tree. + # As nested categories are added, this is updated to point to the proper sub-tree in the next layer + sub_tree = block_tree + while category: + current = category.pop(0) + if current not in sub_tree.keys(): + # Create the new sub-tree + sub_tree[current] = {} + # Move to the next layer in the block tree + sub_tree = sub_tree[current] + # Sub_tree should now point at the final node of the block_tree that contains the block + # Add a reference to the block object to the proper subtree + sub_tree[block.label] = block + self._block_tree_flat[block.label] = block + # Save a reference to the block tree in case it is needed later + self._block_tree = block_tree + + def reload_blocks(self): + self.platform.build_library() + self.load_blocks() + self.populate_tree(self._block_tree) + + def add_block(self, block_key: str): + """Add a block by its key.""" + if block_key is None: + return + + scene = self.app.MainWindow.currentFlowgraphScene + view = self.app.MainWindow.currentView + pos_ = view.mapToScene(view.viewport().rect().center()) + scene.add_block(block_key, pos=(pos_.x(), pos_.y())) + + def populate_tree(self, block_tree, v_blocks=None): + """Populate the item model and tree view with the hierarchical block tree.""" + # Recursive method of populating the QStandardItemModel + # Since the _model.invisibleRootItem is the initial parent, this will populate + # the model which is used for the TreeView. + self._model.removeRows(0, self._model.rowCount()) + + def _populate(blocks, parent): + found = False + for name, obj in sorted(blocks.items()): + child_item = QStandardItem() + child_item.setEditable(False) + if type(obj) is dict: # It's a category + child_item.setText(name) + child_item.setDragEnabled( + False + ) # categories should not be draggable + if not _populate(obj, child_item): + continue + else: + found = True + else: # It's a block + if v_blocks and not name in v_blocks: + continue + else: + found = True + child_item.setText(obj.label) + child_item.setDragEnabled(True) + child_item.setSelectable(True) + child_item.setData( + QVariant(obj.key), + role=Qt.UserRole, + ) + parent.appendRow(child_item) + return found + + # Call the nested function recursively to populate the block tree + log.debug("Populating the treeview") + _populate(block_tree, self._model.invisibleRootItem()) + if self.blocklibrary_expanded: + for row in range(0, self._model.rowCount()): + self._library.expand(self._model.item(row, 0).index()) + else: + self._library.expand( + self._model.item(0, 0).index() + ) + if v_blocks: + self._library.expandAll() + + def populate_w_examples(self, examples_w_block: dict[str, set[str]], designated_examples_w_block: dict[str, set[str]]): + """ + Store the examples in the block library. + See the ExampleBrowser for more info. + """ + self.examples_w_block = examples_w_block + self.designated_examples_w_block = designated_examples_w_block + + def get_examples(self, block_key: str) -> list[Block]: + """Get the example flowgraphs that contain a certain block""" + try: + return self.designated_examples_w_block[block_key] + except: + if block_key in self.examples_w_block: + return self.examples_w_block[block_key] + else: + return [] diff --git a/grc/gui_qt/components/canvas/__init__.py b/grc/gui_qt/components/canvas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grc/gui_qt/components/canvas/block.py b/grc/gui_qt/components/canvas/block.py new file mode 100755 index 0000000..50dd513 --- /dev/null +++ b/grc/gui_qt/components/canvas/block.py @@ -0,0 +1,429 @@ +import logging + +from qtpy.QtGui import QPen, QPainter, QBrush, QFont, QFontMetrics +from qtpy.QtCore import Qt, QPointF, QRectF, QUrl +from qtpy.QtWidgets import QGraphicsItem, QApplication, QAction + +from . import colors +from ... import Constants +from ... import Utils +from ....core.blocks.block import Block as CoreBlock +from ....core.utils import flow_graph_complexity + +from ..dialogs import PropsDialog + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + +LONG_VALUE = 20 # maximum length of a param string. +# if exceeded, '...' will be displayed + + +class Block(CoreBlock): + """ + A block. Accesses its graphical representation with self.gui. + """ + @classmethod + def make_cls_with_base(cls, super_cls): + name = super_cls.__name__ + bases = (super_cls,) + cls.__bases__[:-1] + namespace = cls.__dict__.copy() + return type(name, bases, namespace) + + def __init__(self, parent, **n): + super(self.__class__, self).__init__(parent, **n) + + self.width = 50 # will change immediately after the first paint + self.block_label = self.key + + self.old_data = None + + if "rotation" not in self.states.keys(): + self.states["rotation"] = 0.0 + + self.gui = GUIBlock(self, parent) + + def import_data(self, name, states, parameters, **_): + super(self.__class__, self).import_data(name, states, parameters, **_) + self.gui.setPos(*self.states["coordinate"]) + self.gui.setRotation(self.states["rotation"]) + self.rewrite() + self.gui.create_shapes_and_labels() + + def update_bus_logic(self): + for direc in {'source', 'sink'}: + if direc == 'source': + ports = self.sources + ports_gui = self.filter_bus_port(self.sources) + else: + ports = self.sinks + ports_gui = self.filter_bus_port(self.sinks) + if 'bus' in map(lambda a: a.dtype, ports): + for port in ports_gui: + self.parent_flowgraph.gui.removeItem(port.gui) + super(self.__class__, self).update_bus_logic() + + +class GUIBlock(QGraphicsItem): + """ + The graphical representation of a block. Accesses the actual block with self.core. + """ + + def __init__(self, core, parent, **n): + super(GUIBlock, self).__init__() + self.core = core + self.parent = self.scene() + self.font = QFont("Helvetica", 10) + + self.create_shapes_and_labels() + + if "coordinate" not in self.core.states.keys(): + self.core.states["coordinate"] = (500, 300) + self.setPos(*self.core.states["coordinate"]) + + self.old_pos = (self.x(), self.y()) + self.new_pos = (self.x(), self.y()) + self.moving = False + + self.props_dialog = None + self.right_click_menu = None + + self.setFlag(QGraphicsItem.ItemIsMovable) + self.setFlag(QGraphicsItem.ItemIsSelectable) + self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges) + + self.markups_height = 0.0 + self.markups_width = 0.0 + self.markups = [] + self.markup_text = "" + + def create_shapes_and_labels(self): + qsettings = QApplication.instance().qsettings + self.force_show_id = qsettings.value('grc/show_block_ids', type=bool) + self.hide_variables = qsettings.value('grc/hide_variables', type=bool) + self.hide_disabled_blocks = qsettings.value('grc/hide_disabled_blocks', type=bool) + self.snap_to_grid = qsettings.value('grc/snap_to_grid', type=bool) + self.show_complexity = qsettings.value('grc/show_complexity', type=bool) + self.show_block_comments = qsettings.value('grc/show_block_comments', type=bool) + self.show_param_expr = qsettings.value('grc/show_param_expr', type=bool) + self.show_param_val = qsettings.value('grc/show_param_val', type=bool) + self.prepareGeometryChange() + self.font.setBold(True) + + # figure out height of block based on how many params there are + i = 35 + + # Check if we need to increase the height to fit all the ports + for key, item in self.core.params.items(): + value = item.value + if (value is not None and item.hide == "none") or (item.dtype == 'id' and self.force_show_id): + i += 20 + + self.height = i + + def get_min_height_for_ports(ports): + min_height = ( + 2 * Constants.PORT_BORDER_SEPARATION + + len(ports) * Constants.PORT_SEPARATION + ) + # If any of the ports are bus ports - make the min height larger + if any([p.dtype == "bus" for p in ports]): + min_height = ( + 2 * Constants.PORT_BORDER_SEPARATION + + sum( + port.gui.height + Constants.PORT_SPACING + for port in ports + if port.dtype == "bus" + ) - Constants.PORT_SPACING + ) + + else: + if ports: + min_height -= ports[-1].gui.height + return min_height + + self.height = max( + self.height, + get_min_height_for_ports(self.core.active_sinks), + get_min_height_for_ports(self.core.active_sources), + ) + + # Figure out width of block based on widest line of text + fm = QFontMetrics(self.font) + largest_width = fm.width(self.core.label) + for key, item in self.core.params.items(): + name = item.name + value = item.value + if (value is not None and item.hide == "none") or (item.dtype == 'id' and self.force_show_id): + if len(value) > LONG_VALUE: + value = value[:LONG_VALUE - 3] + '...' + + value_label = item.options[value] if value in item.options else value + full_line = name + ": " + value_label + + if fm.width(full_line) > largest_width: + largest_width = fm.width(full_line) + self.width = largest_width + 15 + + self.markups = [] + self.markup_text = "" + self.markups_width = 0.0 + + if self.show_complexity and self.core.key == "options": + complexity = flow_graph_complexity.calculate(self.parent) + self.markups.append('Complexity: {num} bal'.format( + num=Utils.num_to_str(complexity))) + + if self.show_block_comments and self.core.comment: + self.markups.append(self.core.comment) + + self.markup_text = "\n".join(self.markups).strip() + + self.markups_height = fm.height() * (self.markup_text.count("\n") + 1) + for line in self.markup_text.split("\n"): + if fm.width(line) > self.markups_width: + self.markups_width = fm.width(line) + + # Update the position and size of all the ports + bussified = ( + self.core.current_bus_structure["source"], + self.core.current_bus_structure["sink"], + ) + for ports, has_busses in zip( + (self.core.active_sources, self.core.active_sinks), bussified + ): + if not ports: + continue + port_separation = ( + Constants.PORT_SEPARATION + if not has_busses + else ports[0].gui.height + Constants.PORT_SPACING + ) + offset = ( + self.height - (len(ports) - 1) * port_separation - ports[0].gui.height + ) / 2 + for port in ports: + if port._dir == "sink": + port.gui.setPos(-15, offset) + else: + port.gui.setPos(self.width, offset) + port.gui.create_shapes_and_labels() + + offset += ( + Constants.PORT_SEPARATION + if not has_busses + else port.gui.height + Constants.PORT_SPACING + ) + + self._update_colors() + self.create_port_labels() + self.setTransformOriginPoint(self.width / 2, self.height / 2) + + def create_port_labels(self): + for ports in (self.core.active_sinks, self.core.active_sources): + for port in ports: + port.gui.create_shapes_and_labels() + + def _update_colors(self): + def get_bg(): + """ + Get the background color for this block + Explicit is better than a chain of if/else expressions, + so this was extracted into a nested function. + """ + if self.core.is_dummy_block: + return colors.MISSING_BLOCK_BACKGROUND_COLOR + if self.core.state == "bypassed": + return colors.BLOCK_BYPASSED_COLOR + if self.core.state == "enabled": + if self.core.deprecated: + return colors.BLOCK_DEPRECATED_BACKGROUND_COLOR + return colors.BLOCK_ENABLED_COLOR + return colors.BLOCK_DISABLED_COLOR + + self._bg_color = get_bg() + + def move(self, x, y): + self.moveBy(x, y) + self.core.states["coordinate"] = (self.x(), self.y()) + + def paint(self, painter, option, widget): + if (self.hide_variables and (self.core.is_variable or self.core.is_import)) or (self.hide_disabled_blocks and not self.core.enabled): + return + + painter.setRenderHint(QPainter.Antialiasing) + self.font.setBold(True) + + # TODO: Make sure this is correct + border_color = colors.BORDER_COLOR + + if self.isSelected(): + border_color = colors.HIGHLIGHT_COLOR + else: + if self.core.is_dummy_block: + border_color = colors.MISSING_BLOCK_BORDER_COLOR + elif self.core.deprecated: + border_color = colors.BLOCK_DEPRECATED_BORDER_COLOR + elif self.core.state == "disabled": + border_color = colors.BORDER_COLOR_DISABLED + + pen = QPen(1) + pen = QPen(border_color) + + pen.setWidth(3) + painter.setPen(pen) + painter.setBrush(QBrush(self._bg_color)) + rect = QRectF(0, 0, self.width, self.height) + + painter.drawRect(rect) + painter.setPen(QPen(1)) + + # Draw block label text + painter.setFont(self.font) + if self.core.is_valid(): + painter.setPen(Qt.black) + else: + painter.setPen(Qt.red) + + # Adjust the painter if parent block is 180 degrees rotated + if self.rotation() == 180: + painter.translate(self.width / 2, self.height / 2) + painter.rotate(180) + painter.translate(-self.width / 2, -self.height / 2) + + painter.drawText( + QRectF(0, 0 - self.height / 2 + 15, self.width, self.height), + Qt.AlignCenter, + self.core.label, + ) + + # Draw param text + y_offset = 30 # params start 30 down from the top of the box + for key, item in self.core.params.items(): + name = item.name + value = item.value + is_evaluated = item.value != str(item.get_evaluated()) + + display_value = "" + + # Include the value defined by the user (after evaluation) + if not is_evaluated or self.show_param_val or not self.show_param_expr: + display_value += item.options[value] if value in item.options else value # TODO: pretty_print + + # Include the expression that was evaluated to get the value + if is_evaluated and self.show_param_expr: + expr_string = value # TODO: Truncate + + if display_value: # We are already displaying the value + display_value = expr_string + "=" + display_value + else: + display_value = expr_string + + if len(display_value) > LONG_VALUE: + display_value = display_value[:LONG_VALUE - 3] + '...' + + value_label = display_value + if (value is not None and item.hide == "none") or (item.dtype == 'id' and self.force_show_id): + if item.is_valid(): + painter.setPen(QPen(1)) + else: + painter.setPen(Qt.red) + + self.font.setBold(True) + painter.setFont(self.font) + painter.drawText( + QRectF(7.5, 0 + y_offset, self.width, self.height), + Qt.AlignLeft, + name + ": ", + ) + fm = QFontMetrics(self.font) + self.font.setBold(False) + painter.setFont(self.font) + painter.drawText( + QRectF( + 7.5 + fm.width(name + ": "), + 0 + y_offset, + self.width, + self.height, + ), + Qt.AlignLeft, + value_label, + ) + y_offset += 20 + + if self.markup_text: + painter.setPen(Qt.gray) + painter.drawText( + QRectF(0, self.height + 5, self.markups_width, self.markups_height), + Qt.AlignLeft, + self.markup_text, + ) + + def boundingRect(self): + # TODO: Comments should be a separate QGraphicsItem + return QRectF( + -2.5, -2.5, self.width + 5 + self.markups_width, self.height + 5 + self.markups_height + ) + + def set_states(self, states): + for k, v in states.items(): + self.core.states[k] = v + + self.setPos(*self.core.states["coordinate"]) + self.setRotation(self.core.states["rotation"]) + + def mousePressEvent(self, e): + super(self.__class__, self).mousePressEvent(e) + log.debug(f"{self} clicked") + url_prefix = str(self.core.parent_platform.config.wiki_block_docs_url_prefix) + QApplication.instance().WikiTab.setURL(QUrl(url_prefix + self.core.label.replace(" ", "_"))) + + self.moveToTop() + + def contextMenuEvent(self, e): + if not self.isSelected(): + self.scene().clearSelection() + self.setSelected(True) + + self.right_click_menu = self.scene()._app().MainWindow.menus["edit"] + example_action = QAction("Examples...") + example_action.triggered.connect(self.view_examples) + self.right_click_menu.addAction(example_action) + self.right_click_menu.exec_(e.screenPos()) + + def view_examples(self): + self.scene().app.MainWindow.example_browser_triggered(key_filter=self.core.key) + + def mouseDoubleClickEvent(self, e): + self.open_properties() + super(self.__class__, self).mouseDoubleClickEvent(e) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemPositionChange and self.scene() and self.snap_to_grid: + grid_size = 10 + value.setX(round(value.x() / grid_size) * grid_size) + value.setY(round(value.y() / grid_size) * grid_size) + return value + else: + return QGraphicsItem.itemChange(self, change, value) + + def rotate(self, rotation): + log.debug(f"Rotating {self.core.name}") + new_rotation = (self.rotation() + rotation) % 360 + self.setRotation(new_rotation) + self.core.states["rotation"] = new_rotation + self.create_shapes_and_labels() + for con in self.core.connections(): + con.gui.set_rotation(new_rotation) + con.gui.update() + + def moveToTop(self): + # TODO: Is there a simpler way to do this? + self.setZValue(self.scene().getMaxZValue() + 1) + + def center(self): + return QPointF(self.x() + self.width / 2, self.y() + self.height / 2) + + def open_properties(self): + self.props_dialog = PropsDialog(self.core, self.force_show_id) + self.props_dialog.show() diff --git a/grc/gui_qt/components/canvas/colors.py b/grc/gui_qt/components/canvas/colors.py new file mode 100755 index 0000000..3b3e33c --- /dev/null +++ b/grc/gui_qt/components/canvas/colors.py @@ -0,0 +1,54 @@ +# TODO: This file is a modified copy of the old gui/Platform.py +""" +Copyright 2008,2013 Free Software Foundation, Inc. +This file is part of GNU Radio +SPDX-License-Identifier: GPL-2.0-or-later +""" + + +from qtpy import QtGui + +from ... import Constants + + +def get_color(rgb): + color = QtGui.QColor(rgb) + return color + + +################################################################################# +# fg colors +################################################################################# +HIGHLIGHT_COLOR = get_color('#00FFFF') +BORDER_COLOR = get_color('#616161') +BORDER_COLOR_DISABLED = get_color('#888888') +FONT_COLOR = get_color('#000000') + +# Deprecated blocks +# a light warm yellow +BLOCK_DEPRECATED_BACKGROUND_COLOR = get_color('#FED86B') +# orange +BLOCK_DEPRECATED_BORDER_COLOR = get_color('#FF540B') + +# Missing blocks +MISSING_BLOCK_BACKGROUND_COLOR = get_color('#FFF2F2') +MISSING_BLOCK_BORDER_COLOR = get_color('#FF0000') + +# Block color constants +BLOCK_ENABLED_COLOR = get_color('#F1ECFF') +BLOCK_DISABLED_COLOR = get_color('#CCCCCC') +BLOCK_BYPASSED_COLOR = get_color('#F4FF81') + +CONNECTION_ENABLED_COLOR = get_color('#616161') +CONNECTION_DISABLED_COLOR = get_color('#BBBBBB') +CONNECTION_ERROR_COLOR = get_color('#FF0000') + +DARK_FLOWGRAPH_BACKGROUND_COLOR = get_color('#19232D') +LIGHT_FLOWGRAPH_BACKGROUND_COLOR = get_color('#FFFFFF') + + +################################################################################# +# port colors +################################################################################# +PORT_TYPE_TO_COLOR = {key: get_color(color) for name, key, sizeof, color in Constants.CORE_TYPES} +PORT_TYPE_TO_COLOR.update((key, get_color(color)) for key, (_, color) in Constants.ALIAS_TYPES.items()) diff --git a/grc/gui_qt/components/canvas/connection.py b/grc/gui_qt/components/canvas/connection.py new file mode 100755 index 0000000..c4a4d73 --- /dev/null +++ b/grc/gui_qt/components/canvas/connection.py @@ -0,0 +1,204 @@ +from qtpy.QtGui import QPainterPath, QPainter, QPen, QTransform +from qtpy.QtWidgets import QGraphicsPathItem, QApplication +from qtpy.QtCore import QPointF + +from enum import Enum + +from ....core.Connection import Connection as CoreConnection +from . import colors +from ...Constants import ( + CONNECTOR_ARROW_BASE, + CONNECTOR_ARROW_HEIGHT +) + + +class StyledConnection(): + """ + Styled connection; current styles: + - CURVED: cubic spline + - ANGLED: rectangular connection, one step in the horizontal middle + """ + class ConnectionStyle(Enum): + CURVED = 0 + ANGLED = 1 + + def __init__(self): + self._qsettings = QApplication.instance().qsettings + self._conn_style_str = self._qsettings.value("appearance/connection_style", "CURVED", type=str) + self._conn_style = self.ConnectionStyle[self._conn_style_str] + self._line_width_factor = self._qsettings.value("appearance/line_width_factor", 1.0, type=float) + + +class DummyConnection(QGraphicsPathItem, StyledConnection): + """ + Dummy connection used for when the user drags a connection + between two ports. + """ + + def __init__(self, parent, start_point, end_point): + super(DummyConnection, self).__init__() + super(StyledConnection, self).__init__() + + self.start_point = start_point + self.end_point = end_point + + self._line = QPainterPath() + self._arrowhead = QPainterPath() + self._path = QPainterPath() + self.update(end_point) + + self._color1 = self._color2 = None + + self._current_port_rotations = self._current_coordinates = None + + self._rel_points = None # connection coordinates relative to sink/source + self._arrow_rotation = 0.0 # rotation of the arrow in radians + self._current_cr = None # for what_is_selected() of curved line + self._line_path = None + self.setFlag(QGraphicsPathItem.ItemIsSelectable) + + def update(self, end_point): + """User moved the mouse, redraw with new end point""" + self.end_point = end_point + self._line.clear() + self._line.moveTo(self.start_point) + if self._conn_style == StyledConnection.ConnectionStyle.CURVED: + c1 = self.start_point + QPointF(200, 0) + c2 = self.end_point - QPointF(200, 0) + self._line.cubicTo(c1, c2, self.end_point) + elif self._conn_style == StyledConnection.ConnectionStyle.ANGLED: + y_start = self.start_point.y() + y_end = self.end_point.y() + delta_x = self.end_point.y() - self.start_point.x() + x_step = self.start_point.x() + delta_x / 2 + self._line.lineTo(QPointF(x_step, y_start)) + self._line.lineTo(QPointF(x_step, y_end)) + self._line.lineTo(self.end_point) + + self._arrowhead.clear() + self._arrowhead.moveTo(self.end_point) + self._arrowhead.lineTo(self.end_point + QPointF(-CONNECTOR_ARROW_HEIGHT, - CONNECTOR_ARROW_BASE / 2)) + self._arrowhead.lineTo(self.end_point + QPointF(-CONNECTOR_ARROW_HEIGHT, CONNECTOR_ARROW_BASE / 2)) + self._arrowhead.lineTo(self.end_point) + + self._path.clear() + self._path.addPath(self._line) + self._path.addPath(self._arrowhead) + self.setPath(self._path) + + def paint(self, painter, option, widget): + painter.setRenderHint(QPainter.Antialiasing) + + color = colors.BORDER_COLOR + + pen = QPen(color) + + pen.setWidthF(2 * self._line_width_factor) + painter.setPen(pen) + painter.drawPath(self._line) + painter.setBrush(color) + painter.drawPath(self._arrowhead) + + +class Connection(CoreConnection): + def __init__(self, parent, source, sink): + super(Connection, self).__init__(parent, source, sink) + self.gui = GUIConnection(self, parent, source, sink) + + +class GUIConnection(QGraphicsPathItem, StyledConnection): + def __init__(self, core, parent, source, sink): + super(StyledConnection, self).__init__() + self.core = core + super(QGraphicsPathItem, self).__init__() + + self.source = source + self.sink = sink + + self._line = QPainterPath() + self._arrowhead = QPainterPath() + self._path = QPainterPath() + + self._color1 = self._color2 = None + + self._current_port_rotations = self._current_coordinates = None + + self._rel_points = None # connection coordinates relative to sink/source + self._arrow_rotation = 0.0 # rotation of the arrow in radians + self._current_cr = None # for what_is_selected() of curved line + self._line_path = None + self.setFlag(QGraphicsPathItem.ItemIsSelectable) + self.update() + + def update(self): + """ + Source and sink moved in relation to each other, redraw with new end points + """ + self._line.clear() + self._line.moveTo(self.source.gui.connection_point) + + if self._conn_style == StyledConnection.ConnectionStyle.CURVED: + c1 = self.source.gui.ctrl_point + c2 = self.sink.gui.ctrl_point + self._line.cubicTo(c1, c2, self.sink.gui.connection_point) + elif self._conn_style == StyledConnection.ConnectionStyle.ANGLED: + start = self.source.gui.connection_point + end = self.sink.gui.connection_point + y_start = start.y() + y_end = end.y() + delta_x = end.x() - start.x() + x_step = start.x() + (delta_x / 2) + self._line.lineTo(QPointF(x_step, y_start)) + self._line.lineTo(QPointF(x_step, y_end)) + self._line.lineTo(end) + self._create_arrowhead() + + self._path.clear() + self._path.addPath(self._line) + self._path.addPath(self._arrowhead) + self.setPath(self._path) + + def set_rotation(self, angle): + self._arrow_rotation = angle + + def _create_arrowhead(self): + self._arrowhead.clear() + end_point = self.sink.gui.connection_point + + # Create a default arrow head path + default_arrowhead = QPainterPath() + default_arrowhead.moveTo(QPointF(10.0, 0)) + default_arrowhead.lineTo(QPointF(10.0, 0) + QPointF(-CONNECTOR_ARROW_HEIGHT, -CONNECTOR_ARROW_BASE / 2)) + default_arrowhead.lineTo(QPointF(10.0, 0) + QPointF(-CONNECTOR_ARROW_HEIGHT, CONNECTOR_ARROW_BASE / 2)) + default_arrowhead.lineTo(QPointF(10.0, 0)) + + # Rotate the default arrow head path based on _arrow_rotation + transform = QTransform() + transform.translate(end_point.x(), end_point.y()) + transform.rotate(self._arrow_rotation) + self._arrowhead = transform.map(default_arrowhead) + + def paint(self, painter, option, widget): + painter.setRenderHint(QPainter.Antialiasing) + + color = colors.CONNECTION_ENABLED_COLOR + if self.isSelected(): + color = colors.HIGHLIGHT_COLOR + elif not self.core.enabled: + color = colors.CONNECTION_DISABLED_COLOR + elif not self.core.is_valid(): + color = colors.CONNECTION_ERROR_COLOR + + pen = QPen(color) + + pen.setWidthF(2 * self._line_width_factor) + painter.setPen(pen) + painter.drawPath(self._line) + painter.setBrush(color) + painter.drawPath(self._arrowhead) + + def mouseDoubleClickEvent(self, e): + connection = self.core + self.core.parent.gui.set_saved(False) + connection.parent.connections.remove(connection) + self.scene().removeItem(self) diff --git a/grc/gui_qt/components/canvas/flowgraph.py b/grc/gui_qt/components/canvas/flowgraph.py new file mode 100644 index 0000000..de5e7d0 --- /dev/null +++ b/grc/gui_qt/components/canvas/flowgraph.py @@ -0,0 +1,539 @@ +# Copyright 2014-2020 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from __future__ import absolute_import, print_function + +# Standard modules +import logging +import functools + +from qtpy import QtGui, QtCore, QtWidgets, QT6 +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication + +from itertools import count + +# Custom modules +from ....core.base import Element +from .connection import DummyConnection +from .port import GUIPort +from ... import base +from ....core.FlowGraph import FlowGraph as CoreFlowgraph +from ... import Utils +from ...external_editor import ExternalEditor + +from .block import GUIBlock +from .connection import GUIConnection + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + +DEFAULT_MAX_X = 400 +DEFAULT_MAX_Y = 300 + + +class Flowgraph(CoreFlowgraph): + def __init__(self, gui, platform, *args, **kwargs): + self.gui = gui + CoreFlowgraph.__init__(self, platform) + + +class FlowgraphScene(QtWidgets.QGraphicsScene, base.Component): + itemMoved = QtCore.Signal([QtCore.QPointF]) + newElement = QtCore.Signal([Element]) + deleteElement = QtCore.Signal([Element]) + blockPropsChange = QtCore.Signal([Element]) + + def __init__(self, view, platform, *args, **kwargs): + self.core = Flowgraph(self, platform) + super(FlowgraphScene, self).__init__() + self.setParent(view) + self.view = view + self.isPanning = False + self.mousePressed = False + self.moving_blocks = False + + self.dummy_arrow = None + self.start_port = None + self.end_port = None + self._elements_to_draw = [] + self._external_updaters = {} + + self.qsettings = QApplication.instance().qsettings + + if QT6: + self.undoStack = QtGui.QUndoStack(self) + else: + self.undoStack = QtWidgets.QUndoStack(self) + self.undoAction = self.undoStack.createUndoAction(self, "Undo") + self.redoAction = self.undoStack.createRedoAction(self, "Redo") + + self.filename = None + + self.clickPos = None + + self.saved = False + self.save_allowed = True + + def set_saved(self, saved): + self.saved = saved + + def update(self): + """ + Call the top level rewrite and validate. + Call the top level create labels and shapes. + """ + self.core.rewrite() + self.core.validate() + for block in self.core.blocks: + block.gui.create_shapes_and_labels() + self.update_elements_to_draw() + self.app.VariableEditor.update_gui(self.core.blocks) + + def update_elements_to_draw(self): + # hide_disabled_blocks = Actions.TOGGLE_HIDE_DISABLED_BLOCKS.get_active() + hide_disabled_blocks = False + # hide_variables = Actions.TOGGLE_HIDE_VARIABLES.get_active() + hide_variables = False + + def draw_order(elem): + return elem.gui.isSelected(), elem.is_block, elem.enabled + + elements = sorted(self.core.get_elements(), key=draw_order) + del self._elements_to_draw[:] + + for element in elements: + if hide_disabled_blocks and not element.enabled: + continue # skip hidden disabled blocks and connections + if hide_variables and (element.is_variable or element.is_import): + continue # skip hidden disabled blocks and connections + self._elements_to_draw.append(element) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls: + event.setDropAction(Qt.CopyAction) + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls: + event.setDropAction(Qt.CopyAction) + event.accept() + else: + event.ignore() + + def decode_data(self, bytearray): + data = [] + item = {} + ds = QtCore.QDataStream(bytearray) + while not ds.atEnd(): + row = ds.readInt32() + column = ds.readInt32() + map_items = ds.readInt32() + for i in range(map_items): + key = ds.readInt32() + value = QtCore.QVariant() + ds >> value + item[Qt.ItemDataRole(key)] = value + data.append(item) + return data + + def _get_unique_id(self, base_id=""): + """ + Get a unique id starting with the base id. + + Args: + base_id: the id starts with this and appends a count + + Returns: + a unique id + """ + block_ids = set(b.name for b in self.core.blocks) + for index in count(): + block_id = "{}_{}".format(base_id, index) + if block_id not in block_ids: + break + return block_id + + def dropEvent(self, event): + QtWidgets.QGraphicsScene.dropEvent(self, event) + if event.mimeData().hasUrls: + data = event.mimeData() + if data.hasFormat("application/x-qabstractitemmodeldatalist"): + bytearray = data.data("application/x-qabstractitemmodeldatalist") + data_items = self.decode_data(bytearray) + + # Find block in tree so that we can pull out label + block_key = data_items[0][QtCore.Qt.UserRole].value() + + # Add block of this key at the cursor position + cursor_pos = event.scenePos() + pos = (cursor_pos.x(), cursor_pos.y()) + + self.add_block(block_key, pos) + + event.setDropAction(Qt.CopyAction) + event.accept() + else: + return QtGui.QStandardItemModel.dropMimeData( + self, data, action, row, column, parent + ) + else: + event.ignore() + + def add_block(self, block_key, pos=(0, 0)): + block = self.platform.blocks[block_key] + # Pull out its params (keep in mind we still havent added the dialog box that lets you change param values so this is more for show) + params = [] + for ( + p + ) in ( + block.parameters_data + ): # block.parameters_data is a list of dicts, one per param + if "label" in p: # for now let's just show it as long as it has a label + key = p["label"] + value = p.get("default", "") # just show default value for now + params.append((key, value)) + + id = self._get_unique_id(block_key) + + c_block = self.core.new_block(block_key) + g_block = c_block.gui + c_block.states["coordinate"] = pos + g_block.setPos(*pos) + c_block.params["id"].set_value(id) + self.addItem(g_block) + g_block.moveToTop() + self.update() + self.newElement.emit(c_block) + return id + + def selected_blocks(self) -> list[GUIBlock]: + blocks = [] + for item in self.selectedItems(): + if isinstance(item, GUIBlock): + blocks.append(item) + return blocks + + def selected_connections(self): + conns = [] + for item in self.selectedItems(): + if isinstance(item, GUIConnection): + conns.append(item) + return conns + + def delete_selected(self): + for item in self.selectedItems(): + self.remove_element(item) + + def select_all(self): + for block in self.core.blocks: + block.gui.setSelected(True) + for conn in self.core.connections: + conn.gui.setSelected(True) + + def rotate_selected(self, rotation): + """ + Rotate the selected blocks by multiples of 90 degrees. + Args: + rotation: the rotation in degrees + Returns: + true if changed, otherwise false. + """ + selected_blocks = self.selected_blocks() + if not any(selected_blocks): + return False + # initialize min and max coordinates + min_x, min_y = max_x, max_y = selected_blocks[0].x(), selected_blocks[0].y() + # rotate each selected block, and find min/max coordinate + for selected_block in selected_blocks: + selected_block.rotate(rotation) + # update the min/max coordinate + x, y = selected_block.x(), selected_block.y() + min_x, min_y = min(min_x, x), min(min_y, y) + max_x, max_y = max(max_x, x), max(max_y, y) + # calculate center point of selected blocks + ctr_x, ctr_y = (max_x + min_x) / 2, (max_y + min_y) / 2 + # rotate the blocks around the center point + for selected_block in selected_blocks: + x, y = selected_block.x(), selected_block.y() + x, y = Utils.get_rotated_coordinate((x - ctr_x, y - ctr_y), rotation) + selected_block.setPos(x + ctr_x, y + ctr_y) + return True + + def mousePressEvent(self, event): + g_item = self.itemAt(event.scenePos(), QtGui.QTransform()) + if not g_item: # Nothing selected + event.ignore() + return + + self.clickPos = event.scenePos() + self.moving_blocks = False + + if g_item and not isinstance(g_item, DummyConnection): + c_item = g_item.core + if c_item.is_block: + self.moving_blocks = True + elif c_item.is_port: + if c_item.is_source: + self.start_port = g_item + elif c_item.is_sink: + self.end_port = g_item + if event.button() == Qt.LeftButton: + self.mousePressed = True + super(FlowgraphScene, self).mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self.mousePressed: + if not self.dummy_arrow and not self.moving_blocks and self.start_port: + self.dummy_arrow = DummyConnection(self, self.start_port.connection_point, self.clickPos) + self.addItem(self.dummy_arrow) + self.view.setSceneRect(self.itemsBoundingRect()) + if self.dummy_arrow: + self.dummy_arrow.update(event.scenePos()) + + if self.mousePressed and self.isPanning: + new_pos = event.pos() + self.dragPos = new_pos + event.accept() + else: + super(FlowgraphScene, self).mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self.dummy_arrow: # We are currently dragging a DummyConnection + self.removeItem(self.dummy_arrow) + g_item = self.itemAt(event.scenePos(), QtGui.QTransform()) + if isinstance(g_item, GUIPort): + c_item = g_item.core + if g_item != self.start_port: + log.debug("Created connection (drag)") + new_con = self.core.connect(self.start_port.core, c_item) + self.addItem(new_con.gui) + self.newElement.emit(new_con) + self.update() + self.start_port = None + self.end_port = None + self.dummy_arrow = None + else: + if self.clickPos != event.scenePos() and self.moving_blocks: + self.itemMoved.emit(event.scenePos() - self.clickPos) + elif (self.start_port != None) and (self.end_port != None): + log.debug("Created connection (click)") + new_con = self.core.connect(self.start_port.core, self.end_port.core) + self.addItem(new_con.gui) + self.newElement.emit(new_con) + self.update() + self.start_port = None + self.end_port = None + self.mousePressed = False + self.moving_blocks = False + super(FlowgraphScene, self).mouseReleaseEvent(event) + + def createActions(self, actions): + log.debug("Creating actions") + + """ + # File Actions + actions['save'] = Action(Icons("document-save"), _("save"), self, + shortcut=Keys.New, statusTip=_("save-tooltip")) + + actions['clear'] = Action(Icons("document-close"), _("clear"), self, + shortcut=Keys.Open, statusTip=_("clear-tooltip")) + """ + + def createMenus(self, actions, menus): + log.debug("Creating menus") + + def createToolbars(self, actions, toolbars): + log.debug("Creating toolbars") + + def import_data(self, data): + self.core.import_data(data) + for conn in self.core.connections: + self.addItem(conn.gui) + for block in self.core.blocks: + self.addItem(block.gui) + + def getMaxZValue(self): + z_values = [] + for block in self.core.blocks: + z_values.append(block.gui.zValue()) + return max(z_values) + + def remove_element(self, element: GUIBlock): + self.removeItem(element) + self.core.remove_element(element.core) + + def get_extents(self): + # show_comments = Actions.TOGGLE_SHOW_BLOCK_COMMENTS.get_active() + show_comments = True + + def sub_extents(): + for element in self._elements_to_draw: + yield element.get_extents() + if element.is_block and show_comments and element.enabled: + yield element.get_extents_comment() + + extent = 10000000, 10000000, 0, 0 + cmps = (min, min, max, max) + for sub_extent in sub_extents(): + extent = [cmp(xy, e_xy) for cmp, xy, e_xy in zip(cmps, extent, sub_extent)] + return tuple(extent) + + def copy_to_clipboard(self): + """ + Copy the selected blocks and connections into the clipboard. + + Returns: + the clipboard + """ + # get selected blocks + g_blocks = list(self.selected_blocks()) + if not g_blocks: + return None + # calc x and y min + x_min, y_min = g_blocks[0].core.states["coordinate"] + for g_block in g_blocks: + x, y = g_block.core.states["coordinate"] + x_min = min(x, x_min) + y_min = min(y, y_min) + # get connections between selected blocks + connections = list( + filter( + lambda c: c.source_block.gui in g_blocks and c.sink_block.gui in g_blocks, + self.core.connections, + ) + ) + clipboard = ( + (x_min, y_min), + [g_block.core.export_data() for g_block in g_blocks], + [c_connection.export_data() for c_connection in connections], + ) + return clipboard + + def paste_from_clipboard(self, clipboard): + """ + Paste the blocks and connections from the clipboard. + + Args: + clipboard: the nested data of blocks, connections + """ + self.clearSelection() + (x_min, y_min), blocks_n, connections_n = clipboard + """ + # recalc the position + scroll_pane = self.drawing_area.get_parent().get_parent() + h_adj = scroll_pane.get_hadjustment() + v_adj = scroll_pane.get_vadjustment() + x_off = h_adj.get_value() - x_min + h_adj.get_page_size() / 4 + y_off = v_adj.get_value() - y_min + v_adj.get_page_size() / 4 + + if len(self.get_elements()) <= 1: + x_off, y_off = 0, 0 + """ + x_off, y_off = 10, 10 + + # create blocks + pasted_blocks = {} + for block_n in blocks_n: + block_key = block_n.get("id") + if block_key == "options": + continue + + block_name = block_n.get("name") + # Verify whether a block with this name exists before adding it + if block_name in (blk.name for blk in self.core.blocks): + block_n = block_n.copy() + block_n["name"] = self._get_unique_id(block_name) + + block = self.core.new_block(block_key) + if not block: + continue # unknown block was pasted (e.g. dummy block) + + block.import_data(**block_n) + pasted_blocks[block_name] = block # that is before any rename + + block.gui.moveBy(x_off, y_off) + self.addItem(block.gui) + block.gui.moveToTop() + block.gui.setSelected(True) + """ + while any(Utils.align_to_grid(block.states['coordinate']) == Utils.align_to_grid(other.states['coordinate']) + for other in self.blocks if other is not block): + block.moveBy(Constants.CANVAS_GRID_SIZE, + Constants.CANVAS_GRID_SIZE) + # shift all following blocks + x_off += Constants.CANVAS_GRID_SIZE + y_off += Constants.CANVAS_GRID_SIZE + """ + + # update before creating connections + self.update() + # create connections + for src_block, src_port, dst_block, dst_port in connections_n: + source = pasted_blocks[src_block].get_source(src_port) + sink = pasted_blocks[dst_block].get_sink(dst_port) + connection = self.core.connect(source, sink) + self.addItem(connection.gui) + connection.gui.setSelected(True) + + def itemsBoundingRect(self): + rect = QtWidgets.QGraphicsScene.itemsBoundingRect(self) + return QtCore.QRectF(0.0, 0.0, rect.right(), rect.bottom()) + + def install_external_editor(self, param, parent=None): + target = (param.parent_block.name, param.key) + + if target in self._external_updaters: + editor = self._external_updaters[target] + else: + editor = self.qsettings.value("grc/editor", "") + if not editor: + return + updater = functools.partial( + self.handle_external_editor_change, target=target) + editor = self._external_updaters[target] = ExternalEditor( + editor=editor, + name=target[0], value=param.get_value(), + callback=updater + ) + editor.start() + try: + editor.open_editor() + except Exception as e: + # Problem launching the editor. Need to select a new editor. + log.error('Error opening an external editor. Please select a different editor.\n') + # Reset the editor to force the user to select a new one. + self.parent_platform.config.editor = '' + self.remove_external_editor(target=target) + + def remove_external_editor(self, target=None, param=None): + if target is None: + target = (param.parent_block.name, param.key) + if target in self._external_updaters: + self._external_updaters[target].stop() + del self._external_updaters[target] + + def handle_external_editor_change(self, new_value, target): + try: + block_id, param_key = target + self.core.get_block(block_id).params[param_key].set_value(new_value) + + except (IndexError, ValueError): # block no longer exists + self.remove_external_editor(target=target) + return diff --git a/grc/gui_qt/components/canvas/port.py b/grc/gui_qt/components/canvas/port.py new file mode 100644 index 0000000..a93e807 --- /dev/null +++ b/grc/gui_qt/components/canvas/port.py @@ -0,0 +1,173 @@ +from qtpy.QtGui import QPen, QPainter, QBrush, QFont, QFontMetrics +from qtpy.QtCore import Qt, QPointF, QRectF +from qtpy.QtWidgets import QGraphicsItem + +from . import colors +from ... import Constants +from ....core.ports import Port as CorePort + + +class Port(CorePort): + @classmethod + def make_cls_with_base(cls, super_cls): + name = super_cls.__name__ + bases = (super_cls,) + cls.__bases__[:-1] + namespace = cls.__dict__.copy() + return type(name, bases, namespace) + + def __init__(self, parent, direction, **n): + super(self.__class__, self).__init__(parent, direction, **n) + self.gui = GUIPort(self, direction) + + def remove_clone(self, port): + self.gui.scene().removeItem(port.gui) + super(self.__class__, self).remove_clone(port) + + +class GUIPort(QGraphicsItem): + """ + The graphical port. Interfaces with its underlying Port object using self.core. + The GUIPort is always instantiated in the Port constructor. + + Note that this constructor is called before its parent GUIBlock is instantiated, + which is why we call setParentItem() in create_shapes_and_labels(). + """ + + def __init__(self, core, direction, **n): + self.core = core + QGraphicsItem.__init__(self) + + self.y_offset = 0 + self.height = 3 * 15.0 if self.core.dtype == 'bus' else 15.0 + self.width = 15.0 + + if self.core._dir == "sink": + self.connection_point = self.mapToScene(QPointF(0.0, self.height / 2.0)) + self.ctrl_point = self.mapToScene(QPointF(0.0, self.height / 2.0) - QPointF(5.0, 0.0)) + else: + self.connection_point = self.mapToScene(QPointF(self.width, self.height / 2.0)) + self.ctrl_point = self.mapToScene(QPointF(self.width, self.height / 2.0) + QPointF(5.0, 0.0)) + + self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges) + + self._border_color = self._bg_color = colors.BLOCK_ENABLED_COLOR + + self.setFlag(QGraphicsItem.ItemStacksBehindParent) + self.setFlag(QGraphicsItem.ItemIsSelectable) + self.setAcceptHoverEvents(True) + + self._hovering = False + self.auto_hide_port_labels = False + + self.font = QFont('Helvetica', 8) + self.fm = QFontMetrics(self.font) + + # TODO: Move somewhere else? Not necessarily + self.core.parent_flowgraph.gui.addItem(self) + + """ Dummy blocks instantiate ports only when a connection to them is created. """ + if self.core.parent_block.is_dummy_block: + self.setParentItem(self.core.parent_block.gui) + + def update_connections(self): + if self.core._dir == "sink": + self.connection_point = self.mapToScene(QPointF(-10.0, self.height / 2.0)) + self.ctrl_point = self.mapToScene(QPointF(-10.0, self.height / 2.0) - QPointF(100.0, 0.0)) + else: + self.connection_point = self.mapToScene(QPointF(self.width, self.height / 2.0)) + self.ctrl_point = self.mapToScene(QPointF(self.width, self.height / 2.0) + QPointF(100.0, 0.0)) + + for conn in self.core.connections(): + conn.gui.set_rotation(self.parentItem().rotation()) + conn.gui.update() + + def itemChange(self, change, value): + self.update_connections() + return QGraphicsItem.itemChange(self, change, value) + + def create_shapes_and_labels(self): + self.auto_hide_port_labels = self.core.parent.parent.gui.app.qsettings.value( + 'grc/auto_hide_port_labels', type=bool) + """ + The GUI port is instantiated before its parent block. Therefore we set the parent here, + not in the constructor. Exception: dummy block ports, see __init__() above + """ + if not self.parentItem(): + self.setParentItem(self.core.parent_block.gui) + + self.width = max(15, self.fm.width(self.core.name) * 1.5) + self._update_colors() + self.update_connections() + + @property + def _show_label(self) -> bool: + return self._hovering or not self.auto_hide_port_labels + + def _update_colors(self): + """ + Get the color that represents this port's type. + TODO: Codes differ for ports where the vec length is 1 or greater than 1. + """ + if not self.core.parent.enabled: + color = colors.BLOCK_DISABLED_COLOR + elif self.core.domain == Constants.GR_MESSAGE_DOMAIN: + color = colors.PORT_TYPE_TO_COLOR.get('message') + else: + color = colors.PORT_TYPE_TO_COLOR.get(self.core.dtype) or colors.PORT_TYPE_TO_COLOR.get('') + self._bg_color = color + self._border_color = color + + def paint(self, painter, option, widget): + if self.core.hidden: + return + painter.setRenderHint(QPainter.Antialiasing) + + pen = QPen(self._border_color) + painter.setPen(pen) + painter.setBrush(QBrush(self._bg_color)) + + # TODO: should boundingRect() be used here ? + if self.core._dir == "sink": + rect = QRectF(-max(0, self.width - 15), 0, self.width, self.height) + else: + rect = QRectF(0, 0, self.width, self.height) + painter.drawRect(rect) + + # TODO: Adjustments for finer rotation values if required (eg. 15, 30) + block_rotation = self.parentItem().rotation() + + if self._show_label: + painter.setPen(QPen(1)) + painter.setFont(self.font) + + # Adjust the painter if parent block is 180 degrees rotated + if block_rotation == 180: + painter.translate(self.width, self.height) + painter.rotate(180) + + if self.core._dir == "sink": + if block_rotation == 180: + painter.drawText(QRectF(max(0, self.width - 15), 0, self.width, + self.height), Qt.AlignCenter, self.core.name) + else: + painter.drawText(QRectF(-max(0, self.width - 15), 0, self.width, + self.height), Qt.AlignCenter, self.core.name) + else: + painter.drawText(QRectF(0, 0, self.width, self.height), Qt.AlignCenter, self.core.name) + + def center(self): + return QPointF(self.x() + self.width / 2, self.y() + self.height / 2) + + def boundingRect(self): + if self.core._dir == "sink": + return QRectF(-max(0, self.width - 15), 0, self.width, self.height) + else: + return QRectF(0, 0, self.width, self.height) + + def hoverEnterEvent(self, event): + self._hovering = True + return QGraphicsItem.hoverEnterEvent(self, event) + + def hoverLeaveEvent(self, event): + self._hovering = False + return QGraphicsItem.hoverLeaveEvent(self, event) diff --git a/grc/gui_qt/components/console.py b/grc/gui_qt/components/console.py new file mode 100644 index 0000000..7914f32 --- /dev/null +++ b/grc/gui_qt/components/console.py @@ -0,0 +1,276 @@ +# Copyright 2014-2020 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from __future__ import absolute_import, print_function + +# Standard modules +import html +import logging +import textwrap + +# Third-party modules + +from qtpy import QtCore, QtGui, QtWidgets + +# Custom modules +from .. import base + +# Shortcuts +Action = QtWidgets.QAction +Menu = QtWidgets.QMenu +Toolbar = QtWidgets.QToolBar +Icons = QtGui.QIcon.fromTheme +Keys = QtGui.QKeySequence + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +HTML = ''' + + + + + + + +''' + + +class Console(QtWidgets.QDockWidget, base.Component): + def __init__(self, level): + super(Console, self).__init__() + + self.qsettings = self.app.qsettings + + self.setObjectName('console') + self.setWindowTitle('Console') + self.level = level + + # GUI Widgets + + # Create the layout widget + container = QtWidgets.QWidget(self) + container.setObjectName('console::container') + self._container = container + + layout = QtWidgets.QHBoxLayout(container) + layout.setObjectName('console::layout') + layout.setSpacing(0) + layout.setContentsMargins(5, 0, 5, 5) + self._layout = layout + + # Console output widget + text = QtWidgets.QTextEdit(container) + text.setObjectName('console::text') + text.setUndoRedoEnabled(False) + text.setReadOnly(True) + text.setCursorWidth(0) + text.setTextInteractionFlags(QtCore.Qt.TextSelectableByKeyboard | QtCore.Qt.TextSelectableByMouse) + text.setHtml(textwrap.dedent(HTML)) + self._text = text + + # Add widgets to the component + layout.addWidget(text) + container.setLayout(layout) + self.setWidget(container) + + # Translation support + + # self.setWindowTitle(_translate("", "Library", None)) + # library.headerItem().setText(0, _translate("", "Blocks", None)) + # QtCore.QMetaObject.connectSlotsByName(blockLibraryDock) + + # Setup actions + + # TODO: Move to the base controller and set actions as class attributes + # Automatically create the actions, menus and toolbars. + # Child controllers need to call the register functions to integrate into the mainwindow + self.actions = {} + self.menus = {} + self.toolbars = {} + self.createActions(self.actions) + self.createMenus(self.actions, self.menus) + self.createToolbars(self.actions, self.toolbars) + self.connectSlots() + + # Register the dock widget through the AppController. + # The AppController then tries to find a saved dock location from the preferences + # before calling the MainWindow Controller to add the widget. + self.app.registerDockWidget(self, location=self.settings.window.CONSOLE_DOCK_LOCATION) + + # Register the menus + self.app.registerMenu(self.menus["console"]) + + # Register a new handler for the root logger that outputs messages of + # INFO and HIGHER to the reports view + handler = ReportsHandler(self.add_line) + handler.setLevel(self.level) + + # Need to add this handler to the parent of the controller's logger + log.parent.addHandler(handler) + self.handler = handler + + self.actions['show_level'].setChecked = True + self.handler.show_level = True + self.enabled = False + + if not self.qsettings.value("appearance/display_console", True, type=bool): + self.hide() + + def enable(self): + self.enabled = True + + # Actions + + def createActions(self, actions): + ''' Defines all actions for this view. ''' + + log.debug("Creating actions") + + # File Actions + actions['save'] = Action(Icons("document-save"), _("save"), self, statusTip=_("save-tooltip")) + actions['clear'] = Action(Icons("document-close"), _("clear"), self, statusTip=_("clear-tooltip")) + actions['show_level'] = Action(_("show-level"), self, statusTip=_("show-level"), checkable=True, checked=True) + + actions['auto_scroll'] = Action( + _("auto-scroll"), self, statusTip=_("auto-scroll"), checkable=True, checked=True) + + def createMenus(self, actions, menus): + ''' Setup the view's menus ''' + + log.debug("Creating menus") + + console_menu = QtWidgets.QMenu("&Console") + console_menu.setObjectName("console::menu") + + # Not needed, we have FileHandler logging in main.py + # console_menu.addAction(actions["save"]) + + console_menu.addAction(actions["clear"]) + console_menu.addAction(actions["show_level"]) + console_menu.addAction(actions["auto_scroll"]) + menus["console"] = console_menu + + def createToolbars(self, actions, toolbars): + log.debug("Creating toolbars") + + def add_line(self, line): + # TODO: Support multiple columns for the HTML. DO better with the spacing + # and margins in the output + if self.enabled: + self._text.append(line) + if self.actions["auto_scroll"].isChecked(): + self._text.verticalScrollBar().setValue( + self._text.verticalScrollBar().maximum()) + + # Handlers for the view actions + def clear_triggered(self): + self._text.clear() + + def save_triggered(self): + log.warning("Save reports not implemented") + + def show_level_toggled(self, checked): + self.handler.show_level = checked + + +class ReportsHandler(logging.Handler): # Inherit from logging.Handler + ''' Writes out logs to the reporst window ''' + + def __init__(self, add_line, show_level=True, short_level=True): + # run the regular Handler __init__ + logging.Handler.__init__(self) + + self.add_line = add_line # Function for adding a line to the view + self.show_level = show_level # Dynamically show levels + self.short_level = short_level # Default to true, changed by properties + + self.formatLevelLength = self.formatLevelShort + if not short_level: + self.formatLevelLength = self.formatLevelLong + + def emit(self, record): + # Just handle all formatting here + if self.show_level: + level = self.formatLevel(record.levelname) + message = html.escape(record.msg) + output = self.formatOutput() + self.add_line(output.format(level, message)) + else: + message = html.escape(record.msg) + output = self.formatOutput() + self.add_line(output.format(message)) + + def formatLevel(self, levelname): + output = "{0}{1}{2}" + level = self.formatLevelLength(levelname) + if levelname == "INFO": + return output.format("", level, "") + elif levelname == "WARNING": + return output.format("", level, "") + elif levelname == "ERROR": + return output.format("", level, "") + elif levelname == "CRITICAL": + return output.format("", level, "") + else: + return output.format("", level, "") + + def formatLevelShort(self, levelname): + return f'[{levelname[0:1]}]' + + def formatLevelLong(self, levelname): + output = "{0:<10}" + if levelname in ["DEBUG", "INFO", "WARNING"]: + return output.format(f'[{levelname.capitalize()}]') + else: + return output.format(f'[{levelname.upper()}]') + + def formatOutput(self): + ''' Returns the correct output format based on internal settings ''' + if self.show_level: + if self.short_level: + return "{0}
{1}
" + return "{0}
{1}
" + return "
{0}
" diff --git a/grc/gui_qt/components/dialogs.py b/grc/gui_qt/components/dialogs.py new file mode 100644 index 0000000..b25d2ee --- /dev/null +++ b/grc/gui_qt/components/dialogs.py @@ -0,0 +1,298 @@ +from __future__ import absolute_import, print_function + +from copy import copy + +from ..Constants import MIN_DIALOG_HEIGHT, DEFAULT_PARAM_TAB +from qtpy.QtCore import Qt +from qtpy.QtGui import QStandardItem, QStandardItemModel +from qtpy.QtWidgets import (QLineEdit, QDialog, QDialogButtonBox, QTreeView, + QVBoxLayout, QTabWidget, QGridLayout, QWidget, QLabel, + QPushButton, QListWidget, QComboBox, QPlainTextEdit, QHBoxLayout, + QFileDialog, QApplication, QScrollArea) + + +class ErrorsDialog(QDialog): + def __init__(self, flowgraph): + super().__init__() + self.flowgraph = flowgraph + self.store = [] + self.setModal(True) + self.resize(700, MIN_DIALOG_HEIGHT) + self.setWindowTitle("Errors and Warnings") + buttons = QDialogButtonBox.Close + self.buttonBox = QDialogButtonBox(buttons) + self.buttonBox.rejected.connect(self.reject) # close + self.treeview = QTreeView() + self.treeview.setEditTriggers(QTreeView.NoEditTriggers) + self.model = QStandardItemModel() + self.treeview.setModel(self.model) + self.layout = QVBoxLayout() + self.layout.addWidget(self.treeview) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + self.update() + + def update(self): + # TODO: Make sure the columns are wide enough + self.model.clear() + self.model.setHorizontalHeaderLabels(['Source', 'Aspect', 'Message']) + for element, message in self.flowgraph.iter_error_messages(): + if element.is_block: + src, aspect = QStandardItem(element.name), QStandardItem('') + elif element.is_connection: + src = QStandardItem(element.source_block.name) + aspect = QStandardItem("Connection to '{}'".format(element.sink_block.name)) + elif element.is_port: + src = QStandardItem(element.parent_block.name) + aspect = QStandardItem("{} '{}'".format( + 'Sink' if element.is_sink else 'Source', element.name)) + elif element.is_param: + src = QStandardItem(element.parent_block.name) + aspect = QStandardItem("Param '{}'".format(element.name)) + else: + src = aspect = QStandardItem('') + self.model.appendRow([src, aspect, QStandardItem(message)]) + + +class PropsDialog(QDialog): + def __init__(self, parent_block, force_show_id): + super().__init__() + self.setMinimumSize(700, MIN_DIALOG_HEIGHT) + self._block = parent_block + self.qsettings = QApplication.instance().qsettings + self.setModal(True) + self.force_show_id = force_show_id + + self.setWindowTitle(f"Properties: {self._block.label}") + + categories = (p.category for p in self._block.params.values()) + + def unique_categories(): + seen = {DEFAULT_PARAM_TAB} + yield DEFAULT_PARAM_TAB + for cat in categories: + if cat in seen: + continue + yield cat + seen.add(cat) + + self.edit_params = [] + + self.tabs = QTabWidget() + self.ignore_dtype_labels = ["_multiline", "_multiline_python_external", "file_open", "file_save"] + + for cat in unique_categories(): + qvb = self.build_param_entrys(cat) + tab = QWidget() + tab.setLayout(qvb) + scrollarea = QScrollArea() + scrollarea.setWidget(tab) + self.tabs.addTab(scrollarea, cat) + + self.scroll_error = QScrollArea() + + # Add example tab + self.example_tab = QWidget() + self.example_layout = QVBoxLayout() + self.example_tab.setLayout(self.example_layout) + self.example_list = QListWidget() + self.example_list.itemDoubleClicked.connect(lambda ex: self.open_example(ex)) + + buttons = QDialogButtonBox.Ok | QDialogButtonBox.Cancel + self.buttonBox = QDialogButtonBox(buttons) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + self.layout = QVBoxLayout() + self.layout.addWidget(self.tabs) + + if self._block.enabled: + self.scroll_error.hide() + self.layout.addWidget(self.scroll_error) + + if self._block.get_error_messages(): + error_msg = QLabel() + error_msg.setText('\n'.join(self._block.get_error_messages())) + self.scroll_error.setWidget(error_msg) + self.scroll_error.show() + + self.layout.addWidget(self.buttonBox) + + self.setLayout(self.layout) + self._block.old_data = self._block.export_data() + + def build_param_entrys(self, cat): + qvb = QGridLayout() + qvb.setAlignment(Qt.AlignTop) + qvb.setVerticalSpacing(5) + qvb.setHorizontalSpacing(20) + i = 0 + for param in self._block.params.values(): + if self.force_show_id and param.dtype == 'id': + param.hide = 'none' + if param.category == cat and param.hide != "all": + dtype_label = None + if param.dtype not in self.ignore_dtype_labels: + dtype_label = QLabel(f"[{param.dtype}]") + qvb.addWidget(QLabel(param.name), i, 0) + if param.dtype == "enum" or param.options: + dropdown = QComboBox() + for opt in param.options.values(): + dropdown.addItem(opt) + dropdown.param_values = list(param.options) + dropdown.param = param + qvb.addWidget(dropdown, i, 1) + self.edit_params.append(dropdown) + if param.dtype == "enum": + dropdown.setCurrentIndex( + dropdown.param_values.index(param.get_value()) + ) + dropdown.currentIndexChanged.connect(self.gui_update) + else: + dropdown.setEditable(True) + dropdown.setCurrentIndex( + dropdown.param_values.index(param.get_value()) + ) + dropdown.currentIndexChanged.connect(self.gui_update) + elif param.dtype in ("file_open", "file_save"): + dtype_label = QPushButton("...") + dtype_label.setFlat(True) + file_name = QLineEdit(param.value) + file_name.param = param + qvb.addWidget(file_name, i, 1) + self.edit_params.append(file_name) + if param.dtype == "file_open": + dtype_label.clicked.connect(self.open_filero) + else: + dtype_label.clicked.connect(self.open_filerw) + elif param.dtype == "_multiline": + line_edit = QPlainTextEdit(param.value) + line_edit.param = param + qvb.addWidget(line_edit, i, 1) + self.edit_params.append(line_edit) + elif param.dtype == "_multiline_python_external": + ext_param = copy(param) + + def open_editor(widget=None): + self._block.parent_flowgraph.gui.install_external_editor( + ext_param) + + def open_chooser(widget=None): + self._block.parent_flowgraph.gui.remove_external_editor(param=ext_param) + editor, filtr = QFileDialog.getOpenFileName( + self, + ) + self.qsettings.setValue("grc/editor", editor) + editor_widget = QWidget() + editor_widget.setLayout(QHBoxLayout()) + open_editor_button = QPushButton("Open in Editor") + open_editor_button.clicked.connect(open_editor) + choose_editor_button = QPushButton("Choose Editor") + choose_editor_button.clicked.connect(open_chooser) + editor_widget.layout().addWidget(open_editor_button) + editor_widget.layout().addWidget(choose_editor_button) + line_edit = QPlainTextEdit(param.value) + line_edit.param = param + qvb.addWidget(editor_widget, i, 1) + else: + line_edit = QLineEdit(param.value) + line_edit.returnPressed.connect(self.gui_update) + line_edit.param = param + qvb.addWidget(line_edit, i, 1) + self.edit_params.append(line_edit) + if dtype_label: + qvb.addWidget(dtype_label, i, 2) + i += 1 + return qvb + + def gui_update(self): + index = self.tabs.currentIndex() + self.tabs.currentWidget().widget().hide() + to_delete = self.tabs.currentWidget().takeWidget() + + for par in self.edit_params: + if isinstance(par, QLineEdit): + par.param.set_value(par.text()) + elif isinstance(par, QPlainTextEdit): # Multiline + par.param.set_value(par.toPlainText()) + else: # Dropdown/ComboBox + for key, val in par.param.options.items(): + if val == par.currentText(): + par.param.set_value(key) + self._block.rewrite() + self._block.validate() + cat = self.tabs.tabText(index) + self.edit_params.clear() + qvb = self.build_param_entrys(cat) + tab = QWidget() + tab.setLayout(qvb) + to_delete.deleteLater() + self.tabs.currentWidget().setWidget(tab) + if self._block.enabled: + if self._block.get_error_messages(): + error_msg = QLabel() + error_msg.setText('\n'.join(self._block.get_error_messages())) + if self.scroll_error.widget(): + to_delete = self.scroll_error.takeWidget() + to_delete.deleteLater() + self.scroll_error.setWidget(error_msg) + self.scroll_error.show() + else: + if self.scroll_error.widget(): + to_delete = self.scroll_error.takeWidget() + to_delete.deleteLater() + self.scroll_error.hide() + + self._block.parent.gui.blockPropsChange.emit(self._block) + + def find_param_widget(self, button): + # Find the correct layout + layout = self.tabs.currentWidget().widget().layout() + # Find the location in the layout of the clicked button + location = layout.getItemPosition(layout.indexOf(button)) + # The required widget is at column 1 + return layout.itemAtPosition(location[0], 1).widget() + + def open_filero(self): + f_name, fltr = QFileDialog.getOpenFileName(self, "Open a Data File") + button = self.sender() + self.find_param_widget(button).setText(f_name) + + def open_filerw(self): + f_name, fltr = QFileDialog.getSaveFileName(self, "Save a Data File") + button = self.sender() + self.find_param_widget(button).setText(f_name) + + def accept(self): + super().accept() + for par in self.edit_params: + if isinstance(par, QLineEdit): + par.param.set_value(par.text()) + elif isinstance(par, QPlainTextEdit): # Multiline + par.param.set_value(par.toPlainText()) + else: # Dropdown/ComboBox + for key, val in par.param.options.items(): + if val == par.currentText(): + par.param.set_value(key) + self._block.rewrite() + self._block.validate() + self._block.gui.create_shapes_and_labels() + self._block.parent.gui.blockPropsChange.emit(self._block) + + def reject(self): + try: + name = self._block.old_data['name'] + except KeyError: + name = self._block.old_data['parameters']['id'] + self._block.import_data(name, self._block.old_data['states'], self._block.old_data['parameters']) + self._block.rewrite() + self._block.validate() + self._block.gui.create_shapes_and_labels() + self._block.parent.gui.blockPropsChange.emit(self._block) + super().reject() + + def open_example(self, ex=None): + # example is None if the "Open examples" button was pushed + if ex is None: + ex = self.example_list.currentItem() + self._block.parent.gui.app.MainWindow.open_example(ex.text()) + self.close() diff --git a/grc/gui_qt/components/example_browser.py b/grc/gui_qt/components/example_browser.py new file mode 100644 index 0000000..a6b4bd0 --- /dev/null +++ b/grc/gui_qt/components/example_browser.py @@ -0,0 +1,317 @@ +import logging +import os +import traceback + +from qtpy import uic +from qtpy.QtCore import QObject, Signal, Slot, QRunnable, QVariant, Qt +from qtpy.QtGui import QPixmap, QStandardItem, QStandardItemModel +from qtpy.QtWidgets import QDialog, QListWidgetItem, QTreeWidgetItem, QWidget, QVBoxLayout + + +from ...core.cache import Cache +from .. import base, Constants +from ..properties import Paths + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +class WorkerSignals(QObject): + error = Signal(tuple) + result = Signal(object) + progress = Signal(tuple) + + +class Worker(QRunnable): + """ + This is the Worker that will gather/parse examples as a background task + """ + + def __init__(self, fn, *args, **kwargs): + super(Worker, self).__init__() + + self.fn = fn + self.args = args + self.kwargs = kwargs + self.signals = WorkerSignals() + + self.kwargs['progress_callback'] = self.signals.progress + + @Slot() + def run(self): + try: + result = self.fn(*self.args, **self.kwargs) + except: + print("Error in background task:") + traceback.print_exc() + else: + self.signals.result.emit(result) + + +class ExampleBrowserDialog(QDialog): + def __init__(self, browser): + super(ExampleBrowserDialog, self).__init__() + + self.setMinimumSize(600, 400) + self.setModal(True) + + self.setWindowTitle("GRC Examples") + self.browser = browser + self.layout = QVBoxLayout() + self.setLayout(self.layout) + self.layout.addWidget(browser) + self.browser.connect_dialog(self) + + +class ExampleBrowser(QWidget, base.Component): + file_to_open = Signal(str) + data_role = Qt.UserRole + + lang_dict = { + 'python': 'Python', + 'cpp': 'C++', + } + + gen_opts_dict = { + 'no_gui': 'No GUI', + 'qt_gui': 'Qt GUI', + 'bokeh_gui': 'Bokeh GUI', + 'hb': 'Hier block ', + 'hb_qt_gui': 'Hier block (Qt GUI)' + } + + def __init__(self): + super(ExampleBrowser, self).__init__() + uic.loadUi(Paths.RESOURCES + "/example_browser_widget.ui", self) + self.library = None + self.dialog = None + + self.tree_widget.setHeaderHidden(True) + self.tree_widget.clicked.connect(self.handle_clicked) + + self.cpp_qt_fg = QPixmap(Paths.RESOURCES + "/cpp_qt_fg.png") + self.cpp_cmd_fg = QPixmap(Paths.RESOURCES + "/cpp_cmd_fg.png") + self.py_qt_fg = QPixmap(Paths.RESOURCES + "/py_qt_fg.png") + self.py_cmd_fg = QPixmap(Paths.RESOURCES + "/py_cmd_fg.png") + + self.examples_dict = self.platform.examples_dict + self.dir_items = {} + + self.tree_widget.currentItemChanged.connect(self.populate_preview) + self.tree_widget.itemDoubleClicked.connect(self.open_file) + self.open_button.clicked.connect(self.open_file) + + def set_library(self, library): + self.library = library + + def handle_clicked(self): + if self.tree_widget.isExpanded(self.tree_widget.currentIndex()): + self.tree_widget.collapse(self.tree_widget.currentIndex()) + else: + self.tree_widget.expand(self.tree_widget.currentIndex()) + + def connect_dialog(self, dialog: QDialog): + if self.dialog: + pass # disconnect? + + self.dialog = dialog + if isinstance(dialog, ExampleBrowserDialog): + self.close_button.setHidden(False) + self.close_button.clicked.connect(dialog.reject) + self.open_button.clicked.connect(self.done) + self.tree_widget.itemDoubleClicked.connect(self.done) + else: + raise Exception + + def done(self, _=None): + self.dialog.done(0) + + def populate(self, examples_dict): + self.examples_dict = examples_dict + self.tree_widget.clear() + + for path, examples in examples_dict.items(): + for ex in examples: + rel_path = os.path.relpath(os.path.dirname(ex['path']), path) + split_rel_path = os.path.normpath(rel_path).split(os.path.sep) + parent_path = "/".join(split_rel_path[0:-1]) + if rel_path not in self.dir_items: + dir_ = None + if parent_path: + try: + dir_ = QTreeWidgetItem(self.dir_items[parent_path]) + except KeyError: + i = 0 + while i <= len(split_rel_path): + partial_path = "/".join(split_rel_path[0:i + 1]) + split_partial_path = os.path.normpath(partial_path).split(os.path.sep) + if not partial_path in self.dir_items: + if i == 0: # Top level + dir_ = QTreeWidgetItem(self.tree_widget) + dir_.setText(0, partial_path) + self.dir_items[partial_path] = dir_ + else: + dir_ = QTreeWidgetItem(self.dir_items["/".join(split_partial_path[:-1])]) + dir_.setText(0, split_partial_path[-1]) + self.dir_items[partial_path] = dir_ + i += 1 + else: + dir_ = QTreeWidgetItem(self.tree_widget) + dir_.setText(0, split_rel_path[-1]) + self.dir_items[rel_path] = dir_ + item = QTreeWidgetItem(self.dir_items[rel_path]) + item.setText(0, ex["title"] if ex["title"] else ex["name"]) + item.setData(0, self.data_role, QVariant(ex)) + + def reset_preview(self): + self.title_label.setText(f"Title: ") + self.author_label.setText(f"Author: ") + self.language_label.setText(f"Output language: ") + self.gen_opts_label.setText(f"Type: ") + self.desc_label.setText('') + self.image_label.setPixmap(QPixmap()) + + def populate_preview(self): + ex = self.tree_widget.currentItem().data(0, self.data_role) + + self.title_label.setText(f"Title: {ex['title'] if ex else ''}") + self.author_label.setText(f"Author: {ex['author'] if ex else ''}") + try: + self.language_label.setText( + f"Output language: {self.lang_dict[ex['output_language']] if ex else ''}") + self.gen_opts_label.setText(f"Type: {self.gen_opts_dict[ex['generate_options']] if ex else ''}") + except KeyError: + self.language_label.setText(f"Output language: ") + self.gen_opts_label.setText(f"Type: ") + self.desc_label.setText(ex["desc"] if ex else '') + + if ex: + if ex["output_language"] == "python": + if ex["generate_options"] == "qt_gui": + self.image_label.setPixmap(self.py_qt_fg) + else: + self.image_label.setPixmap(self.py_cmd_fg) + else: + if ex["generate_options"] == "qt_gui": + self.image_label.setPixmap(self.cpp_qt_fg) + else: + self.image_label.setPixmap(self.cpp_cmd_fg) + else: + self.image_label.setPixmap(QPixmap()) + + def open_file(self): + ex = self.tree_widget.currentItem().data(0, self.data_role) + self.file_to_open.emit(ex["path"]) + + def filter_(self, key: str): + """ + Only display examples that contain a specific block. (Hide the others) + + Parameters: + key: The key of the block to search for + """ + found = False + ex_paths = self.library.get_examples(key) + for i in range(self.tree_widget.topLevelItemCount()): + top = self.tree_widget.topLevelItem(i) + if self.show_selective(top, ex_paths): + found = True + return found + + def show_selective(self, item, path): + item.setHidden(True) + if item.childCount(): # is a directory + for i in range(item.childCount()): + if self.show_selective(item.child(i), path): + item.setHidden(False) + return not item.isHidden() + else: # is an example + ex = item.data(0, self.data_role) + if ex['path'] in path: + item.setHidden(False) + return True + else: + return False + + def show_all(self, item): + item.setHidden(False) + for i in range(item.childCount()): + self.show_all(item.child(i)) + + def reset(self): + """Reset the filter, collapse all.""" + self.tree_widget.collapseAll() + self.reset_preview() + + for i in range(self.tree_widget.topLevelItemCount()): + top = self.tree_widget.topLevelItem(i) + self.show_all(top) + + def find_examples(self, progress_callback, ext="grc"): + """Iterate through the example flowgraph directories and parse them.""" + examples_dict = {} + try: + from gnuradio.gr import paths + cache_file = os.path.join(paths.cache(), Constants.GRC_SUBDIR, Constants.EXAMPLE_CACHE_FILE_NAME) + except ImportError: + cache_file = Constants.FALLBACK_EXAMPLE_CACHE_FILE + with Cache(cache_file, log=False) as cache: + for entry in self.platform.config.example_paths: + if entry == '': + log.info("Empty example path!") + continue + examples_dict[entry] = [] + if os.path.isdir(entry): + subdirs = 0 + current_subdir = 0 + for dirpath, dirnames, filenames in os.walk(entry): + subdirs += 1 # Loop through once to see how many there are + for dirpath, dirnames, filenames in os.walk(entry): + dirnames.sort() + current_subdir += 1 + progress_callback.emit((int(100 * current_subdir / subdirs), "Indexing examples")) + for filename in sorted(filter(lambda f: f.endswith('.' + ext), filenames)): + file_path = os.path.join(dirpath, filename) + try: + data = cache.get_or_load(file_path) + example = {} + example["name"] = os.path.basename(file_path) + example["generate_options"] = data["options"]["parameters"].get( + "generate_options") or "no_gui" + example["output_language"] = data["options"]["parameters"].get( + "output_language") or "python" + example["example_filter"] = data["metadata"].get("example_filter") or [] + example["title"] = data["options"]["parameters"]["title"] or "" + example["desc"] = data["options"]["parameters"]["description"] or "" + example["author"] = data["options"]["parameters"]["author"] or "" + example["path"] = file_path + example["module"] = os.path.dirname(file_path).replace(entry, "") + if example["module"].startswith("/"): + example["module"] = example["module"][1:] + + example["blocks"] = set() + for block in data["blocks"]: + example["blocks"].add(block["id"]) + examples_dict[entry].append(example) + except Exception: + continue + + examples_w_block: dict[str, set[str]] = {} + designated_examples_w_block: dict[str, set[str]] = {} + for path, examples in examples_dict.items(): + for example in examples: + if example["example_filter"]: + for block in example["example_filter"]: + try: + designated_examples_w_block[block].append(example["path"]) + except KeyError: + designated_examples_w_block[block] = [example["path"]] + continue + else: + for block in example["blocks"]: + try: + examples_w_block[block].append(example["path"]) + except KeyError: + examples_w_block[block] = [example["path"]] + + return (examples_dict, examples_w_block, designated_examples_w_block) diff --git a/grc/gui_qt/components/executor.py b/grc/gui_qt/components/executor.py new file mode 100644 index 0000000..54e9c3b --- /dev/null +++ b/grc/gui_qt/components/executor.py @@ -0,0 +1,120 @@ +import os +import shlex +import subprocess +import threading +import time +from pathlib import Path +from shutil import which as find_executable + +from ..Utils import get_cmake_nproc +from ...core import Messages + + +class ExecFlowGraphThread(threading.Thread): + """Execute the flow graph as a new process and wait on it to finish.""" + + def __init__(self, view, flowgraph, xterm_executable, callback): + """ + ExecFlowGraphThread constructor. + """ + threading.Thread.__init__(self) + + self.view = view + self.flow_graph = flowgraph + self.xterm_executable = xterm_executable + self.update_callback = callback + + try: + if self.flow_graph.get_option('output_language') == 'python': + self.process = self.view.process = self._popen() + elif self.flow_graph.get_option('output_language') == 'cpp': + self.process = self.view.process = self._cpp_popen() + + self.update_callback() + self.start() + except Exception as e: + Messages.send_verbose_exec(str(e)) + Messages.send_end_exec() + + def _popen(self): + """ + Execute this python flow graph. + """ + generator = self.view.get_generator() + run_command = self.flow_graph.get_run_command(generator.file_path) + run_command_args = shlex.split(run_command) + + # When in no gui mode on linux, use a graphical terminal (looks nice) + xterm_executable = find_executable(self.xterm_executable) + if generator.generate_options == 'no_gui' and xterm_executable: + if ('gnome-terminal' in xterm_executable): + run_command_args = [xterm_executable, '--'] + run_command_args + else: + run_command_args = [xterm_executable, '-e'] + run_command_args + + # this does not reproduce a shell executable command string, if a graphical + # terminal is used. Passing run_command though shlex_quote would do it but + # it looks really ugly and confusing in the console panel. + Messages.send_start_exec(' '.join(run_command_args)) + + dirname = Path(generator.file_path).parent + + return subprocess.Popen( + args=run_command_args, + cwd=dirname, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + shell=False, universal_newlines=True + ) + + def _cpp_popen(self): + """ + Execute this C++ flow graph after generating and compiling it. + """ + generator = self.view.get_generator() + run_command = generator.file_path + \ + '/build/' + self.flow_graph.get_option('id') + + dirname = generator.file_path + builddir = os.path.join(dirname, 'build') + + if os.path.isfile(run_command): + os.remove(run_command) + + xterm_executable = find_executable(self.xterm_executable) + + nproc = get_cmake_nproc() + + run_command_args = f'cmake .. && cmake --build . -j{nproc} && cd ../.. && {xterm_executable} -e {run_command}' + Messages.send_start_exec(run_command_args) + + return subprocess.Popen( + args=run_command_args, + cwd=builddir, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + shell=True, universal_newlines=True + ) + + def run(self): + """ + Wait on the executing process by reading from its stdout. + Use GObject.idle_add when calling functions that modify gtk objects. + """ + # handle completion + r = "\n" + while r: + Messages.send_verbose_exec(r) + r = self.process.stdout.read(1) + + # Properly close pipe before thread is terminated + self.process.stdout.close() + while self.process.poll() is None: + # Wait for the process to fully terminate + time.sleep(0.05) + + self.done + + def done(self): + """Perform end of execution tasks.""" + Messages.send_end_exec(self.process.returncode) + self.view.process = None + self.update_callback() diff --git a/grc/gui_qt/components/flowgraph_view.py b/grc/gui_qt/components/flowgraph_view.py new file mode 100644 index 0000000..a7b7517 --- /dev/null +++ b/grc/gui_qt/components/flowgraph_view.py @@ -0,0 +1,200 @@ +from __future__ import absolute_import, print_function + +# Standard modules +import logging + +import xml.etree.ElementTree as ET + +from ast import literal_eval +from qtpy import QtGui, QtCore, QtWidgets +from qtpy.QtCore import Qt + +# Custom modules +from .canvas.block import Block +from .. import base +from .canvas.flowgraph import FlowgraphScene, Flowgraph +from .canvas.colors import LIGHT_FLOWGRAPH_BACKGROUND_COLOR, DARK_FLOWGRAPH_BACKGROUND_COLOR + +from ...core.generator import Generator + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + +DEFAULT_MAX_X = 400 +DEFAULT_MAX_Y = 300 + + +class FlowgraphView( + QtWidgets.QGraphicsView, base.Component +): # added base.Component so it can see platform + def __init__(self, parent, platform, filename=None): + super(FlowgraphView, self).__init__() + self.setParent(parent) + self.setAlignment(Qt.AlignLeft | Qt.AlignTop) + + self.setScene(FlowgraphScene(self, platform)) + + self.scalefactor = 0.8 + self.scale(self.scalefactor, self.scalefactor) + + self.setSceneRect(0, 0, DEFAULT_MAX_X, DEFAULT_MAX_Y) + if filename is not None: + self.readFile(filename) + else: + self.initEmpty() + + self.fitInView(self.scene().sceneRect(), QtCore.Qt.KeepAspectRatio) + if self.app.theme == "dark": + self.setBackgroundBrush(QtGui.QBrush(DARK_FLOWGRAPH_BACKGROUND_COLOR)) + else: + self.setBackgroundBrush(QtGui.QBrush(LIGHT_FLOWGRAPH_BACKGROUND_COLOR)) + + self.isPanning = False + self.mousePressed = False + + self.setDragMode(self.RubberBandDrag) + + self.generator = None + self.process = None + + def createActions(self, actions): + log.debug("Creating actions") + + def contextMenuEvent(self, event): + super(FlowgraphView, self).contextMenuEvent(event) + + def createMenus(self, actions, menus): + log.debug("Creating menus") + + def createToolbars(self, actions, toolbars): + log.debug("Creating toolbars") + + def get_generator(self) -> Generator: + return self.generator + + def process_is_done(self) -> bool: + if self.process is None: + return True + else: + return (False if self.process.returncode is None else True) + + def readFile(self, filename): + tree = ET.parse(filename) + + for xml_block in tree.findall("block"): + attrib = {} + params = [] + block_key = xml_block.find("key").text + + for param in xml_block.findall("param"): + key = param.find("key").text + value = param.find("value").text + if key.startswith("_"): + attrib[key] = literal_eval(value) + else: + params.append((key, value)) + + # Find block in tree so that we can pull out label + try: + block = self.platform.blocks[block_key] + + new_block = Block(block_key, block.label, attrib, params) + self.scene.addItem(new_block) + self.newElement.emit(new_block) + except: + log.warning("Block '{}' was not found".format(block_key)) + + # This part no longer works now that we are using a Scene with GraphicsItems, but I'm sure there's still some way to do it + # bounds = self.scene.itemsBoundingRect() + # self.setSceneRect(bounds) + # self.fitInView(bounds) + + def initEmpty(self): + self.setSceneRect(0, 0, DEFAULT_MAX_X, DEFAULT_MAX_Y) + + def zoom(self, factor: float, anchor=QtWidgets.QGraphicsView.AnchorViewCenter): + new_scalefactor = self.scalefactor * factor + + if new_scalefactor > 0.25 and new_scalefactor < 2.5: + self.scalefactor = new_scalefactor + self.setTransformationAnchor(anchor) + self.setResizeAnchor(anchor) + self.scale(factor, factor) + + def zoomOriginal(self): + # TODO: Original scale factor as a constant? + self.zoom(0.8 / self.scalefactor) + + def wheelEvent(self, event): + # TODO: Support multi touch drag and drop for scrolling through the view + if event.modifiers() == Qt.ControlModifier: + factor = 1.1 if event.angleDelta().y() > 0 else (1.0 / 1.1) + self.zoom(factor, anchor=QtWidgets.QGraphicsView.AnchorUnderMouse) + + # if new_scalefactor > 0.25 and new_scalefactor < 2.5: + # self.scalefactor = new_scalefactor + # self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor) + # self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor) + + # old_pos = self.mapToScene(event.pos()) + + # self.scale(factor, factor) + # new_pos = self.mapToScene(event.pos()) + + # delta = new_pos - old_pos + # self.translate(delta.x(), delta.y()) + + elif event.modifiers() == Qt.ShiftModifier: + self.horizontalScrollBar().setValue( + self.horizontalScrollBar().value() - event.angleDelta().y()) + else: + QtWidgets.QGraphicsView.wheelEvent(self, event) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.mousePressed = True + # This will pass the mouse move event to the scene + super(FlowgraphView, self).mousePressEvent(event) + + elif event.button() == Qt.MiddleButton: + self.isPanning = True + self.dragPos = event.pos() + self.setCursor(Qt.ClosedHandCursor) + event.accept() + + def mouseMoveEvent(self, event): + if self.isPanning: + new_pos = event.pos() + diff = new_pos - self.dragPos + self.dragPos = new_pos + self.horizontalScrollBar().setValue( + self.horizontalScrollBar().value() - diff.x() + ) + self.verticalScrollBar().setValue( + self.verticalScrollBar().value() - diff.y() + ) + event.accept() + else: + # This will pass the mouse move event to the scene + super(FlowgraphView, self).mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.LeftButton: + self.mousePressed = False + + elif event.button() == Qt.MiddleButton: + self.isPanning = False + self.setCursor(Qt.ArrowCursor) + event.accept() + + super(FlowgraphView, self).mouseReleaseEvent(event) + + def keyPressEvent(self, event): + super(FlowgraphView, self).keyPressEvent(event) + + def keyReleaseEvent(self, event): + super(FlowgraphView, self).keyPressEvent(event) + + def mouseDoubleClickEvent(self, event): + # This will pass the double click event to the scene + super(FlowgraphView, self).mouseDoubleClickEvent(event) diff --git a/grc/gui_qt/components/oot_browser.py b/grc/gui_qt/components/oot_browser.py new file mode 100644 index 0000000..5e599e6 --- /dev/null +++ b/grc/gui_qt/components/oot_browser.py @@ -0,0 +1,113 @@ +from __future__ import absolute_import, print_function + +# Standard modules +import logging +import os +import yaml + +from qtpy import QtCore, QtWidgets, uic + +from .. import base +from ..properties import Paths + + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +class OOTBrowser(QtWidgets.QDialog, base.Component): + data_role = QtCore.Qt.UserRole + + def __init__(self): + super().__init__() + uic.loadUi(Paths.RESOURCES + "/oot_browser.ui", self) + + self.setMinimumSize(600, 450) + self.setModal(True) + + self.setWindowTitle("GRC OOT Modules") + + self.left_list.currentItemChanged.connect(self.populate_right_view) + + self.manifest_dir = os.path.join(Paths.RESOURCES, "manifests") + + if not os.path.exists(self.manifest_dir): + return + + for f in os.listdir(self.manifest_dir): + with open(os.path.join(self.manifest_dir, f), 'r', encoding='utf8') as manifest: + text = manifest.read() + yml, desc = text.split("---") + data = yaml.safe_load(yml) + data["description"] = desc + self.validate(data) + item = QtWidgets.QListWidgetItem() + item.setText(data["title"]) + item.setData(self.data_role, data) + + self.left_list.addItem(item) + + self.left_list.setCurrentRow(0) + + def validate(self, module) -> bool: + type_dict = { + 'title': str, + 'brief': str, + 'website': str, + 'dependencies': list, + 'repo': str, + 'copyright_owner': list, + 'gr_supported_version': list, + 'tags': list, + 'license': str, + 'description': str, + 'author': list + } + + valid = True + + for key, val in type_dict.items(): + if key in module: + if not type(module.get(key)) == val: + log.error( + f"OOT module {module.get('title')} has field {key}, but it's not the correct type. Expected {val}, got {type(module.get(key))}. Ignoring") + valid = False + else: + log.error(f"OOT module {module.get('title')} is missing field {key}. Ignoring") + valid = False + + return valid + + def populate_right_view(self): + module = self.left_list.currentItem().data(self.data_role) + + self.title_label.setText(f"{module.get('title')}") + self.version_label.setText(f"Version: {module.get('version')}") + self.brief_label.setText(module.get("brief")) + if module.get('website'): + self.website_label.setText(f"**Website:** {module.get('website')}") + else: + self.website_label.setText(f'**Website:** None') + if module.get("dependencies"): + self.dep_label.setText(f"Dependencies: {'; '.join(module.get('dependencies'))}") + else: + self.dep_label.setText("Dependencies: None") + if module.get('repo'): + self.repo_label.setText(f"**Repository:** {module.get('repo')}") + else: + self.repo_label.setText(f'**Repository:** None') + if module.get("copyright_owner"): + self.copyright_label.setText(f"Copyright Owner: {', '.join(module.get('copyright_owner'))}") + else: + self.copyright_label.setText("Copyright Owner: None") + if type(module.get('gr_supported_version')) == list: + self.supp_ver_label.setText( + f"Supported GNU Radio Versions: {', '.join(module.get('gr_supported_version'))}") + else: + self.supp_ver_label.setText("Supported GNU Radio Versions: N/A") + log.error(f"module {module.get('title')} has invalid manifest field gr_supported_version") + + self.tags_label.setText(f"Tags: {'; '.join(module.get('tags'))}") + self.license_label.setText(f"License: {module.get('license')}") + self.desc_label.setMarkdown("\n" + module.get("description").replace("\t", "")) + self.author_label.setText(f"**Author(s):** {', '.join(module.get('author'))}") diff --git a/grc/gui_qt/components/preferences.py b/grc/gui_qt/components/preferences.py new file mode 100644 index 0000000..b2382cd --- /dev/null +++ b/grc/gui_qt/components/preferences.py @@ -0,0 +1,172 @@ +from __future__ import absolute_import, print_function + +# Standard modules +import logging +import yaml + +from qtpy.QtCore import Qt, QSettings +from qtpy.QtWidgets import (QLineEdit, QTabWidget, QDialog, + QScrollArea, QVBoxLayout, QCheckBox, + QComboBox, QHBoxLayout, QDialogButtonBox, + QLabel, QWidget, QFormLayout) + +from ..properties import Paths +from gnuradio import gr + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +class PreferencesDialog(QDialog): + pref_dict = {} + + def __init__(self, qsettings): + super(QDialog, self).__init__() + self.qsettings = qsettings + + self.setMinimumSize(600, 400) + self.setModal(True) + + self.setWindowTitle("GRC Preferences") + self.tab_widget = QTabWidget() + + self.rt_prefs = QSettings(gr.userconf_path() + "/config.conf", QSettings.IniFormat) + + with open(Paths.AVAILABLE_PREFS_YML) as available_prefs_yml: + self.pref_dict = yaml.safe_load(available_prefs_yml) + + self.populate_tabs() + + buttons = QDialogButtonBox.Save | QDialogButtonBox.Cancel + self.buttonBox = QDialogButtonBox(buttons) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + self.layout = QVBoxLayout() + self.layout.addWidget(self.tab_widget) + self.layout.addWidget(self.buttonBox) + + self.setLayout(self.layout) + + def new_tab_widget(self) -> QWidget: + widget = QWidget() + vbox = QVBoxLayout() + scroll = QScrollArea() + scroll.setWidgetResizable(True) + content = QWidget() + widget.form = QFormLayout(content) + + widget.setLayout(vbox) + vbox.addWidget(scroll) + scroll.setWidget(content) + + return widget + + def populate_tabs(self) -> None: + for cat in self.pref_dict['categories']: + tab = cat['tab'] = self.new_tab_widget() + self.tab_widget.addTab(tab, cat['name']) + + for item in cat['items']: + full_key = cat['key'] + '/' + item['key'] + + item['_label'] = QLabel(item['name']) + + if item['dtype'] == 'bool': + item['_edit'] = QCheckBox() + + if self.qsettings.contains(full_key): + value = self.qsettings.value(full_key, False, type=bool) + item['_edit'].setChecked(value) + else: + item['_edit'].setChecked(item['default']) + self.qsettings.setValue(full_key, item['default']) + + elif item['dtype'] == 'enum': + item['_edit'] = QComboBox() + for opt in item['option_labels']: + item['_edit'].addItem(opt) + index = item['options'].index(self.qsettings.value(full_key, item['default'], type=str)) + item['_edit'].setCurrentIndex(index) + else: + if self.qsettings.contains(full_key): + item['_edit'] = QLineEdit(self.qsettings.value(full_key)) + else: + item['_edit'] = QLineEdit(str(item['default'])) + self.qsettings.setValue(full_key, item['default']) + + item['_line'] = QHBoxLayout() + + if 'tooltip' in item.keys(): + item['_label'].setToolTip(item['tooltip']) + item['_edit'].setToolTip(item['tooltip']) + + tab.form.addRow(item['_label'], item['_edit']) + + rt_tab = self.new_tab_widget() + for rt_cat in self.pref_dict['runtime']: + for item in rt_cat['items']: + full_key = rt_cat['key'] + '/' + item['key'] + + item['_label'] = QLabel(item['name']) + + if item['dtype'] == 'bool': + item['_edit'] = QCheckBox() + + if self.rt_prefs.contains(full_key): + value = self.rt_prefs.value(full_key, False, type=bool) + item['_edit'].setChecked(value) + else: + item['_edit'].setChecked(item['default']) + self.rt_prefs.setValue(full_key, item['default']) + + elif item['dtype'] == 'enum': + item['_edit'] = QComboBox() + for opt in item['option_labels']: + item['_edit'].addItem(opt) + index = item['options'].index(self.rt_prefs.value(full_key, item['default'], type=str)) + item['_edit'].setCurrentIndex(index) + else: + if self.rt_prefs.contains(full_key): + item['_edit'] = QLineEdit(self.rt_prefs.value(full_key)) + else: + item['_edit'] = QLineEdit(str(item['default'])) + self.rt_prefs.setValue(full_key, item['default']) + + item['_line'] = QHBoxLayout() + + if 'tooltip' in item.keys(): + item['_label'].setToolTip(item['tooltip']) + item['_edit'].setToolTip(item['tooltip']) + + rt_tab.form.addRow(item['_label'], item['_edit']) + + self.tab_widget.addTab(rt_tab, 'Runtime') + + def save_all(self): + log.debug(f'Writing changes to {self.qsettings.fileName()}') + + for cat in self.pref_dict['categories']: + for item in cat['items']: + full_key = cat['key'] + '/' + item['key'] + + if item['dtype'] == 'bool': + self.qsettings.setValue(full_key, item['_edit'].isChecked()) + elif item['dtype'] == 'enum': + self.qsettings.setValue(full_key, item['options'][item['_edit'].currentIndex()]) + else: + self.qsettings.setValue(full_key, item['_edit'].text()) + + self.qsettings.sync() + + for rt_cat in self.pref_dict['runtime']: + for item in rt_cat['items']: + full_key = rt_cat['key'] + '/' + item['key'] + + if item['dtype'] == 'bool': + self.rt_prefs.setValue(full_key, item['_edit'].isChecked()) + elif item['dtype'] == 'enum': + self.rt_prefs.setValue(full_key, item['options'][item['_edit'].currentIndex()]) + else: + self.rt_prefs.setValue(full_key, item['_edit'].text()) + + self.rt_prefs.sync() diff --git a/grc/gui_qt/components/undoable_actions.py b/grc/gui_qt/components/undoable_actions.py new file mode 100644 index 0000000..c7935cb --- /dev/null +++ b/grc/gui_qt/components/undoable_actions.py @@ -0,0 +1,235 @@ +from qtpy.QtWidgets import QUndoCommand + +import logging +from copy import copy +from qtpy.QtCore import QPointF + +from .canvas.flowgraph import FlowgraphScene +from .canvas.block import Block +from ...core.base import Element + +log = logging.getLogger(f"grc.application.{__name__}") + + +# Movement, rotation, enable/disable/bypass, bus ports, +# change params, toggle type. +# Basically anything that's not cut/paste or new/delete +class ChangeStateAction(QUndoCommand): + def __init__(self, scene: FlowgraphScene): + QUndoCommand.__init__(self) + log.debug("init ChangeState") + self.old_states = [] + self.old_params = [] + self.new_states = [] + self.new_params = [] + self.scene = scene + self.g_blocks = scene.selected_blocks() + for g_block in self.g_blocks: + self.old_states.append(copy(g_block.core.states)) + self.new_states.append(copy(g_block.core.states)) + self.old_params.append(copy(g_block.core.params)) + self.new_params.append(copy(g_block.core.params)) + + def redo(self): + for i in range(len(self.g_blocks)): + self.g_blocks[i].set_states(self.new_states[i]) + self.g_blocks[i].core.params = (self.new_params[i]) + self.scene.update() + + def undo(self): + for i in range(len(self.g_blocks)): + self.g_blocks[i].set_states(self.old_states[i]) + self.g_blocks[i].params = (self.old_params[i]) + self.scene.update() + + +class RotateAction(QUndoCommand): + def __init__(self, scene: FlowgraphScene, delta_angle: int): + QUndoCommand.__init__(self) + log.debug("init RotateAction") + self.setText('Rotate') + self.g_blocks = scene.selected_blocks() + self.scene = scene + self.delta_angle = delta_angle + + def redo(self): + self.scene.rotate_selected(self.delta_angle) + + def undo(self): + self.scene.rotate_selected(-self.delta_angle) + + +class MoveAction(QUndoCommand): + def __init__(self, scene: FlowgraphScene, diff: QPointF): + QUndoCommand.__init__(self) + log.debug("init MoveAction") + self.setText('Move') + self.g_blocks = scene.selected_blocks() + self.scene = scene + self.x = diff.x() + self.y = diff.y() + for block in self.g_blocks: + block.core.states["coordinate"] = (block.x(), block.y()) + self.first = True + + # redo() is called when the MoveAction is first created. + # At this point, the item is already at the correct position. + # Therefore, do nothing. + def redo(self): + if self.first: + self.first = False + return + for g_block in self.g_blocks: + g_block.move(self.x, self.y) + self.scene.update() + + def undo(self): + for g_block in self.g_blocks: + g_block.move(-self.x, -self.y) + self.scene.update() + + +class EnableAction(ChangeStateAction): + def __init__(self, scene: FlowgraphScene): + ChangeStateAction.__init__(self, scene) + log.debug("init EnableAction") + self.setText('Enable') + for i in range(len(self.g_blocks)): + self.new_states[i]['state'] = 'enabled' + + +class DisableAction(ChangeStateAction): + def __init__(self, scene: FlowgraphScene): + ChangeStateAction.__init__(self, scene) + log.debug("init DisableAction") + self.setText('Disable') + for i in range(len(self.g_blocks)): + self.new_states[i]['state'] = 'disabled' + + +class BypassAction(ChangeStateAction): + def __init__(self, scene: FlowgraphScene): + ChangeStateAction.__init__(self, scene) + log.debug("init BypassAction") + self.setText('Bypass') + for i in range(len(self.g_blocks)): + self.new_states[i]['state'] = 'bypassed' + + +# Change properties +# This can only be performed on one block at a time +class BlockPropsChangeAction(QUndoCommand): + def __init__(self, scene: FlowgraphScene, c_block: Block): + QUndoCommand.__init__(self) + log.debug("init BlockPropsChangeAction") + self.setText(f'{c_block.name} block: Change properties') + self.scene = scene + self.c_block = c_block + self.old_data = copy(c_block.old_data) + self.new_data = copy(c_block.export_data()) + self.first = True + + def redo(self): + if self.first: + self.first = False + return + try: + name = self.new_data['name'] + except KeyError: + name = self.new_data['parameters']['id'] + + self.c_block.import_data(name, self.new_data['states'], self.new_data['parameters']) + self.c_block.rewrite() + self.c_block.validate() + self.c_block.gui.create_shapes_and_labels() + self.scene.update() + + def undo(self): + try: + name = self.old_data['name'] + except KeyError: + name = self.old_data['parameters']['id'] + + self.c_block.import_data(name, self.old_data['states'], self.old_data['parameters']) + self.c_block.rewrite() + self.c_block.validate() + self.c_block.gui.create_shapes_and_labels() + self.scene.update() + + +class BussifyAction(QUndoCommand): + def __init__(self, scene: FlowgraphScene, direction: str): # direction is either "sink" or "source" + QUndoCommand.__init__(self) + log.debug("init BussifyAction") + self.setText(f'Toggle bus {direction}') + self.scene = scene + self.direction = direction + self.g_blocks = scene.selected_blocks() + + def bussify(self): + for g_block in self.g_blocks: + g_block.core.bussify(self.direction) + self.scene.update() + + def redo(self): + self.bussify() + + def undo(self): + self.bussify() + + +# Blocks and connections +class NewElementAction(QUndoCommand): + def __init__(self, scene: FlowgraphScene, element: Element): + QUndoCommand.__init__(self) + log.debug("init NewElementAction") + self.setText('New') + self.scene = scene + self.element = element + self.first = True + + def redo(self): + if self.first: + self.first = False + return + + if self.element.is_block: + self.scene.core.blocks.append(self.element) + elif self.element.is_connection: + self.scene.core.connections.add(self.element) + + self.scene.addItem(self.element.gui) + self.scene.update() + + def undo(self): + self.scene.remove_element(self.element.gui) + self.scene.update() + + +class DeleteElementAction(QUndoCommand): + def __init__(self, scene: FlowgraphScene): + QUndoCommand.__init__(self) + log.debug("init DeleteElementAction") + self.setText('Delete') + self.scene = scene + self.g_connections = scene.selected_connections() + self.g_blocks = scene.selected_blocks() + for block in self.g_blocks: + for conn in block.core.connections(): + self.g_connections = self.g_connections + [conn.gui] + + def redo(self): + for con in self.g_connections: + self.scene.remove_element(con) + for block in self.g_blocks: + self.scene.remove_element(block) + self.scene.update() + + def undo(self): + for block in self.g_blocks: + self.scene.core.blocks.append(block.core) + self.scene.addItem(block) + for con in self.g_connections: + self.scene.core.connections.add(con.core) + self.scene.addItem(con) + self.scene.update() diff --git a/grc/gui_qt/components/variable_editor.py b/grc/gui_qt/components/variable_editor.py new file mode 100644 index 0000000..e1ddc3c --- /dev/null +++ b/grc/gui_qt/components/variable_editor.py @@ -0,0 +1,296 @@ +from __future__ import absolute_import, print_function + +# Standard modules +import logging +from enum import Enum + +from qtpy import QtGui +from qtpy.QtWidgets import QMenu, QAction, QDockWidget, QTreeWidget, QTreeWidgetItem +from qtpy.QtCore import Slot, Signal, QPointF, Qt, QVariant + +# Custom modules +from .. import base +from ...core.base import Element +from .canvas.flowgraph import FlowgraphScene +from .canvas import colors + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +class VariableEditorAction(Enum): + # Actions that are handled by the editor + ADD_IMPORT = 0 + ADD_VARIABLE = 1 + OPEN_PROPERTIES = 2 + DELETE_BLOCK = 3 + DELETE_CONFIRM = 4 + ENABLE_BLOCK = 5 + DISABLE_BLOCK = 6 + + +class VariableEditor(QDockWidget, base.Component): + all_editor_actions = Signal([VariableEditorAction]) + + def __init__(self): + super(VariableEditor, self).__init__() + + self.qsettings = self.app.qsettings + + self.setObjectName('variable_editor') + self.setWindowTitle('Variable Editor') + + self.right_click_menu = VariableEditorContextMenu(self) + self.scene = None + + # GUI Widgets + self._tree = QTreeWidget() + self._model = self._tree.model() + self._tree.setObjectName('variable_editor::tree_widget') + self._tree.setHeaderLabels(["ID", "Value", ""]) + self.setWidget(self._tree) + self.currently_rebuilding = True + self._model.dataChanged.connect(self.handle_change) + + self.var_add = VariableEditorAction.ADD_VARIABLE + self.import_add = VariableEditorAction.ADD_IMPORT + self.del_block = VariableEditorAction.DELETE_BLOCK + self._tree.itemClicked.connect(self.handle_click) + + imports = QTreeWidgetItem(self._tree) + imports.setText(0, "Imports") + imports.setData(2, Qt.UserRole, self.import_add) + imports.setIcon(2, QtGui.QIcon.fromTheme("list-add")) + + variables = QTreeWidgetItem(self._tree) + variables.setText(0, "Variables") + variables.setData(2, Qt.UserRole, self.var_add) + variables.setIcon(2, QtGui.QIcon.fromTheme("list-add")) + self._tree.expandAll() + + # TODO: Move to the base controller and set actions as class attributes + # Automatically create the actions, menus and toolbars. + # Child controllers need to call the register functions to integrate into the mainwindow + self.actions = {} + self.menus = {} + self.toolbars = {} + self.createActions(self.actions) + self.createMenus(self.actions, self.menus) + self.createToolbars(self.actions, self.toolbars) + self.connectSlots() + + # Register the dock widget through the AppController. + # The AppController then tries to find a saved dock location from the preferences + # before calling the MainWindow Controller to add the widget. + self.app.registerDockWidget(self, location=self.settings.window.VARIABLE_EDITOR_DOCK_LOCATION) + self.currently_rebuilding = False + if not self.qsettings.value("appearance/display_variable_editor", True, type=bool): + self.hide() + + # Actions + + def createActions(self, actions): + log.debug("Creating actions") + + def createMenus(self, actions, menus): + log.debug("Creating menus") + + def createToolbars(self, actions, toolbars): + log.debug("Creating toolbars") + + def contextMenuEvent(self, event): + self.right_click_menu.exec_(self.mapToGlobal(event.pos())) + + def keyPressEvent(self, event): + super(VariableEditor, self).keyPressEvent(event) + + def handle_click(self, item, col): + # we only care about the add/remove icons being clicked + if (col != 2): + return + + action = item.data(2, Qt.UserRole) + + if action is None: + log.warn("Item %s does not contain any actions!!", item) + + self.handle_action(action) + + def set_scene(self, scene: FlowgraphScene): + self.scene = scene + self.update_gui(self.scene.core.blocks) + self._tree.resizeColumnToContents(0) + self._tree.resizeColumnToContents(1) + + # the handler is required by the signal to take two arguments even + # if we only use one + def handle_change(self, tl, br): + if self.currently_rebuilding: + return + + c_block = self._tree.model().data(tl, role=Qt.UserRole) + if not c_block: + return + + new_text = self._tree.model().data(tl) + c_block.old_data = c_block.export_data() + if tl.column() == 0: # The name (id) changed + c_block.params['id'].set_value(new_text) + else: # column == 1, i.e. the value changed + if c_block.is_import: + c_block.params['imports'].set_value(new_text) + else: + c_block.params['value'].set_value(new_text) + self.scene.blockPropsChange.emit(c_block) + self._tree.resizeColumnToContents(0) + self._tree.resizeColumnToContents(1) + + def _rebuild(self): + # TODO: The way we update block params here seems suboptimal + self.currently_rebuilding = True + self._tree.clear() + imports = QTreeWidgetItem(self._tree, 2) + imports.setText(0, "Imports") + imports.setForeground(0, self._tree.palette().color(self.palette().WindowText)) + imports.setIcon(2, QtGui.QIcon.fromTheme("list-add")) + imports.setData(2, Qt.UserRole, self.import_add) + for block in self._imports: + import_ = QTreeWidgetItem(imports, 0) + import_.setText(0, block.name) + import_.setData(0, Qt.UserRole, block) + import_.setText(1, block.params['imports'].get_value()) + import_.setData(1, Qt.UserRole, block) + import_.setIcon(2, QtGui.QIcon.fromTheme("list-remove")) + import_.setData(2, Qt.UserRole, self.del_block) + if block.enabled: + import_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled) + import_.setForeground(0, self._tree.palette().color(self.palette().WindowText)) + import_.setForeground(1, self._tree.palette().color(self.palette().WindowText)) + else: + import_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + import_.setForeground(0, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + import_.setForeground(1, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + + variables = QTreeWidgetItem(self._tree, 2) + variables.setText(0, "Variables") + variables.setForeground(0, self._tree.palette().color(self.palette().WindowText)) + variables.setIcon(2, QtGui.QIcon.fromTheme("list-add")) + variables.setData(2, Qt.UserRole, self.var_add) + for block in sorted(self._variables, key=lambda v: v.name): + variable_ = QTreeWidgetItem(variables, 1) + variable_.setText(0, block.name) + variable_.setData(0, Qt.UserRole, block) + variable_.setData(2, Qt.UserRole, self.del_block) + if block.key == 'variable': + variable_.setText(1, block.params['value'].get_value()) + variable_.setData(1, Qt.UserRole, block) + variable_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled) + if block.enabled: + variable_.setForeground(0, self._tree.palette().color(self.palette().WindowText)) + variable_.setForeground(1, self._tree.palette().color(self.palette().WindowText)) + else: + variable_.setForeground(0, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + variable_.setForeground(1, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + else: + variable_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + if block.enabled: + try: + variable_.setText(1, str(block.evaluate(block.value))) + variable_.setForeground(0, self._tree.palette().color(self.palette().WindowText)) + variable_.setForeground(1, self._tree.palette().color(self.palette().WindowText)) + except Exception: + log.exception(f'Failed to evaluate variable block {block.name}', exc_info=True) + variable_.setText(1, '') + variable_.setForeground(0, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + variable_.setForeground(1, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + else: + variable_.setText(1, '') + variable_.setForeground(0, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + variable_.setForeground(1, self._tree.palette().color(self.palette().Disabled, self.palette().WindowText)) + variable_.setIcon(2, QtGui.QIcon.fromTheme("list-remove")) + + self.currently_rebuilding = False + + def update_gui(self, blocks): + self._imports = [block for block in blocks if block.is_import] + self._variables = [block for block in blocks if block.is_variable] + self._rebuild() + self._tree.expandAll() + + Slot(VariableEditorAction) + + def handle_action(self, action): + log.debug(f"{action} triggered!") + """ + Single handler for the different actions that can be triggered by the context menu, + key presses or mouse clicks. Also triggers an update of the flow graph and editor. + """ + if action == VariableEditorAction.ADD_IMPORT: + self.all_editor_actions.emit(action) + return + elif action == VariableEditorAction.ADD_VARIABLE: + self.all_editor_actions.emit(action) + return + elif action == VariableEditorAction.OPEN_PROPERTIES: + if self._tree.currentItem().type() == 2: # Import or Variable header line was selected + return + self.scene.clearSelection() + if self._tree.currentItem().type() == 0: + to_handle = self.scene.core.blocks.index(self._imports[self._tree.currentIndex().row()]) + else: + to_handle = self.scene.core.blocks.index(self._variables[self._tree.currentIndex().row()]) + self.scene.core.blocks[to_handle].gui.setSelected(True) + self.all_editor_actions.emit(action) + return + + if self._tree.currentItem().type() == 2: # Import or Variable header line was selected + return + + self.scene.clearSelection() + if self._tree.currentItem().type() == 0: + to_handle = self.scene.core.blocks.index(self._imports[self._tree.currentIndex().row()]) + else: + to_handle = self.scene.core.blocks.index(self._variables[self._tree.currentIndex().row()]) + self.scene.core.blocks[to_handle].gui.setSelected(True) + self.all_editor_actions.emit(action) + + +class VariableEditorContextMenu(QMenu): + def __init__(self, var_edit: VariableEditor): + super(QMenu, self).__init__() + + self.imports = QAction(_("variable_editor_add_import"), self) + self.imports.triggered.connect(lambda: var_edit.handle_action(VariableEditorAction.ADD_IMPORT)) + self.addAction(self.imports) + + self.variables = QAction(_("variable_editor_add_variable"), self) + self.variables.triggered.connect(lambda: var_edit.handle_action(VariableEditorAction.ADD_VARIABLE)) + self.addAction(self.variables) + + self.addSeparator() + + self.enable = QAction(_("variable_editor_enable"), self) + self.enable.triggered.connect(lambda: var_edit.handle_action(VariableEditorAction.ENABLE_BLOCK)) + self.addAction(self.enable) + + self.disable = QAction(_("variable_editor_disable"), self) + self.disable.triggered.connect(lambda: var_edit.handle_action(VariableEditorAction.DISABLE_BLOCK)) + self.addAction(self.disable) + + self.addSeparator() + + self.delete = QAction(_("variable_editor_delete"), self) + self.delete.triggered.connect(lambda: var_edit.handle_action(VariableEditorAction.DELETE_BLOCK)) + self.addAction(self.delete) + + self.addSeparator() + + self.properties = QAction(_("variable_editor_properties"), self) + self.properties.triggered.connect(lambda: var_edit.handle_action(VariableEditorAction.OPEN_PROPERTIES)) + self.addAction(self.properties) + + def update_enabled(self, selected, enabled=False): + self.delete.setEnabled(selected) + self.properties.setEnabled(selected) + self.enable.setEnabled(selected and not enabled) + self.disable.setEnabled(selected and enabled) diff --git a/grc/gui_qt/components/wiki_tab.py b/grc/gui_qt/components/wiki_tab.py new file mode 100644 index 0000000..3c2ca0d --- /dev/null +++ b/grc/gui_qt/components/wiki_tab.py @@ -0,0 +1,101 @@ +# Copyright 2014-2022 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from __future__ import absolute_import, print_function + +# Standard modules +import logging + +from qtpy import QtWidgets + +# Custom modules +from .. import base + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + + +class WikiTab(QtWidgets.QDockWidget, base.Component): + def __init__(self): + super(WikiTab, self).__init__() + + self.qsettings = self.app.qsettings + + self.setObjectName('wiki_tab') + self.setWindowTitle('Wiki') + + self.setFloating(False) + + try: + from qtpy.QtWebEngineWidgets import QWebEngineView + except ImportError: + log.error("PyQt QWebEngine missing!") + self.hide() + return + + # GUI Widgets + + # Create the layout widget + container = QtWidgets.QWidget(self) + container.setObjectName('wiki_tab::container') + self._container = container + + layout = QtWidgets.QVBoxLayout(container) + layout.setObjectName('wiki_tab::layout') + layout.setSpacing(0) + layout.setContentsMargins(5, 0, 5, 5) + self._text = QWebEngineView() + self._text.setZoomFactor(0.5) + layout.addWidget(self._text) + self._layout = layout + + container.setLayout(layout) + self.setWidget(container) + + # TODO: Move to the base controller and set actions as class attributes + # Automatically create the actions, menus and toolbars. + # Child controllers need to call the register functions to integrate into the mainwindow + self.actions = {} + self.menus = {} + self.toolbars = {} + self.createActions(self.actions) + self.createMenus(self.actions, self.menus) + self.createToolbars(self.actions, self.toolbars) + self.connectSlots() + + # Register the dock widget through the AppController. + # The AppController then tries to find a saved dock location from the preferences + # before calling the MainWindow Controller to add the widget. + self.app.registerDockWidget(self, location=self.settings.window.WIKI_TAB_DOCK_LOCATION) + if not self.qsettings.value("appearance/display_wiki", False, type=bool): + self.hide() + + def setURL(self, url): + if not self.isHidden(): + self._text.load(url) + self._text.show() + + # Actions + + def createActions(self, actions): + pass + + def createMenus(self, actions, menus): + pass + + def createToolbars(self, actions, toolbars): + pass diff --git a/grc/gui_qt/components/window.py b/grc/gui_qt/components/window.py new file mode 100644 index 0000000..9b44668 --- /dev/null +++ b/grc/gui_qt/components/window.py @@ -0,0 +1,1775 @@ +# Copyright 2014-2020 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from __future__ import absolute_import, print_function + +# Standard modules +import logging +import os +import sys +import subprocess +import cProfile +import pstats + + +from typing import Union + +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import Qt + +# Custom modules +from .flowgraph_view import FlowgraphView +from .canvas.flowgraph import FlowgraphScene +from .example_browser import ExampleBrowser, ExampleBrowserDialog, Worker +from .executor import ExecFlowGraphThread +from .. import base, Constants, Utils +from .variable_editor import VariableEditorAction +from .undoable_actions import ( + RotateAction, + EnableAction, + DisableAction, + BypassAction, + MoveAction, + NewElementAction, + DeleteElementAction, + BlockPropsChangeAction, + BussifyAction +) +from .preferences import PreferencesDialog +from .oot_browser import OOTBrowser +from .dialogs import ErrorsDialog +from ...core.base import Element + +# Logging +log = logging.getLogger(f"grc.application.{__name__}") + +# Shortcuts +Action = QtWidgets.QAction +Menu = QtWidgets.QMenu +Toolbar = QtWidgets.QToolBar +Icons = QtGui.QIcon.fromTheme +Keys = QtGui.QKeySequence +QStyle = QtWidgets.QStyle + + +class MainWindow(QtWidgets.QMainWindow, base.Component): + def __init__(self, file_path=[]): + QtWidgets.QMainWindow.__init__(self) + # base.Component.__init__(self) + + log.debug("Setting the main window") + self.setObjectName("main") + self.setWindowTitle(_("window-title")) + self.setDockOptions( + QtWidgets.QMainWindow.AllowNestedDocks | + QtWidgets.QMainWindow.AllowTabbedDocks | + QtWidgets.QMainWindow.AnimatedDocks + ) + self.progress_bar = QtWidgets.QProgressBar() + self.statusBar().addPermanentWidget(self.progress_bar) + self.progress_bar.hide() + + # Setup the window icon + icon = QtGui.QIcon(self.settings.path.ICON) + log.debug("Setting window icon - ({0})".format(self.settings.path.ICON)) + self.setWindowIcon(icon) + + monitor = self.screen().availableGeometry() + log.debug( + "Setting window size - ({}, {})".format(monitor.width(), monitor.height()) + ) + self.resize(int(monitor.width() * 0.50), monitor.height()) + + self.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) + + # Get max length of recently opened files list to be displayed in the menu + self.max_recent_files = self.app.qsettings.value('appearance/max_recent_files', 10, type=int) + recent_files = list(self.app.qsettings.value('window/files_recent', [])) + for file in recent_files: + if not os.path.exists(file): + recent_files.remove(file) + self.recent_files = recent_files[:self.max_recent_files] + + self.menuBar().setNativeMenuBar(self.settings.window.NATIVE_MENUBAR) + + # TODO: Not sure about document mode + # self.setDocumentMode(True) + + # Generate the rest of the window + self.createStatusBar() + + self.profiler = cProfile.Profile() + + # actions['Quit.triggered.connect(self.close) + # actions['Report.triggered.connect(self.reportDock.show) + # QtCore.QMetaObject.connectSlotsByName(self) + + # Translation support + + # self.setWindowTitle(_translate("blockLibraryDock", "Library", None)) + # library.headerItem().setText(0, _translate("blockLibraryDock", "Blocks", None)) + # QtCore.QMetaObject.connectSlotsByName(blockLibraryDock) + + # TODO: Move to the base controller and set actions as class attributes + # Automatically create the actions, menus and toolbars. + # Child controllers need to call the register functions to integrate into the mainwindow + self.actions = {} + self.menus = {} + self.toolbars = {} + self.createActions(self.actions) + self.createMenus(self.actions, self.menus) + self.createToolbars(self.actions, self.toolbars) + self.connectSlots() + + # Rest of the GUI widgets + + # Map some of the view's functions to the controller class + self.registerDockWidget = self.addDockWidget + self.registerMenu = self.addMenu + self.registerToolBar = self.addToolBar + + # Do other initialization stuff. View should already be allocated and + # actions dynamically connected to class functions. Also, the log + # functionality should be also allocated + log.debug("__init__") + + # Add the menus from the view + menus = self.menus + self.registerMenu(menus["file"]) + self.registerMenu(menus["edit"]) + self.registerMenu(menus["view"]) + self.registerMenu(menus["build"]) + self.registerMenu(menus["tools"]) + self.registerMenu(menus["help"]) + + toolbars = self.toolbars + self.registerToolBar(toolbars["file"]) + self.registerToolBar(toolbars["edit"]) + self.registerToolBar(toolbars["run"]) + self.registerToolBar(toolbars["misc"]) + + self.tabWidget = QtWidgets.QTabWidget() + self.tabWidget.setTabsClosable(True) + self.tabWidget.tabCloseRequested.connect( + lambda index: self.close_triggered(index) + ) + self.tabWidget.currentChanged.connect( + lambda index: self.tab_triggered(index) + ) + self.setCentralWidget(self.tabWidget) + + self.clipboard = None + self.undoView = None + + files_open = list(self.app.qsettings.value('window/files_open', [])) + file_path + grc_file_found = False + if files_open: + for file in files_open: + if os.path.isfile(file): + self.open_triggered(file) + grc_file_found = True + if not grc_file_found: + self.new_triggered() + + try: + self.restoreGeometry(self.app.qsettings.value("window/geometry")) + self.restoreState(self.app.qsettings.value("window/windowState")) + except TypeError: + log.warning("Could not restore window geometry and state.") + + self.examples_found = False + self.ExampleBrowser = ExampleBrowser() + self.ExampleBrowser.file_to_open.connect(self.open_example) + self.OOTBrowser = OOTBrowser() + + self.threadpool = QtCore.QThreadPool() + self.threadpool.setMaxThreadCount(1) + ExampleFinder = Worker(self.ExampleBrowser.find_examples) + ExampleFinder.signals.result.connect(self.populate_libraries_w_examples) + ExampleFinder.signals.progress.connect(self.update_progress_bar) + self.threadpool.start(ExampleFinder) + + """def show(self): + log.debug("Showing main window") + self.show() + """ + + @QtCore.Slot(VariableEditorAction) + def handle_editor_action(self, key): + # Calculate the position to insert a new block + # Perhaps we should add a random component, as we may add several blocks + pos = (self.currentFlowgraphScene.sceneRect().width() / 2, self.currentFlowgraphScene.sceneRect().height() / 2) + if key == VariableEditorAction.DELETE_BLOCK: + self.delete_triggered() + elif key == VariableEditorAction.DISABLE_BLOCK: + self.disable_triggered() + elif key == VariableEditorAction.ENABLE_BLOCK: + self.enable_triggered() + elif key == VariableEditorAction.ADD_VARIABLE: + self.currentFlowgraphScene.add_block('variable', pos) + elif key == VariableEditorAction.ADD_IMPORT: + self.currentFlowgraphScene.add_block('import', pos) + elif key == VariableEditorAction.OPEN_PROPERTIES: + self.currentFlowgraphScene.selected_blocks()[0].open_properties() + else: + log.debug(f"{key} not implemented yet") + self.currentFlowgraphScene.clearSelection() + + @property + def currentView(self): + return self.tabWidget.currentWidget() + + @property + def currentFlowgraphScene(self): + return self.tabWidget.currentWidget().scene() + + @property + def currentFlowgraph(self): + return self.tabWidget.currentWidget().scene().core + + @QtCore.Slot(QtCore.QPointF) + def registerMove(self, diff): + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + action = MoveAction(self.currentFlowgraphScene, diff) + self.currentFlowgraphScene.undoStack.push(action) + self.updateActions() + + @QtCore.Slot(Element) + def registerNewElement(self, elem): + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + action = NewElementAction(self.currentFlowgraphScene, elem) + self.currentFlowgraphScene.undoStack.push(action) + self.updateActions() + self.currentFlowgraphScene.update() + + @QtCore.Slot(Element) + def registerDeleteElement(self, elem): + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + action = DeleteElementAction(self.currentFlowgraphScene, elem) + self.currentFlowgraphScene.undoStack.push(action) + self.updateActions() + self.currentFlowgraphScene.update() + + @QtCore.Slot(Element) + def registerBlockPropsChange(self, elem): + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + action = BlockPropsChangeAction(self.currentFlowgraphScene, elem) + self.currentFlowgraphScene.undoStack.push(action) + self.updateActions() + self.currentFlowgraphScene.update() + + def createActions(self, actions): + """ + Defines all actions for this view. + Controller manages connecting signals to slots implemented in the controller + """ + log.debug("Creating actions") + + # File Actions + actions["new"] = Action( + Icons("document-new"), + _("new"), + self, + shortcut=Keys.New, + statusTip=_("new-tooltip"), + ) + + actions["open"] = Action( + Icons("document-open"), + _("open"), + self, + shortcut=Keys.Open, + statusTip=_("open-tooltip"), + ) + + actions["open_recent"] = Action( + Icons("document-open"), + _("open_recent"), + self, + statusTip=_("open-recent-file"), + ) + actions["open_recent"].setText("Open recent file") + + actions["example_browser"] = Action( + _("example_browser"), + self, + statusTip=_("example_browser-tooltip"), + ) + + actions["close"] = Action( + Icons("window-close"), + _("close"), + self, + shortcut=Keys.Close, + statusTip=_("close-tooltip"), + ) + + actions["close_all"] = Action( + Icons("window-close"), + _("close_all"), + self, + statusTip=_("close_all-tooltip"), + ) + actions["save"] = Action( + Icons("document-save"), + _("save"), + self, + shortcut=Keys.Save, + statusTip=_("save-tooltip"), + ) + + actions["save_as"] = Action( + Icons("document-save-as"), + _("save_as"), + self, + shortcut=Keys.SaveAs, + statusTip=_("save_as-tooltip"), + ) + + actions["save_copy"] = Action(_("save_copy"), self) + + actions["screen_capture"] = Action( + Icons("camera-photo"), + _("screen_capture"), + self, + shortcut=Keys.Print, + statusTip=_("screen_capture-tooltip"), + ) + + actions["exit"] = Action( + Icons("application-exit"), + _("exit"), + self, + shortcut=Keys.Quit, + statusTip=_("exit-tooltip"), + ) + + # Edit Actions + actions["undo"] = Action( + Icons("edit-undo"), + _("undo"), + self, + shortcut=Keys.Undo, + statusTip=_("undo-tooltip"), + ) + + actions["redo"] = Action( + Icons("edit-redo"), + _("redo"), + self, + shortcut=Keys.Redo, + statusTip=_("redo-tooltip"), + ) + + actions["view_undo_stack"] = Action("View undo stack", self) + + actions["cut"] = Action( + Icons("edit-cut"), + _("cut"), + self, + shortcut=Keys.Cut, + statusTip=_("cut-tooltip"), + ) + + actions["copy"] = Action( + Icons("edit-copy"), + _("copy"), + self, + shortcut=Keys.Copy, + statusTip=_("copy-tooltip"), + ) + + actions["paste"] = Action( + Icons("edit-paste"), + _("paste"), + self, + shortcut=Keys.Paste, + statusTip=_("paste-tooltip"), + ) + + actions["delete"] = Action( + Icons("edit-delete"), + _("delete"), + self, + shortcut=Keys.Delete, + statusTip=_("delete-tooltip"), + ) + + actions["undo"].setEnabled(False) + actions["redo"].setEnabled(False) + actions["cut"].setEnabled(False) + actions["copy"].setEnabled(False) + actions["paste"].setEnabled(False) + actions["delete"].setEnabled(False) + + actions["select_all"] = Action( + Icons("edit-select_all"), + _("select_all"), + self, + shortcut=Keys.SelectAll, + statusTip=_("select_all-tooltip"), + ) + + actions["select_none"] = Action( + _("Select None"), self, statusTip=_("select_none-tooltip") + ) + + actions["rotate_ccw"] = Action( + Icons("object-rotate-left"), + _("rotate_ccw"), + self, + shortcut=Keys.MoveToPreviousChar, + statusTip=_("rotate_ccw-tooltip"), + ) + + actions["rotate_cw"] = Action( + Icons("object-rotate-right"), + _("rotate_cw"), + self, + shortcut=Keys.MoveToNextChar, + statusTip=_("rotate_cw-tooltip"), + ) + + actions["rotate_cw"].setEnabled(False) + actions["rotate_ccw"].setEnabled(False) + + actions["enable"] = Action(_("enable"), self, shortcut="E") + actions["disable"] = Action(_("disable"), self, shortcut="D") + actions["bypass"] = Action(_("bypass"), self, shortcut="B") + + # TODO + actions["block_inc_type"] = Action(_("block_inc_type"), self) + actions["block_dec_type"] = Action(_("block_dec_type"), self) + + actions["enable"].setEnabled(False) + actions["disable"].setEnabled(False) + actions["bypass"].setEnabled(False) + + # TODO + actions["vertical_align_top"] = Action(_("vertical_align_top"), self) + actions["vertical_align_middle"] = Action(_("vertical_align_middle"), self) + actions["vertical_align_bottom"] = Action(_("vertical_align_bottom"), self) + + actions["vertical_align_top"].setEnabled(False) + actions["vertical_align_middle"].setEnabled(False) + actions["vertical_align_bottom"].setEnabled(False) + + actions["horizontal_align_left"] = Action(_("horizontal_align_left"), self) + actions["horizontal_align_center"] = Action(_("horizontal_align_center"), self) + actions["horizontal_align_right"] = Action(_("horizontal_align_right"), self) + + actions["horizontal_align_left"].setEnabled(False) + actions["horizontal_align_center"].setEnabled(False) + actions["horizontal_align_right"].setEnabled(False) + + actions["create_hier"] = Action(_("create_hier_block"), self) + actions["open_hier"] = Action(_("open_hier_block"), self) + actions["toggle_source_bus"] = Action(_("toggle_source_bus"), self) + actions["toggle_sink_bus"] = Action(_("toggle_sink_bus"), self) + + actions["create_hier"].setEnabled(True) + actions["open_hier"].setEnabled(True) + actions["toggle_source_bus"].setEnabled(False) + actions["toggle_sink_bus"].setEnabled(False) + + actions["properties"] = Action( + Icons("document-properties"), + _("flowgraph-properties"), + self, + statusTip=_("flowgraph-properties-tooltip"), + ) + + actions["properties"].setEnabled(False) + + # View Actions + actions["zoom_in"] = Action( + Icons("zoom-in"), + _("Zoom &in"), + self + ) + actions["zoom_in"].setShortcuts([Keys.ZoomIn, "Ctrl+="]) + actions["zoom_out"] = Action( + Icons("zoom-out"), + _("Zoom &out"), + self, + shortcut=Keys.ZoomOut, + ) + actions["zoom_original"] = Action( + Icons("zoom-original"), + _("O&riginal size"), + self, + shortcut="Ctrl+0", + ) + + actions["toggle_grid"] = Action( + _("toggle_grid"), self, shortcut="G", statusTip=_("toggle_grid-tooltip") + ) + + actions["errors"] = Action( + Icons("dialog-error"), _("errors"), self, statusTip=_("errors-tooltip") + ) + + actions["find"] = Action( + Icons("edit-find"), + _("find"), + self, + shortcut=Keys.Find, + statusTip=_("find-tooltip"), + ) + + # Help Actions + actions["about"] = Action( + Icons("help-about"), _("about"), self, statusTip=_("about-tooltip") + ) + + actions["about_qt"] = Action( + self.style().standardIcon(QStyle.SP_TitleBarMenuButton), + _("about-qt"), + self, + statusTip=_("about-tooltip"), + ) + + actions["generate"] = Action( + Icons("system-run"), + _("process-generate"), + self, + shortcut="F5", + statusTip=_("process-generate-tooltip"), + ) + + actions["execute"] = Action( + Icons("media-playback-start"), + _("process-execute"), + self, + shortcut="F6", + statusTip=_("process-execute-tooltip"), + ) + + actions["kill"] = Action( + Icons("process-stop"), + _("process-kill"), + self, + shortcut="F7", + statusTip=_("process-kill-tooltip"), + ) + + actions["help"] = Action( + Icons("help-browser"), + _("help"), + self, + shortcut=Keys.HelpContents, + statusTip=_("help-tooltip"), + ) + + # Tools Actions + + actions["filter_design_tool"] = Action(_("&Filter Design Tool"), self) + + actions["module_browser"] = Action(_("&OOT Module Browser"), self) + + actions["start_profiler"] = Action(_("Start profiler"), self) + actions["stop_profiler"] = Action(_("Stop profiler"), self) + + # Help Actions + + actions["types"] = Action(_("&Types"), self) + + actions["keys"] = Action(_("&Keys"), self) + + actions["get_involved"] = Action(_("&Get Involved"), self) + + actions["preferences"] = Action( + Icons("preferences-system"), + _("preferences"), + self, + statusTip=_("preferences-tooltip"), + ) + + actions["reload"] = Action( + Icons("view-refresh"), _("reload"), self, statusTip=_("reload-tooltip") + ) + + # Disable some actions, by default + actions["save"].setEnabled(True) + actions["errors"].setEnabled(False) + + def updateDocTab(self): + pass + """ + doc_txt = self._app().DocumentationTab._text + blocks = self.currentFlowgraphScene.selected_blocks() + if len(blocks) == 1: + #print(blocks[0].documentation) + doc_string = blocks[0].documentation[''] + doc_txt.setText(doc_string) + """ + + def updateActions(self): + """Update the available actions based on what is selected""" + + blocks = self.currentFlowgraphScene.selected_blocks() + conns = self.currentFlowgraphScene.selected_connections() + undoStack = self.currentFlowgraphScene.undoStack + canUndo = undoStack.canUndo() + canRedo = undoStack.canRedo() + self.currentFlowgraph.validate() + valid_fg = self.currentFlowgraph.is_valid() + saved_fg = self.currentFlowgraphScene.saved + + self.actions["save"].setEnabled(not saved_fg) + + self.actions["undo"].setEnabled(canUndo) + self.actions["redo"].setEnabled(canRedo) + self.actions["generate"].setEnabled(valid_fg) + self.actions["execute"].setEnabled(valid_fg) + self.actions["errors"].setEnabled(not valid_fg) + self.actions["kill"].setEnabled(self.currentView.process_is_done()) + + self.actions["cut"].setEnabled(False) + self.actions["copy"].setEnabled(False) + self.actions["paste"].setEnabled(False) + self.actions["delete"].setEnabled(False) + self.actions["rotate_cw"].setEnabled(False) + self.actions["rotate_ccw"].setEnabled(False) + self.actions["enable"].setEnabled(False) + self.actions["disable"].setEnabled(False) + self.actions["bypass"].setEnabled(False) + self.actions["properties"].setEnabled(False) + self.actions["toggle_source_bus"].setEnabled(False) + self.actions["toggle_sink_bus"].setEnabled(False) + + if self.clipboard: + self.actions["paste"].setEnabled(True) + + if len(conns) > 0: + self.actions["delete"].setEnabled(True) + + if len(blocks) > 0: + self.actions["cut"].setEnabled(True) + self.actions["copy"].setEnabled(True) + self.actions["delete"].setEnabled(True) + self.actions["rotate_cw"].setEnabled(True) + self.actions["rotate_ccw"].setEnabled(True) + self.actions["enable"].setEnabled(True) + self.actions["disable"].setEnabled(True) + self.actions["bypass"].setEnabled(True) + self.actions["toggle_source_bus"].setEnabled(True) + self.actions["toggle_sink_bus"].setEnabled(True) + + self.actions["vertical_align_top"].setEnabled(False) + self.actions["vertical_align_middle"].setEnabled(False) + self.actions["vertical_align_bottom"].setEnabled(False) + + self.actions["horizontal_align_left"].setEnabled(False) + self.actions["horizontal_align_center"].setEnabled(False) + self.actions["horizontal_align_right"].setEnabled(False) + + if len(blocks) == 1: + self.actions["properties"].setEnabled(True) + + if len(blocks) > 1: + self.actions["vertical_align_top"].setEnabled(True) + self.actions["vertical_align_middle"].setEnabled(True) + self.actions["vertical_align_bottom"].setEnabled(True) + + self.actions["horizontal_align_left"].setEnabled(True) + self.actions["horizontal_align_center"].setEnabled(True) + self.actions["horizontal_align_right"].setEnabled(True) + + for block in blocks: + if not block.core.can_bypass(): + self.actions["bypass"].setEnabled(False) + break + + def createMenus(self, actions, menus): + """Setup the main menubar for the application""" + log.debug("Creating menus") + + # Global menu options + self.menuBar().setNativeMenuBar(True) + + open_recent = Menu("Open recent file") + menus["open_recent"] = open_recent + + act_recent_len = len(self.recent_files) + if act_recent_len > self.max_recent_files: + act_recent_len = self.max_recent_files + + # Populate recent file list + if act_recent_len == 0: + for i in range(self.max_recent_files): + # Setup invisible dummy entries, that can be filled later, if recently opened files are available + action = open_recent.addAction("Dummy", self.open_recent_triggered) + action.setVisible(False) + else: + for i in range(act_recent_len): + open_recent.addAction(self.recent_files[i], self.open_recent_triggered) + if act_recent_len < self.max_recent_files: + for i in range(self.max_recent_files - act_recent_len): + action = open_recent.addAction("Dummy", self.open_recent_triggered) + action.setVisible(False) + + # Setup the file menu + file = Menu("&File") + file.addAction(actions["new"]) + file.addAction(actions["open"]) + file.addMenu(open_recent) + file.addAction(actions["example_browser"]) + file.addAction(actions["close"]) + file.addAction(actions["close_all"]) + file.addSeparator() + file.addAction(actions["save"]) + file.addAction(actions["save_as"]) + file.addAction(actions["save_copy"]) + file.addSeparator() + file.addAction(actions["screen_capture"]) + file.addSeparator() + file.addAction(actions["preferences"]) + file.addSeparator() + file.addAction(actions["exit"]) + menus["file"] = file + + # Setup the edit menu + edit = Menu("&Edit") + edit.addAction(actions["undo"]) + edit.addAction(actions["redo"]) + edit.addAction(actions["view_undo_stack"]) + edit.addSeparator() + edit.addAction(actions["cut"]) + edit.addAction(actions["copy"]) + edit.addAction(actions["paste"]) + edit.addAction(actions["delete"]) + edit.addAction(actions["select_all"]) + edit.addAction(actions["select_none"]) + edit.addSeparator() + edit.addAction(actions["rotate_ccw"]) + edit.addAction(actions["rotate_cw"]) + + align = Menu("&Align") + menus["align"] = align + align.addAction(actions["vertical_align_top"]) + align.addAction(actions["vertical_align_middle"]) + align.addAction(actions["vertical_align_bottom"]) + align.addSeparator() + align.addAction(actions["horizontal_align_left"]) + align.addAction(actions["horizontal_align_center"]) + align.addAction(actions["horizontal_align_right"]) + + edit.addMenu(align) + edit.addSeparator() + edit.addAction(actions["enable"]) + edit.addAction(actions["disable"]) + edit.addAction(actions["bypass"]) + edit.addSeparator() + + more = Menu("&More") + menus["more"] = more + more.addAction(actions["create_hier"]) + more.addAction(actions["open_hier"]) + more.addAction(actions["toggle_source_bus"]) + more.addAction(actions["toggle_sink_bus"]) + + edit.addMenu(more) + edit.addAction(actions["properties"]) + menus["edit"] = edit + + # Setup submenu + panels = Menu("&Panels") + menus["panels"] = panels + panels.setEnabled(False) + + toolbars = Menu("&Toolbars") + menus["toolbars"] = toolbars + toolbars.setEnabled(False) + + # Setup the view menu + view = Menu("&View") + view.addMenu(panels) + view.addMenu(toolbars) + view.addSeparator() + view.addAction(actions["zoom_in"]) + view.addAction(actions["zoom_out"]) + view.addAction(actions["zoom_original"]) + view.addSeparator() + view.addAction(actions["toggle_grid"]) + view.addAction(actions["find"]) + menus["view"] = view + + # Setup the build menu + build = Menu("&Build") + build.addAction(actions["errors"]) + build.addAction(actions["generate"]) + build.addAction(actions["execute"]) + build.addAction(actions["kill"]) + menus["build"] = build + + # Setup the tools menu + tools = Menu("&Tools") + tools.addAction(actions["filter_design_tool"]) + tools.addAction(actions["module_browser"]) + tools.addSeparator() + tools.addAction(actions["start_profiler"]) + tools.addAction(actions["stop_profiler"]) + menus["tools"] = tools + + # Setup the help menu + help = Menu("&Help") + help.addAction(actions["help"]) + help.addAction(actions["types"]) + help.addAction(actions["keys"]) + help.addSeparator() + help.addAction(actions["get_involved"]) + help.addAction(actions["about"]) + help.addAction(actions["about_qt"]) + menus["help"] = help + + def createToolbars(self, actions, toolbars): + log.debug("Creating toolbars") + + # Main toolbar + file = Toolbar("File") + file.setObjectName("_FileTb") + file.addAction(actions["new"]) + file.addAction(actions["open"]) + file.addAction(actions["save"]) + file.addAction(actions["close"]) + toolbars["file"] = file + + # Edit toolbar + edit = Toolbar("Edit") + edit.setObjectName("_EditTb") + edit.addAction(actions["undo"]) + edit.addAction(actions["redo"]) + edit.addSeparator() + edit.addAction(actions["cut"]) + edit.addAction(actions["copy"]) + edit.addAction(actions["paste"]) + edit.addAction(actions["delete"]) + toolbars["edit"] = edit + + # Run Toolbar + run = Toolbar("Run") + run.setObjectName("_RunTb") + run.addAction(actions["errors"]) + run.addAction(actions["generate"]) + run.addAction(actions["execute"]) + run.addAction(actions["kill"]) + toolbars["run"] = run + + # Misc Toolbar + misc = Toolbar("Misc") + misc.setObjectName("_MiscTb") + misc.addAction(actions["reload"]) + toolbars["misc"] = misc + + def createStatusBar(self): + log.debug("Creating status bar") + self.statusBar().showMessage(_("ready-message")) + + def open(self): + if self.currentFlowgraphScene.filename: + dirname = os.path.dirname(self.currentFlowgraphScene.filename) + else: + dirname = os.getcwd() + Open = QtWidgets.QFileDialog.getOpenFileName + filename, filtr = Open( + self, + self.actions["open"].statusTip(), + dir=dirname, + filter="Flow Graph Files (*.grc);;All files (*.*)", + ) + return filename + + def save(self): + Save = QtWidgets.QFileDialog.getSaveFileName + filename, filtr = Save( + self, + self.actions["save"].statusTip(), + filter="Flow Graph Files (*.grc);;All files (*.*)", + ) + return filename + + # Overridden methods + def addDockWidget(self, location, widget): + """Adds a dock widget to the view.""" + # This overrides QT's addDockWidget so that a 'show' menu auto can automatically be + # generated for this action. + super().addDockWidget(location, widget) + # This is the only instance where a controller holds a reference to a view it does not + # actually control. + name = widget.__class__.__name__ + log.debug("Generating show action item for widget: {0}".format(name)) + + # Create the new action and wire it to the show/hide for the widget + self.menus["panels"].addAction(widget.toggleViewAction()) + self.menus["panels"].setEnabled(True) + + def addToolBar(self, toolbar): + """Adds a toolbar to the main window""" + # This is also overridden so a show menu item can automatically be added + super().addToolBar(toolbar) + name = toolbar.windowTitle() + log.debug("Generating show action item for toolbar: {0}".format(name)) + + # Create the new action and wire it to the show/hide for the widget + self.menus["toolbars"].addAction(toolbar.toggleViewAction()) + self.menus["toolbars"].setEnabled(True) + + def addMenu(self, menu): + """Adds a menu to the main window""" + help = self.menus["help"].menuAction() + self.menuBar().insertMenu(help, menu) + + def populate_libraries_w_examples(self, example_tuple): + examples, examples_w_block, designated_examples_w_block = example_tuple + self.ExampleBrowser.populate(examples) + self.app.BlockLibrary.populate_w_examples(examples_w_block, designated_examples_w_block) + self.progress_bar.reset() + self.progress_bar.hide() + self.examples_found = True + + @QtCore.Slot(tuple) + def update_progress_bar(self, progress_tuple): + progress, msg = progress_tuple + self.progress_bar.show() + self.progress_bar.setValue(progress) + self.progress_bar.setTextVisible(True) + self.progress_bar.setFormat(msg) + + def connect_fg_signals(self, scene: FlowgraphScene): + scene.selectionChanged.connect(self.updateActions) + scene.selectionChanged.connect(self.updateDocTab) + scene.itemMoved.connect(self.registerMove) + scene.newElement.connect(self.registerNewElement) + scene.deleteElement.connect(self.registerDeleteElement) + scene.blockPropsChange.connect(self.registerBlockPropsChange) + + # Action Handlers + def new_triggered(self): + log.debug("New") + fg_view = FlowgraphView(self, self.platform) + fg_view.centerOn(0, 0) + initial_state = self.platform.parse_flow_graph("") + fg_view.scene().import_data(initial_state) + fg_view.scene().saved = False + self.connect_fg_signals(fg_view.scene()) + log.debug("Adding flowgraph view") + self.tabWidget.addTab(fg_view, "Untitled") + self.tabWidget.setCurrentIndex(self.tabWidget.count() - 1) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + + def open_triggered(self, filename=None, save_allowed=True): + log.debug("open") + if not filename: + filename = self.open() + + if filename: + open_fgs = self.get_open_flowgraphs() + if filename not in open_fgs: + self.add_recent_file(filename) + log.info("Opening flowgraph ({0})".format(filename)) + new_flowgraph = FlowgraphView(self, self.platform) + initial_state = self.platform.parse_flow_graph(filename) + self.tabWidget.addTab(new_flowgraph, os.path.basename(filename)) + self.tabWidget.setCurrentIndex(self.tabWidget.count() - 1) + self.currentFlowgraphScene.import_data(initial_state) + self.currentFlowgraphScene.filename = filename + self.connect_fg_signals(self.currentFlowgraphScene) + self.currentFlowgraphScene.saved = True + self.currentFlowgraphScene.save_allowed = save_allowed + self.currentFlowgraphScene.core.rewrite() + self.currentFlowgraphScene.core.validate() + for block in self.currentFlowgraphScene.core.blocks: + block.gui.create_shapes_and_labels() + self.currentFlowgraphScene.update_elements_to_draw() + if hasattr(self.app, 'VariableEditor'): + self.app.VariableEditor.set_scene(self.currentFlowgraphScene) + self.updateActions() + else: + self.tabWidget.setCurrentIndex(open_fgs.index(filename)) + + def open_example(self, example_path): + log.debug("open example") + if example_path: + self.open_triggered(example_path, False) + + def save_triggered(self): + if not self.currentFlowgraphScene.save_allowed: + self.save_as_triggered() + return + log.debug("save") + filename = self.currentFlowgraphScene.filename + + if filename: + try: + self.platform.save_flow_graph(filename, self.currentFlowgraph) + self.currentFlowgraph.grc_file_path = filename + self.add_recent_file(filename) + except IOError: + log.error("Save failed") + return + + log.info(f"Saved {filename}") + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), self.palette().color(self.palette().WindowText)) + self.currentFlowgraphScene.set_saved(True) + else: + log.debug("Flowgraph does not have a filename") + self.save_as_triggered() + self.updateActions() + + def save_as_triggered(self): + log.debug("Save As") + file_dialog = QtWidgets.QFileDialog() + file_dialog.setWindowTitle(self.actions["save"].statusTip()) + file_dialog.setNameFilter('Flow Graph Files (*.grc)') + file_dialog.setDefaultSuffix('grc') + file_dialog.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) + if self.currentFlowgraphScene.filename: + dirname = os.path.dirname(self.currentFlowgraphScene.filename) + else: + dirname = os.getcwd() + file_dialog.setDirectory(dirname) + filename = None + if file_dialog.exec_() == QtWidgets.QFileDialog.Accepted: + filename = file_dialog.selectedFiles()[0] + + if filename: + self.currentFlowgraphScene.filename = filename + try: + self.platform.save_flow_graph(filename, self.currentFlowgraph) + except IOError: + log.error("Save (as) failed") + return + + log.info(f"Saved (as) {filename}") + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), self.palette().color(self.palette().WindowText)) + self.currentFlowgraphScene.set_saved(True) + self.tabWidget.setTabText(self.tabWidget.currentIndex(), os.path.basename(filename)) + self.add_recent_file(filename) + else: + log.debug("Cancelled Save As action") + self.updateActions() + + def save_copy_triggered(self): + log.debug("Save Copy") + file_dialog = QtWidgets.QFileDialog() + file_dialog.setWindowTitle(self.actions["save"].statusTip()) + file_dialog.setNameFilter('Flow Graph Files (*.grc)') + file_dialog.setDefaultSuffix('grc') + file_dialog.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) + + filename = None + if file_dialog.exec_() == QtWidgets.QFileDialog.Accepted: + filename = file_dialog.selectedFiles()[0] + + if filename: + try: + self.platform.save_flow_graph(filename, self.currentFlowgraph) + except IOError: + log.error("Save (copy) failed") + + log.info(f"Saved (copy) {filename}") + else: + log.debug("Cancelled Save Copy action") + + def tab_triggered(self, tab_index=None): + """ + Switches to a tab. + + Parameters: + tab_index: switches to tab(tab_index) + """ + log.debug(f"Switching to tab (index {tab_index})") + if tab_index < 0: + return + self.tabWidget.setCurrentIndex(tab_index) + if hasattr(self.app, 'VariableEditor'): + self.app.VariableEditor.set_scene(self.currentFlowgraphScene) + self.updateActions() + + def close_triggered(self, tab_index=None) -> Union[str, bool]: + """ + Closes a tab. + + Parameters: + tab_index: specifies which tab to close. If none, close the open tab + + Returns: + the file path OR True if a tab was closed (False otherwise) + """ + log.debug(f"Closing a tab (index {tab_index})") + + file_path = self.currentFlowgraphScene.filename + if tab_index is None: + tab_index = self.tabWidget.currentIndex() + + if self.currentFlowgraphScene.saved: + self.tabWidget.removeTab(tab_index) + else: + message = None + if file_path: + message = f"Save changes to {os.path.basename(file_path)} before closing? Your changes will be lost otherwise." + else: + message = "This flowgraph has not been saved" # TODO: Revise text + + response = QtWidgets.QMessageBox.question( + None, + "Save flowgraph?", + message, + QtWidgets.QMessageBox.Discard | + QtWidgets.QMessageBox.Cancel | + QtWidgets.QMessageBox.Save, + ) + + if response == QtWidgets.QMessageBox.Discard: + file_path = self.currentFlowgraphScene.filename + self.tabWidget.removeTab(tab_index) + elif response == QtWidgets.QMessageBox.Save: + self.save_triggered() + if self.currentFlowgraphScene.saved: + file_path = self.currentFlowgraphScene.filename + self.tabWidget.removeTab(tab_index) + else: + return False + else: # Cancel + return False + + if self.tabWidget.count() == 0: # No tabs left + self.new_triggered() + return True + else: + return file_path + + def close_all_triggered(self): + log.debug("close") + + while self.tabWidget.count() > 1: + self.close_triggered() + # Close the final tab + self.close_triggered() + + def print_triggered(self): + log.debug("print") + + def screen_capture_triggered(self): + log.debug("screen capture") + # TODO: Should be user-set somehow + background_transparent = True + + if self.currentFlowgraphScene.filename: + filename = self.currentFlowgraphScene.filename.split('.')[0] + '.pdf' + else: + filename = 'Untitled.pdf' + Save = QtWidgets.QFileDialog.getSaveFileName + file_path, filtr = Save( + self, + self.actions["screen_capture"].statusTip(), + filename, + filter="PDF files (*.pdf);;PNG files (*.png);;SVG files (*.svg)", + ) + if file_path is not None: + try: + Utils.make_screenshot( + self.currentView, file_path, background_transparent + ) + except ValueError: + log.error("Failed to generate screenshot") + + def undo_triggered(self): + log.debug("undo") + self.currentFlowgraphScene.undoStack.undo() + self.currentFlowgraphScene.update() + self.updateActions() + + def redo_triggered(self): + log.debug("redo") + self.currentFlowgraphScene.undoStack.redo() + self.updateActions() + + def view_undo_stack_triggered(self): + log.debug("view_undo_stack") + self.undoView = QtWidgets.QUndoView(self.currentFlowgraphScene.undoStack) + self.undoView.setWindowTitle("Undo stack") + self.undoView.show() + + def cut_triggered(self): + log.debug("cut") + self.copy_triggered() + self.delete_triggered() + self.updateActions() + + def copy_triggered(self): + log.debug("copy") + self.clipboard = self.currentFlowgraphScene.copy_to_clipboard() + self.updateActions() + + def paste_triggered(self): + log.debug("paste") + if self.clipboard: + self.currentFlowgraphScene.paste_from_clipboard(self.clipboard) + self.currentFlowgraphScene.update() + else: + log.debug("clipboard is empty") + + def delete_triggered(self): + log.debug("delete") + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + action = DeleteElementAction(self.currentFlowgraphScene) + self.currentFlowgraphScene.undoStack.push(action) + self.updateActions() + self.currentFlowgraphScene.update() + + def select_all_triggered(self): + log.debug("select_all") + self.currentFlowgraphScene.select_all() + self.updateActions() + + def select_none_triggered(self): + log.debug("select_none") + self.currentFlowgraphScene.clearSelection() + self.updateActions() + + def rotate_ccw_triggered(self): + # Pass to Undo/Redo + log.debug("rotate_ccw") + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + rotateCommand = RotateAction(self.currentFlowgraphScene, -90) + self.currentFlowgraphScene.undoStack.push(rotateCommand) + self.updateActions() + self.currentFlowgraphScene.update() + + def rotate_cw_triggered(self): + # Pass to Undo/Redo + log.debug("rotate_cw") + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + rotateCommand = RotateAction(self.currentFlowgraphScene, 90) + self.currentFlowgraphScene.undoStack.push(rotateCommand) + self.updateActions() + self.currentFlowgraphScene.update() + + def toggle_source_bus_triggered(self): + log.debug("toggle_source_bus") + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + bussifyCommand = BussifyAction(self.currentFlowgraphScene, 'source') + self.currentFlowgraphScene.undoStack.push(bussifyCommand) + self.updateActions() + self.currentFlowgraphScene.update() + + def toggle_sink_bus_triggered(self): + log.debug("toggle_sink_bus") + self.currentFlowgraphScene.set_saved(False) + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + bussifyCommand = BussifyAction(self.currentFlowgraphScene, 'sink') + self.currentFlowgraphScene.undoStack.push(bussifyCommand) + self.updateActions() + self.currentFlowgraphScene.update() + + def errors_triggered(self): + log.debug("errors") + err = ErrorsDialog(self.currentFlowgraph) + err.exec() + + def module_browser_triggered(self): + log.debug("oot browser") + self.OOTBrowser.show() + + def zoom_in_triggered(self): + log.debug("zoom in") + self.currentView.zoom(1.1) + + def zoom_out_triggered(self): + log.debug("zoom out") + self.currentView.zoom(1.0 / 1.1) + + def zoom_original_triggered(self): + log.debug("zoom to original size") + self.currentView.zoomOriginal() + + def find_triggered(self): + log.debug("find block") + self._app().BlockLibrary._search_bar.clear() + self._app().BlockLibrary._search_bar.setFocus() + self._app().BlockLibrary.reset() + + def get_involved_triggered(self): + log.debug("get involved") + ad = QtWidgets.QMessageBox() + ad.setWindowTitle("Get Involved Instructions") + ad.setText( + """\ + Welcome to the GNU Radio Community!

+ For more details on contributing to GNU Radio and getting engaged with our great community visit here.

+ You can also join our Matrix chat server, IRC Channel (#gnuradio) or contact through our mailing list (discuss-gnuradio). + """ + ) + ad.exec() + + def about_triggered(self): + log.debug("about") + config = self.platform.config + py_version = sys.version.split()[0] + QtWidgets.QMessageBox.about( + self, "About GNU Radio", f"GNU Radio {config.version} (Python {py_version})" + ) + + def about_qt_triggered(self): + log.debug("about_qt") + QtWidgets.QApplication.instance().aboutQt() + + def properties_triggered(self): + log.debug("properties") + if len(self.currentFlowgraphScene.selected_blocks()) != 1: + log.warn("Opening Properties even though selected_blocks() != 1 ") + self.currentFlowgraphScene.selected_blocks()[0].open_properties() + + def enable_triggered(self): + log.debug("enable") + self.currentFlowgraphScene.set_saved(False) + all_enabled = True + for block in self.currentFlowgraphScene.selected_blocks(): + if not block.core.state == "enabled": + all_enabled = False + break + + if not all_enabled: + cmd = EnableAction(self.currentFlowgraphScene) + self.currentFlowgraphScene.undoStack.push(cmd) + + self.currentFlowgraphScene.update() + self.updateActions() + + def disable_triggered(self): + log.debug("disable") + self.currentFlowgraphScene.set_saved(False) + all_disabled = True + for g_block in self.currentFlowgraphScene.selected_blocks(): + if not g_block.core.state == "disabled": + all_disabled = False + break + + if not all_disabled: + cmd = DisableAction(self.currentFlowgraphScene) + self.currentFlowgraphScene.undoStack.push(cmd) + + self.currentFlowgraphScene.update() + self.updateActions() + + def bypass_triggered(self): + log.debug("bypass") + all_bypassed = True + for g_block in self.currentFlowgraphScene.selected_blocks(): + if not g_block.core.state == "bypassed": + all_bypassed = False + break + + if not all_bypassed: + cmd = BypassAction(self.currentFlowgraphScene) + self.currentFlowgraphScene.undoStack.push(cmd) + + self.currentFlowgraphScene.update() + self.updateActions() + + def block_inc_type_triggered(self): + log.debug("block_inc_type") + + def block_dec_type_triggered(self): + log.debug("block_dec_type") + + def generate_triggered(self): + log.debug("generate") + if not self.currentFlowgraphScene.saved: + self.save_triggered() + if not self.currentFlowgraphScene.saved: # The line above was cancelled + log.error("Cannot generate a flowgraph without saving first") + return + + filename = self.currentFlowgraphScene.filename + self.currentFlowgraph.grc_file_path = filename + generator = self.platform.Generator( + self.currentFlowgraph, os.path.dirname(filename) + ) + generator.write() + self.currentView.generator = generator + log.info(f"Generated {generator.file_path}") + + def execute_triggered(self): + log.debug("execute") + if self.currentView.process_is_done(): + self.generate_triggered() + if self.currentView.generator: + xterm = self.app.qsettings.value("grc/xterm_executable", "") + '''if self.config.xterm_missing() != xterm: + if not os.path.exists(xterm): + Dialogs.show_missing_xterm(main, xterm) + self.config.xterm_missing(xterm)''' + if self.currentFlowgraphScene.saved and self.currentFlowgraphScene.filename: + # Save config before execution + # self.config.save() + ExecFlowGraphThread( + view=self.currentView, + flowgraph=self.currentFlowgraph, + xterm_executable=xterm, + callback=self.updateActions + ) + + def kill_triggered(self): + log.debug("kill") + + def reload_triggered(self): + log.debug("Reload hier blocks") + self.app.BlockLibrary.reload_blocks() + + range_ = self.tabWidget.count() + for idx in range(range_): + self.rebuild_tab(idx) + + self.updateActions() + + def create_hier_triggered(self): + + selected_blocks = [] + + source_fg = self.currentFlowgraphScene + for block in source_fg.selected_blocks(): + selected_blocks.append(block.core) + + # Generate new page + self.new_triggered() + sink_fg = self.currentFlowgraphScene + + # Set flow graph to heir block type + top_block = sink_fg.core.get_block(Constants.DEFAULT_FLOW_GRAPH_ID) + # Check if hb or hb_qt is required + gen_opts = 'hb' + for block in selected_blocks: + if block.label.upper().startswith('QT GUI'): + gen_opts = 'hb_qt_gui' + break + top_block.params['generate_options'].set_value(gen_opts) + + # this needs to be a unique name + top_block.params['id'].set_value('new_hier') + # Remove default samp_rate + remove_me = sink_fg.core.get_block("samp_rate") + sink_fg.remove_element(remove_me.gui) + + self.clipboard = source_fg.copy_to_clipboard() + self.paste_triggered() + + # For each variable generate a possible parameter block + # The user has to decide if the hier block should use a variable or a paramter + + for variable in sink_fg.core.get_variables(): + id = sink_fg.add_block( + 'parameter', (variable.states['coordinate'][0] + variable.gui.width + 50, variable.states['coordinate'][1] + 50)) + param_block = sink_fg.core.get_block(id) + param_block.params['id'].set_value(variable.name) + + for connection in source_fg.core.connections: + # Get id of connected blocks + source = connection.source_block + sink = connection.sink_block + if source not in selected_blocks and sink in selected_blocks: + # Create pad source and add to canvas + pad_source_key = int(connection.sink_port.key) + pad_id = sink_fg.add_block( + 'pad_source', (source.states['coordinate'][0], source.states['coordinate'][1] + pad_source_key * 50)) + pad_block = sink_fg.core.get_block(pad_id) + pad_source = pad_block.sources[0] + sink_block = sink_fg.core.get_block(sink_fg.core.blocks[selected_blocks.index(sink) + 1].name) + sink = sink_block.sinks[pad_source_key] + # ensure the port types match + if pad_source.dtype != sink.dtype: + if pad_source.dtype == 'complex' and sink.dtype == 'fc32': + pass + else: + pad_block.params['type'].value = sink.dtype + pad_source.dtype = sink.dtype + new_connection = sink_fg.core.connect(pad_source, sink) + sink_fg.addItem(new_connection.gui) + + elif sink not in selected_blocks and source in selected_blocks: + # Create Pad Sink and add to canvas + pad_sink_key = int(connection.source_port.key) + pad_id = sink_fg.add_block( + 'pad_sink', (sink.states['coordinate'][0], sink.states['coordinate'][1] + pad_sink_key * 50)) + pad_block = sink_fg.core.get_block(pad_id) + pad_sink = pad_block.sinks[0] + source_block = sink_fg.core.get_block(sink_fg.core.blocks[selected_blocks.index(source) + 1].name) + source = source_block.sources[pad_sink_key] + # ensure the port types match + if pad_sink.dtype != source.dtype: + if pad_sink.dtype == 'complex' and source.dtype == 'fc32': + pass + else: + pad_block.params['type'].value = source.dtype + pad_sink.dtype = source.dtype + new_connection = sink_fg.core.connect(source, pad_sink) + sink_fg.addItem(new_connection.gui) + + sink_fg.clearSelection() + sink_fg.update() + self.updateActions() + + def open_hier_triggered(self): + log.debug("Open hier block triggered") + for block in self.currentFlowgraphScene.selected_blocks(): + grc_source = block.core.extra_data.get('grc_source', '') + if grc_source: + self.open_triggered(grc_source) + + def rebuild_tab(self, idx): + fgscene = self.tabWidget.widget(idx).scene() + log.info("Rebuilding flowgraph ({0})".format(fgscene.filename)) + data = fgscene.core.export_data() + fgblocks = fgscene.core.blocks + for block in fgblocks: + fgscene.removeItem(block.gui) + for conn in fgscene.core.connections: + fgscene.removeItem(conn.gui) + fgscene.import_data(data) + + def add_recent_file(self, file_name): + # double check file_name + if os.path.exists(file_name): + recent_files = self.recent_files + if file_name in recent_files: + recent_files.remove(file_name) # Attempt removal + recent_files.insert(0, file_name) # Insert at start + self.recent_files = recent_files[:self.max_recent_files] + + # Now add files to menu entries + actions_list = self.menus["open_recent"].actions() + for i in range(len(self.recent_files)): + actions_list[i].setText(self.recent_files[i]) + actions_list[i].setVisible(True) + + def show_help(parent): + """Display basic usage tips.""" + message = """\ + Usage Tips + \n\ + Add block: drag and drop or double click a block in the block + selection window. + Rotate block: Select a block, press left/right on the keyboard. + Change type: Select a block, press up/down on the keyboard. + Edit parameters: double click on a block in the flow graph. + Make connection: click on the source port of one block, then + click on the sink port of another block. + Remove connection: select the connection and press delete, or + drag the connection. + \n\ + *Press Ctrl+K or see menu for Keyboard - Shortcuts + \ + """ + + ad = QtWidgets.QMessageBox() + ad.setWindowTitle("Help") + ad.setText(message) + ad.exec() + + def types_triggered(self): + log.debug("types") + colors = [(name, color) for name, key, sizeof, color in Constants.CORE_TYPES] + + message = """ + + + """ + + message += "\n".join( + '' + "".format(color=color, name=name) + for name, color in colors + ) + message += "
{name}
" + ad = QtWidgets.QMessageBox() + ad.setWindowTitle("Stream Types") + ad.setText(message) + ad.exec() + + def keys_triggered(self): + log.debug("keys") + + message = """\ + Keyboard Shortcuts +

+ Ctrl+N: Create a new flowgraph.
+ Ctrl+O: Open an existing flowgraph.
+ Ctrl+S: Save the current flowgraph or save as for new.
+ Ctrl+W: Close the current flowgraph.
+ Ctrl+Z: Undo a change to the flowgraph.
+ Ctrl+Y: Redo a change to the flowgraph.
+ Ctrl+A: Selects all blocks and connections.
+ Ctrl+P: Screen Capture of the Flowgraph.
+ Ctrl+Shift+P: Save the console output to file.
+ Ctrl+L: Clear the console.
+ Ctrl+E: Show variable editor.
+ Ctrl+F: Search for a block by name.
+ Ctrl+Q: Quit.
+ F1 : Help menu.
+ F5 : Generate the Flowgraph.
+ F6 : Execute the Flowgraph.
+ F7 : Kill the Flowgraph.
+ Ctrl+Shift+S: Save as the current flowgraph.
+ Ctrl+Shift+D: Create a duplicate of current flow graph.
+ + Ctrl+X/C/V: Edit-cut/copy/paste.
+ Ctrl+D/B/R: Toggle visibility of disabled blocks or + connections/block tree widget/console.
+ Shift+T/M/B/L/C/R: Vertical Align Top/Middle/Bottom and + Horizontal Align Left/Center/Right respectively of the + selected block.
+ Ctrl+0: Reset the zoom level
+ Ctrl++/-: Zoom in and out
+ \ + """ + + ad = QtWidgets.QMessageBox() + ad.setWindowTitle("Keyboard shortcuts") + ad.setText(message) + ad.exec() + + def preferences_triggered(self): + log.debug("preferences") + prefs_dialog = PreferencesDialog(self.app.qsettings) + if prefs_dialog.exec_(): # User pressed Save + prefs_dialog.save_all() + self.currentFlowgraphScene.update() + + def open_recent_triggered(self): + log.debug("open_recent") + self.open_triggered(self.sender().text()) + + def example_browser_triggered(self, key_filter: Union[str, None] = None): + log.debug("example-browser") + if self.examples_found: + self.ExampleBrowser.reset() + ex_dialog = ExampleBrowserDialog(self.ExampleBrowser) + if len(ex_dialog.browser.examples_dict) == 0: + ad = QtWidgets.QMessageBox() + ad.setWindowTitle("GRC: No examples found") + ad.setText("GRC did not find any examples. Please ensure that the example path in grc.conf is correct.") + ad.exec() + return + + if isinstance(key_filter, str): + if not ex_dialog.browser.filter_(key_filter): + ad = QtWidgets.QMessageBox() + ad.setWindowTitle("GRC: No examples") + ad.setText("There are no examples for this block.") + ad.exec() + return + else: + ex_dialog.browser.reset() + + ex_dialog.exec_() + else: + ad = QtWidgets.QMessageBox() + ad.setWindowTitle("GRC still indexing examples") + ad.setText("GRC is still indexing examples, please try again shortly.") + ad.exec() + + def exit_triggered(self): + log.debug("exit") + + files_open = [] + range_ = reversed(range(self.tabWidget.count())) + for idx in range_: # Close the rightmost first. It'll be the first element in files_open + tab = self.tabWidget.widget(idx) + file_path = self.currentFlowgraphScene.filename + if file_path: + files_open.append(file_path) + closed = self.close_triggered() + if closed == False: + # We cancelled closing a tab. We don't want to close the application + return + + # Save the panel settings + # Console + self.app.qsettings.setValue('appearance/display_console', not self.app.Console.isHidden()) + # Block Library + self.app.qsettings.setValue('appearance/display_blocklibrary', not self.app.BlockLibrary.isHidden()) + # Wiki + self.app.qsettings.setValue('appearance/display_wiki', not self.app.WikiTab.isHidden()) + # Variable Editor + self.app.qsettings.setValue('appearance/display_variable_editor', not self.app.VariableEditor.isHidden()) + + # Write the leftmost tab to file first + self.app.qsettings.setValue('window/files_open', reversed(files_open)) + self.app.qsettings.setValue('window/windowState', self.saveState()) + self.app.qsettings.setValue('window/geometry', self.saveGeometry()) + self.app.qsettings.sync() + + # TODO: Make sure all flowgraphs have been saved + self.app.exit() + + def closeEvent(self, evt): + log.debug("Close Event") + self.exit_triggered() + evt.ignore() + + def help_triggered(self): + log.debug("help") + self.show_help() + + def report_triggered(self): + log.debug("report") + + def library_triggered(self): + log.debug("library_triggered") + + def library_toggled(self): + log.debug("library_toggled") + + def filter_design_tool_triggered(self): + log.debug("filter_design_tool") + subprocess.Popen( + "gr_filter_design", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + def start_profiler_triggered(self): + log.info("Starting profiler") + self.profiler.enable() + + def stop_profiler_triggered(self): + self.profiler.disable() + log.info("Stopping profiler") + stats = pstats.Stats(self.profiler) + stats.dump_stats('stats.prof') + + def get_open_flowgraphs(self): + index = self.tabWidget.count() + if index == 0: + return [] + result = [] + range_ = range(index) + for fg_nr in range_: + self.tabWidget.setCurrentIndex(fg_nr) + result.append(self.currentFlowgraphScene.filename) + return result diff --git a/grc/gui_qt/external_editor.py b/grc/gui_qt/external_editor.py new file mode 100644 index 0000000..522b6dc --- /dev/null +++ b/grc/gui_qt/external_editor.py @@ -0,0 +1,74 @@ +""" +Copyright 2015 Free Software Foundation, Inc. +This file is part of GNU Radio + +SPDX-License-Identifier: GPL-2.0-or-later + +""" + + +import os +import sys +import time +import threading +import tempfile +import subprocess + + +class ExternalEditor(threading.Thread): + + def __init__(self, editor, name, value, callback): + threading.Thread.__init__(self) + self.daemon = True + self._stop_event = threading.Event() + + self.editor = editor + self.callback = callback + self.filename = self._create_tempfile(name, value) + + def _create_tempfile(self, name, value): + with tempfile.NamedTemporaryFile( + mode='wb', prefix=name + '_', suffix='.py', delete=False, + ) as fp: + fp.write(value.encode('utf-8')) + return fp.name + + def open_editor(self): + proc = subprocess.Popen(args=(self.editor, self.filename)) + proc.poll() + return proc + + def stop(self): + self._stop_event.set() + + def run(self): + filename = self.filename + # print "file monitor: started for", filename + last_change = os.path.getmtime(filename) + try: + while not self._stop_event.is_set(): + mtime = os.path.getmtime(filename) + if mtime > last_change: + # print "file monitor: reload trigger for", filename + last_change = mtime + with open(filename, 'rb') as fp: + data = fp.read().decode('utf-8') + self.callback(data) + time.sleep(1) + + except Exception as e: + print("file monitor crashed:", str(e), file=sys.stderr) + finally: + try: + os.remove(self.filename) + except OSError: + pass + + +if __name__ == '__main__': + e = ExternalEditor('/usr/bin/gedit', "test", "content", print) + e.open_editor() + e.start() + time.sleep(15) + e.stop() + e.join() diff --git a/grc/gui_qt/grc.py b/grc/gui_qt/grc.py new file mode 100644 index 0000000..91d7055 --- /dev/null +++ b/grc/gui_qt/grc.py @@ -0,0 +1,167 @@ +# Copyright 2014-2020 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from __future__ import absolute_import, print_function + +# Standard modules +import logging +import textwrap +import os + +from qtpy import QtCore, QtWidgets, QtGui, PYQT_VERSION, PYSIDE_VERSION + +# Custom modules +from . import components +from .helpers.profiling import StopWatch + +# Logging +# Setup the logger to use a different name than the file name +log = logging.getLogger("grc.application") + + +class Application(QtWidgets.QApplication): + """ + This is the main QT application for GRC. + It handles setting up the application components and actions and handles communication between different components in the system. + """ + + def __init__(self, settings, platform, file_path): + # Note. Logger must have the correct naming convention to share handlers + log.debug("__init__") + self.settings = settings + self.platform = platform + config = platform.config + + self.qsettings = QtCore.QSettings(config.gui_prefs_file, QtCore.QSettings.IniFormat) + log.debug(f"Using QSettings from {config.gui_prefs_file}") + os.environ["QT_SCALE_FACTOR"] = self.qsettings.value('appearance/qt_scale_factor', "1.0", type=str) + + log.debug("Creating QApplication instance") + QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts, True) + QtWidgets.QApplication.__init__(self, settings.argv) + + self.theme = "light" + if self.qsettings.value("appearance/theme", "dark") == "dark": + try: + import qdarkstyle + + self.setStyleSheet(qdarkstyle.load_stylesheet()) + self.theme = "dark" + except ImportError: + log.warning("Did not find QDarkstyle. Dark mode disabled") + + # Load the main view class and initialize QMainWindow + log.debug("ARGV - {0}".format(settings.argv)) + log.debug("INSTALL_DIR - {0}".format(settings.path.INSTALL)) + + # Global signals + self.signals = {} + + # Setup the main application window + log.debug("Creating main application window") + stopwatch = StopWatch() + self.MainWindow = components.MainWindow(file_path) + stopwatch.lap("mainwindow") + level_str = self.qsettings.value('grc/console_log_level', "info", type=str) + if level_str == "info": + self.Console = components.Console(logging.INFO) + else: # level_str == "debug" + self.Console = components.Console(logging.DEBUG) + stopwatch.lap("console") + self.BlockLibrary = components.BlockLibrary() + stopwatch.lap("blocklibrary") + # self.DocumentationTab = components.DocumentationTab() + # stopwatch.lap('documentationtab') + self.WikiTab = components.WikiTab() + stopwatch.lap("wikitab") + self.VariableEditor = components.VariableEditor() + stopwatch.lap("variable_editor") + self.VariableEditor.set_scene(self.MainWindow.currentFlowgraphScene) + self.VariableEditor.all_editor_actions.connect(self.MainWindow.handle_editor_action) + self.MainWindow.ExampleBrowser.set_library(self.BlockLibrary) + + # Debug times + log.debug( + "Loaded MainWindow controller - {:.4f}s".format( + stopwatch.elapsed("mainwindow") + ) + ) + log.debug( + "Loaded Console component - {:.4f}s".format(stopwatch.elapsed("console")) + ) + log.debug( + "Loaded BlockLibrary component - {:.4}s".format( + stopwatch.elapsed("blocklibrary") + ) + ) + # log.debug("Loaded DocumentationTab component - {:.4}s".format(stopwatch.elapsed("documentationtab"))) + + # Print Startup information once everything has loaded + self.Console.enable() + + paths = "\n\t".join(config.block_paths) + welcome = ( + f"<<< Welcome to {config.name} {config.version} >>>\n\n" + f"{('PyQt ' + PYQT_VERSION) if PYQT_VERSION else ('PySide ' + PYSIDE_VERSION)}\n" + f"GUI preferences file: {self.qsettings.fileName()}\n" + f"Block paths:\n\t{paths}\n" + f"Using {QtGui.QIcon.themeName()} icon theme\n" + ) + log.info(textwrap.dedent(welcome)) + + log.debug(f'devicePixelRatio {self.MainWindow.screen().devicePixelRatio()}') + + if (self.qsettings.value("appearance/theme", "dark") == "dark") and (self.theme == "light"): + log.warning("Could not apply dark theme. Is QDarkStyle installed?") + + # Global registration functions + # - Handles the majority of child controller interaciton + + def registerSignal(self, signal): + pass + + def registerDockWidget(self, widget, location=0): + """Allows child controllers to register a widget that can be docked in the main window""" + # TODO: Setup the system to automatically add new "Show " menu items when a new + # dock widget is added. + log.debug( + "Registering widget ({0}, {1})".format(widget.__class__.__name__, location) + ) + self.MainWindow.registerDockWidget(location, widget) + + def registerMenu(self, menu): + """Allows child controllers to register an a menu rather than just a single action""" + # TODO: Need to have several capabilities: + # - Menu's need the ability to have a priority for ordering + # - When registering, the controller needs to specific target menu + # - Automatically add seporators (unless it is the first or last items) + # - Have the ability to add it as a sub menu + # - MainWindow does not need to call register in the app controller. It can call directly + # - Possibly view sidebars and toolbars as submenu + # - Have the ability to create an entirely new menu + log.debug("Registering menu ({0})".format(menu.title())) + self.MainWindow.registerMenu(menu) + + def registerAction(self, action, menu): + """Allows child controllers to register a global action shown in the main window""" + pass + + def run(self): + """Launches the main QT event loop""" + # Show the main window after everything is initialized. + self.MainWindow.show() + return self.exec_() diff --git a/grc/gui_qt/helpers/__init__.py b/grc/gui_qt/helpers/__init__.py new file mode 100644 index 0000000..b0dc219 --- /dev/null +++ b/grc/gui_qt/helpers/__init__.py @@ -0,0 +1,2 @@ +from . import logging +from . import qt diff --git a/grc/gui_qt/helpers/logging.py b/grc/gui_qt/helpers/logging.py new file mode 100644 index 0000000..b0b1a80 --- /dev/null +++ b/grc/gui_qt/helpers/logging.py @@ -0,0 +1,132 @@ +import logging + + +class GRCHandler(logging.Handler): # Inherit from logging.Handler + ''' Custom log handler for GRC. Stores log entries to be viewed using the GRC debug window. ''' + + def __init__(self, maxLength=256): + # run the regular Handler __init__ + logging.Handler.__init__(self) + # Our custom argument + self.log = collections.deque(maxlen=maxLength) + + def emit(self, record): + self.log.append(record) + + def getLogs(self, level): + pass + + +class ConsoleFormatter(logging.Formatter): + ''' + Custom log formatter that nicely truncates the log message and log levels + - Verbose mode outputs: time, level, message, name, filename, and line number + - Normal mode output varies based on terminal size: + w < 80 - Level, Message (min length 40) + 80 < w < 120 - Level, Message, File, Line (25) + 120 < w - Level, Message, Name, File, Line + - Color mode ouptuts the same variable sizes and uses the blessings module + to add color + ''' + + # TODO: Better handle multi line messages. Need to indent them or something + + def __init__(self, verbose=False, short_level=True): + # Test for blessings formatter + try: + from blessings import Terminal + self.terminal = Terminal() + self.formatLevel = self.formatLevelColor + except: + self.terminal = None + self.formatLevel = self.formatLevelPlain + + # Default to short + self.formatLevelLength = self.formatLevelShort + if not short_level: + self.formatLevelLength = self.formatLevelLong + + # Setup the format function as a pointer to the correct formatting function + # Determine size and mode + # TODO: Need to update the sizes depending on short or long outputs + import shutil + size = shutil.get_terminal_size() + width = max(40, size.columns - 10) + if size.columns < 80: + self.format = self.short + self.width = width + elif size.columns < 120: + self.format = self.medium + self.width = width - 30 + elif size.columns >= 120: + self.format = self.long + self.width = width - 45 + # Check if verbose mode. If so override other options + if verbose: + self.format = self.verbose + + # Normal log formmatters + def short(self, record): + message = self.formatMessage(record.msg, self.width) + level = self.formatLevel(record.levelname) + return "{0} -- {1}".format(level, message) + + def medium(self, record): + message = self.formatMessage(record.msg, self.width) + level = self.formatLevel(record.levelname) + output = '{0} -- {1:<' + str(self.width) + '} ({2}:{3})' + return output.format(level, message, record.filename, record.lineno) + + def long(self, record): + message = self.formatMessage(record.msg, self.width) + level = self.formatLevel(record.levelname) + output = '{0} -- {1:<' + str(self.width) + '} {2} ({3}:{4})' + return output.format(level, message, record.name, record.filename, record.lineno) + + ''' Verbose formatter ''' + + def verbose(self, record): + # TODO: Still need to implement this + pass + + ''' Level and message formatters ''' + # Nicely format the levelname + # Level name can be formated to either short or long, and also with color + + def formatLevelColor(self, levelname): + term = self.terminal + output = "{0}{1}{2}{3}" + level = self.formatLevelLength(levelname) + if levelname == "DEBUG": + return output.format(term.blue, "", level, term.normal) + elif levelname == "INFO": + return output.format(term.green, "", level, term.normal) + elif levelname == "WARNING": + return output.format(term.yellow, "", level, term.normal) + elif levelname == "ERROR": + return output.format(term.red, term.bold, level, term.normal) + elif levelname == "CRITICAL": + return output.format(term.red, term.bold, level, term.normal) + else: + return output.format(term.blue, "", level, term.normal) + + def formatLevelPlain(self, levelname): + ''' Format the level name without color. formatLevelLength points to the right function ''' + return self.formatLevelLength(levelname) + + def formatLevelShort(self, levelname): + return "[{0}]".format(levelname[0:1]) + + def formatLevelLong(self, levelname): + output = "{0:<10}" + if levelname in ["DEBUG", "INFO", "WARNING"]: + return output.format("[{0}]".format(levelname.capitalize())) + else: + return output.format("[{0}]".format(levelname.upper())) + + def formatMessage(self, message, width): + # First, strip out any newline for console output + message = message.rstrip() + if len(message) > width: + return (message[:(width - 3)] + "...") + return message diff --git a/grc/gui_qt/helpers/profiling.py b/grc/gui_qt/helpers/profiling.py new file mode 100644 index 0000000..66e44ca --- /dev/null +++ b/grc/gui_qt/helpers/profiling.py @@ -0,0 +1,25 @@ +import time + + +class StopWatch(object): + ''' + Tool for tracking/profiling application execution. Once initialized, this tracks elapsed + time between "laps" which can be given a name and accessed at a later point. + ''' + + def __init__(self): + self._laps = {} + self._previous = time.time() + + def lap(self, name): + # Do as little as possible since this is timing things. + # Save the previous and current time, then overwrite the previous + lap = (self._previous, time.time()) + self._previous = lap[1] + self._laps[name] = lap + + def elapsed(self, name): + # If the lap isn't defined, this should throw a key error + # Don't worry about catching it here + start, stop = self._laps[name] + return stop - start diff --git a/grc/gui_qt/helpers/qt.py b/grc/gui_qt/helpers/qt.py new file mode 100644 index 0000000..e69de29 diff --git a/grc/gui_qt/properties.py b/grc/gui_qt/properties.py new file mode 100644 index 0000000..2315dea --- /dev/null +++ b/grc/gui_qt/properties.py @@ -0,0 +1,264 @@ +import os +import stat + +from qtpy.QtCore import Qt + + +class Properties(object): + ''' Stores global properties for GRC. ''' + + APP_NAME = 'grc' + DEFAULT_LANGUAGE = ['en_US'] + + def __init__(self, argv): + self.argv = argv + + # Setup sub-categories + self.path = Paths() + self.system = System() + self.window = Window() + self.colors = Colors() + self.types = Types() + + +class Paths(object): + ''' Initialize GRC paths relative to current file. ''' + + # Flow graph + DEFAULT_FILE = os.getcwd() + IMAGE_FILE_EXTENSION = '.png' + TEXT_FILE_EXTENSION = '.txt' + NEW_FLOGRAPH_TITLE = 'untitled' + SEPARATORS = {'/': ':', '\\': ';'}[os.path.sep] + + # Setup all the install paths + p = os.path + PREFERENCES = p.expanduser('~/.grc') + INSTALL = p.abspath(p.join(p.dirname(__file__), '..')) + RESOURCES = p.join(INSTALL, 'gui_qt/resources') + LANGUAGE = p.join(INSTALL, 'gui_qt/resources/language') + LOGO = p.join(INSTALL, 'gui_qt/resources/logo') + ICON = p.join(LOGO, 'gnuradio_logo_icon-square.png') + AVAILABLE_PREFS_YML = p.join(RESOURCES, 'available_preferences.yml') + + # Model Paths + MODEL = p.join(INSTALL, 'model') + BLOCK_TREE_DTD = p.join(MODEL, 'block_tree.dtd') + FLOW_GRAPH_DTD = p.join(MODEL, 'flow_graph.dtd') + FLOW_GRAPH_TEMPLATE = p.join(MODEL, 'flow_graph.tmpl') + DEFAULT_FLOW_GRAPH = os.path.join(MODEL, 'default_flow_graph.grc') + + # File creation modes + HIER_BLOCK_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR | stat.S_IWGRP | stat.S_IROTH | stat.S_IRGRP + TOP_BLOCK_FILE_MODE = HIER_BLOCK_FILE_MODE | stat.S_IXUSR | stat.S_IXGRP + + # Setup paths + # FIXME: this should use ..main.get_config_directory() and get_state_directory(), probably. + ''' + HIER_BLOCKS_LIB_DIR = os.environ.get('GRC_HIER_PATH', + os.path.expanduser('~/.grc_gnuradio')) + PREFS_FILE = os.environ.get('GRC_PREFS_PATH', os.path.join(os.path.expanduser('~/.grc'))) + BLOCKS_DIRS = filter( #filter blank strings + lambda x: x, PATH_SEP.join([ + os.environ.get('GRC_BLOCKS_PATH', ''), + _gr_prefs.get_string('grc', 'local_blocks_path', ''), + _gr_prefs.get_string('grc', 'global_blocks_path', ''), + ]).split(PATH_SEP), + ) + [HIER_BLOCKS_LIB_DIR] + ''' + + +class System(object): + ''' System specific properties ''' + + OS = 'Unknown' + #XTERM_EXECUTABLE = _gr_prefs.get_string('grc', 'xterm_executable', 'xterm') + + +class Window(object): + ''' Properties for the main window ''' + + # Default window properties + MIN_WINDOW_WIDTH = 600 + MIN_WINDOW_HEIGHT = 400 + + MIN_DIALOG_WIDTH = 500 + MIN_DIALOG_HEIGHT = 500 + + # How close can the mouse get to the window border before mouse events are ignored. + BORDER_PROXIMITY_SENSITIVITY = 50 + + # How close the mouse can get to the edge of the visible window before scrolling is invoked. + SCROLL_PROXIMITY_SENSITIVITY = 30 + + # When the window has to be scrolled, move it this distance in the required direction. + SCROLL_DISTANCE = 15 + + # By default Always show the menubar + NATIVE_MENUBAR = False + + # Default sizes + DEFAULT_BLOCKS_WINDOW_WIDTH = 100 + DEFAULT_CONSOLE_WINDOW_WIDTH = 100 + + DEFAULT_PARAM_TAB = 'General' + ADVANCED_PARAM_TAB = 'Advanced' + + CONSOLE_DOCK_LOCATION = Qt.BottomDockWidgetArea + BLOCK_LIBRARY_DOCK_LOCATION = Qt.LeftDockWidgetArea + DOCUMENTATION_TAB_DOCK_LOCATION = Qt.RightDockWidgetArea + WIKI_TAB_DOCK_LOCATION = Qt.RightDockWidgetArea + VARIABLE_EDITOR_DOCK_LOCATION = Qt.BottomDockWidgetArea + + # Define the top level menus. + # This does not actually define the menus; it simply defines a list of constants that + # to be used as keys for the actual menu dictionaries + class menus(object): + FILE = "file" + EDIT = "edit" + VIEW = "view" + BUILD = "build" + TOOLS = "tools" + #PLUGINS = "plugins" + HELP = "help" + + +class Flowgraph(object): + ''' Flow graph specific properites ''' + + # File format + FILE_FORMAT_VERSION = 1 + + # Fonts + FONT_FAMILY = 'Sans' + FONT_SIZE = 8 + BLOCK_FONT = "%s %f" % (FONT_FAMILY, FONT_SIZE) + PORT_FONT = BLOCK_FONT + PARAM_FONT = "%s %f" % (FONT_FAMILY, FONT_SIZE - 0.5) + + # The size of the state saving cache in the flow graph (for undo/redo functionality) + STATE_CACHE_SIZE = 42 + + # Shared targets for drag and drop of blocks + #DND_TARGETS = [('STRING', gtk.TARGET_SAME_APP, 0)] + + # Label constraints + LABEL_SEPARATION = 3 + BLOCK_LABEL_PADDING = 7 + PORT_LABEL_PADDING = 2 + + # Port constraints + PORT_SEPARATION = 32 + PORT_BORDER_SEPARATION = 9 + PORT_MIN_WIDTH = 20 + PORT_LABEL_HIDDEN_WIDTH = 10 + + # Connector lengths + CONNECTOR_EXTENSION_MINIMAL = 11 + CONNECTOR_EXTENSION_INCREMENT = 11 + + # Connection arrows + CONNECTOR_ARROW_BASE = 13 + CONNECTOR_ARROW_HEIGHT = 17 + + # Rotations + POSSIBLE_ROTATIONS = (0, 90, 180, 270) + + # How close the mouse click can be to a line and register a connection select. + LINE_SELECT_SENSITIVITY = 5 + + # canvas grid size + CANVAS_GRID_SIZE = 8 + + +class Colors(object): + ''' Color definitions ''' + + # Graphics stuff + HIGHLIGHT = '#00FFFF' + BORDER = 'black' + MISSING_BLOCK_BACKGROUND = '#FFF2F2' + MISSING_BLOCK_BORDER = 'red' + PARAM_ENTRY_TEXT = 'black' + ENTRYENUM_CUSTOM = '#EEEEEE' + FLOWGRAPH_BACKGROUND = '#FFF9FF' + BLOCK_ENABLED = '#F1ECFF' + BLOCK_DISABLED = '#CCCCCC' + CONNECTION_ENABLED = 'black' + CONNECTION_DISABLED = '#999999' + CONNECTION_ERROR = 'red' + + # Alias Colors + COMPLEX = '#3399FF' + FLOAT = '#FF8C69' + INT = '#00FF99' + SHORT = '#FFFF66' + BYTE = '#FF66FF' + + # Type Colors + COMPLEX_FLOAT_64 = '#CC8C69' + COMPLEX_FLOAT_32 = '#3399FF' + COMPLEX_INTEGER_64 = '#66CC00' + COMPLEX_INTEGER_32 = '#33cc66' + COMPLEX_INTEGER_16 = '#cccc00' + COMPLEX_INTEGER_8 = '#cc00cc' + FLOAT_64 = '#66CCCC' + FLOAT_32 = '#FF8C69' + INTEGER_64 = '#99FF33' + INTEGER_32 = '#00FF99' + INTEGER_16 = '#FFFF66' + INTEGER_8 = '#FF66FF' + MESSAGE_QUEUE = '#777777' + ASYNC_MESSAGE = '#C0C0C0' + BUS_CONNECTION = '#FFFFFF' + WILDCARD = '#FFFFFF' + + COMPLEX_VECTOR = '#3399AA' + FLOAT_VECTOR = '#CC8C69' + INT_VECTOR = '#00CC99' + SHORT_VECTOR = '#CCCC33' + BYTE_VECTOR = '#CC66CC' + ID = '#DDDDDD' + WILDCARD = '#FFFFFF' + MSG = '#777777' + + +class Types(object): + ''' Setup types then map them to the conversion dictionaries ''' + + CORE_TYPES = { # Key: (Size, Color, Name) + 'fc64': (16, Colors.COMPLEX_FLOAT_64, 'Complex Float 64'), + 'fc32': (8, Colors.COMPLEX_FLOAT_32, 'Complex Float 32'), + 'sc64': (16, Colors.COMPLEX_INTEGER_64, 'Complex Integer 64'), + 'sc32': (8, Colors.COMPLEX_INTEGER_32, 'Complex Integer 32'), + 'sc16': (4, Colors.COMPLEX_INTEGER_16, 'Complex Integer 16'), + 'sc8': (2, Colors.COMPLEX_INTEGER_8, 'Complex Integer 8',), + 'f64': (8, Colors.FLOAT_64, 'Float 64'), + 'f32': (4, Colors.FLOAT_32, 'Float 32'), + 's64': (8, Colors.INTEGER_64, 'Integer 64'), + 's32': (4, Colors.INTEGER_32, 'Integer 32'), + 's16': (2, Colors.INTEGER_16, 'Integer 16'), + 's8': (1, Colors.INTEGER_8, 'Integer 8'), + 'msg': (0, Colors.MESSAGE_QUEUE, 'Message Queue'), + 'message': (0, Colors.ASYNC_MESSAGE, 'Async Message'), + 'bus': (0, Colors.BUS_CONNECTION, 'Bus Connection'), + '': (0, Colors.WILDCARD, 'Wildcard') + } + + ALIAS_TYPES = { + 'complex': (8, Colors.COMPLEX), + 'float': (4, Colors.FLOAT), + 'int': (4, Colors.INT), + 'short': (2, Colors.SHORT), + 'byte': (1, Colors.BYTE), + } + + # Setup conversion dictionaries + TYPE_TO_COLOR = {} + TYPE_TO_SIZEOF = {} + for key, (size, color, name) in CORE_TYPES.items(): + TYPE_TO_COLOR[key] = color + TYPE_TO_SIZEOF[key] = size + for key, (sizeof, color) in ALIAS_TYPES.items(): + TYPE_TO_COLOR[key] = color + TYPE_TO_SIZEOF[key] = size diff --git a/grc/gui_qt/resources/available_preferences.yml b/grc/gui_qt/resources/available_preferences.yml new file mode 100644 index 0000000..090a1b8 --- /dev/null +++ b/grc/gui_qt/resources/available_preferences.yml @@ -0,0 +1,192 @@ +# This file specifies which settings are available +# in the Preferences window. + +categories: + - key: grc + name: GRC + items: + - key: editor + name: Editor + tooltip: Choose the editor + dtype: str + default: /usr/bin/gedit + - key: xterm_executable + name: Terminal + tooltip: Choose the Terminal app + dtype: str + default: /usr/bin/xterm + - key: hide_variables + name: Hide variables + dtype: bool + default: False + - key: show_param_expr + name: Show parameter expressions in block + dtype: bool + default: False + - key: show_param_val + name: Show parameter value in block + dtype: bool + default: False + - key: hide_disabled_blocks + name: Hide disabled blocks + dtype: bool + default: False + - key: auto_hide_port_labels + name: Auto-hide port labels + dtype: bool + default: False + - key: snap_to_grid + name: Snap to grid + dtype: bool + default: False + - key: show_block_comments + name: Show block comments + dtype: bool + default: True + - key: show_block_ids + name: Show all block IDs + dtype: bool + default: False + - key: generated_code_preview + name: Generated code preview + dtype: bool + default: False + - key: show_complexity + name: Show flowgraph complexity + dtype: bool + default: False + - key: custom_block_paths + name: Custom block paths + dtype: str + default: "" + - key: default_grc + name: Default GRC version + dtype: enum + default: grc_gtk + options: + - grc_gtk + - grc_qt + option_labels: + - GRC Gtk + - GRC Qt + - key: blocklibrary_expanded + name: Expand blocklibrary on startup + dtype: bool + default: True + - key: console_log_level + name: Console log level (Requires restart) + dtype: enum + default: info + options: + - info + - debug + option_labels: + - Info + - Debug + - key: config_file_path + name: Config file path + dtype: str + default: "" + + - key: appearance + name: Appearance + items: + - key: theme + name: Theme (requires restart) + tooltip: Choose the theme + dtype: enum + default: dark + options: + - light + - dark + option_labels: + - Light + - Dark + - key: connection_style + name: Connection style + dtype: enum + default: CURVED + options: + - CURVED + - ANGLED + option_labels: + - Curved + - Angular + - key: line_width_factor + name: Line width factor (relative to 1) + dtype: float + default: 1.0 + - key: qt_scale_factor + name: Qt scale factor (experimental, requires restart) + tooltip: Scaling factor of Qt GUI elements. Note that the effective device pixel ratio will be a product of this value and the native device pixel ratio. + dtype: str + default: 1.0 + - key: max_recent_files + name: Maximum number of recently opened files in list + dtype: int + default: 10 + +# Runtime preferences typically end up in config.conf. They are grouped in a single tab. +runtime: + - key: log + items: + - key: debug_file + name: Debug file + dtype: str + default: stderr + - key: debug_level + name: Debug level + dtype: str + default: crit + - key: log_file + name: Log file + dtype: str + default: stdout + - key: log_level + name: Log level + dtype: str + default: info + - key: perfcounters + items: + - key: clock + name: Perfcounters clock + dtype: str + default: thread + - key: export + name: Perfcounters export + dtype: bool + default: False + - key: enabled + name: Perfcounters enabled + dtype: bool + default: False + - key: controlport + items: + - key: edges_list + name: Controlport edges list + dtype: bool + default: False + - key: enabled + name: Controlport enabled + dtype: bool + default: False + - key: default + items: + - key: max_messages + name: Max messages + dtype: int + default: 8192 + - key: verbose + name: Verbose + dtype: bool + default: False + +# Window settings, these are not meant to be changed by the user. +# TODO: Is it necessary to have these in here? +window: + - key: current_file + dtype: str + default: "" + - key: recent_files + dtype: list + default: [] diff --git a/grc/gui_qt/resources/cpp_cmd_fg.png b/grc/gui_qt/resources/cpp_cmd_fg.png new file mode 100644 index 0000000000000000000000000000000000000000..c9756f8bc45d57a5e0aa2dd393efb8bc4da1f651 GIT binary patch literal 26946 zcmeFYbx>SEvp2kpOR(VX!QI_GxC9L>uvl<+76=mDAxLm1xI=I!xJz&g9z5u`$s_l< z^}SVJ-Ky{X?=E%r%$e!w>G^d}Pfs8AlbVVg8VV5#002N!keAi~0Dx-H%NYp)T9Y3Y zv;)0Of^`)v6ciW%NYDo;B@hk(gqGBxU%-EsouOqIAS?hDS|19%UPH@&X#QM8K={A+ zC}^4ZpXDTInf{+NfBJ9$VE|~*dnWYagqCrj_cG{Z9rlm0I-uo0AEdv3RoGA}=+nRM zatg|76r8La?5te8&=z)1Zb5cVL3Tb04nF9&AQwAyd|=LhN%V&kg_WQ!RKAbaQc`LP zQc@I-&JGsVwqO9jGt)0#M7~!ICqz}VkO~)GD4gHCL9=#@bfcd%Q>8wCecv}>t2`hA|8zeLB|_RrV+Ad@BRV}}v2 z>Gl~v%_YC@>GV%B!F^+NW_)^sAW3qYjU8d)10qScpXQN+t(JoWqKug*0Z@s_pOETjL>Haj);pEw}-YO~k znZ#x;q9fqoG|$L0-%1w%;;*V=cFCGYO3>|lO%txBCPj$57(P{*XiBNPk4*kjeUv&> z7+3yEHU?fRSHk^iBjDVpQ3qr*t%O1cImI9RO#m<1CSG^Szdjfz9}&BsIll6m@3pYnMj#Y%LH$Y90GIhFT0)ui{KEr#^YfB)g@CDK$xz5 zh``SeHxQBZ?ey~W%XrJv%1rt?hK_=BMi}NsBh&^xt+jMrb(P-=nmO39g3KLE!K|Kk zj(-#e5Ek=v1etvRyHc2fEv@ZEsLt9tsVJ`HfQ~coxGIels6``Vnwp08|K08Nc z<$trccljp@P(0W?L5^%3tn6%dc5MII!^Ks`9m?dN0{tI*xM+Dfg4r~{E)H(aW?&h2 zu)Qnwf3h$)`*(jwH)q?w(=j(=1KWb_prkI)Q91tGkbhR(zkB?Fz|z{z@oz6E*#C{v z)!O2}!1`~p{b~6-o&OXB+Wp^r{~Ptc=Khx$N~NqUDD7b8_D4JgX%VVF;|rQQm|2?( z{w;D_@PoiS?3^sTru<+QZaxs01!OM3$pT^r32<|oa#{%RvHu4v1$!4)ki8lB4=X5h zR%<99GYd`uE&)CP76D#fZWeB^03VBhDX%FDrvML#kHehLoDanNA1qXzt)W&4vi(n~ z{;)EKvI2v_d^}vH0xawT<{T{C-0bWu0w6Fii#gcT!h*ws&q4si`InWsnV_tLvmFSk zPHQ`mC78|8-tuq5AHW4A)D%RhI9dN8_}>~eTac>-w1WuMTWfna&;KITvbFM6=v)M) zoWUSh2WKq@2U`)Ue|z13H!DMzlR3y0Bn@%}Lz({D4{QC)4>PfI2(tehcR^(bb88E) z|1au4i-$t^A1ar(c7cxX^|$IDD@qgW^pB^1Jlb0ST}l)be;0)y$m}0eZ~?i4&Hu^? z<@JvyGb@n2B^c^G{%NlNs<-|>n1TSFxf7Y7Sh50Eoh!V-!j6f3BC{@tM{=>IB;;lERRSb_g&0SXuk)MVND{skDv zKLKO==Y-k*_>6zeSeWhqg^BQAg8y(b(0>1@gE}v$FJ$|-GyErJf85Uh#m_%=@qcjy zDC_?nm2k{zt_BYhC}p!G-c)$0@KqGz#*79+o=L z!x*6lEd)~~IqAPgF2HZ8wg6}e*->841puIX|K|cubnC`ID-m54lw}YPU=T48$uZhY zW}#OPYoY>#YPQA;D_o914t%h8X`f)wmP`B*n^v!u zf&#Gq7V`N-00op45l06@NBJ54fJ5WS1~WVO8a`-(eN0uF^{dSRBLf2@6fw;s0W#r* zVzmzuMoc4>tFna24)!JOd)Og3f|ut>N>0KNZ^IZfAe2!!;F<;HmgSB_u0{2kUs4O4%zXte?^K%c;lp}>`OHmOe=Otv% zl$$YYpYk^_Jn}xKa0z@O7g1bI(n7V+rpgnRph1pM^4%1G{v_0O(#1XJmX!kU3!q9p z>>9QCR8=q=24KWLsB?5APWTWAHwPBcpw!sIWOwObx7HZTEx;)TB%@yGS*^kvhB)70 zG!RMbh;Q6dg$d4)C@MO^sI_znAS$;J@ARW&g5Gfjbq4=}=qk3@pwku-z?T>nkfS{U zhg-@FykQHrRA~^N>B$OY{)iL>(6We+(0qYU$N+{NCcHn1hNUHb)=I{ds^U!-P(MQ{g=BWV2R8l{>~tWOxCTi>B>7$Z5JyT$X+Z&CS<};^&4Oa5 zGmxVOSs_9Khhm491|CTdVvVOE2QDEQ-8G__+9E@KD+^Z)NsBTkVuvRn$ls<<&2fqx z>Q~cLgqo|6iSCXt2E%&*Lf=qZ6M$I4HwZJE>ao17sp5I z4BA)saK%oAp5crH|rIp#Bi;!CRz>8mgjvRJRZqvsO_BT$CFX#K-`r(j!Tex*8XJs7y zqObB^g}N3U`9*07Fb8~_KdI(^Glw(f9~h+v($(~7{E{~@F#ahnL?3yiQP7UY&k|9# zCl~RdzqX6XCLOvW{RKb{{n0;tfqjMQL2aNW&&0*JR1*fy& z0?ETkww-w9lJ14Yce7|VX4V1YQ9Gemm`SC}tgO?GQXGdG6=- zq*nrw#zf~yelD7KQsUs z6^MoJ$bD;s`xt=9?N9Gqs$(<{8@JqTlmZyM)xg!qx-hJnH7xX&DpiFpEunx2OpGgy zCmqTb8D=itl1daTY5eU1Nn@l{xidr3uWw4SXq=xy%J`DDh;lA07Qng}V5)$uNknIk zpL^u}rp$B7o7J*}5w0RA7Y5(h4^h~o6Seq-3!C9fzB?;-Q;Jbvc&uDX_`chLyU@P< zNSJrCc7p*C0}f-6c{`|+HcNY{(f5;z?@A&BKEuar7r%XRPRHer#a-dB%;-Hbkjw3CR zPnZ&Zrb?X;mo$a0E4T^ZIFJ=^FQc9fSF@eefieG}mBG~S41*PV@Js$TWB4YP_fByE zv1LglmLdnLnvh0oN5|NGL|@{IY`T*rjmpxlDfdX03)YqmW@<1Ao|NA#TzfPo+Xi|& zRw->~`w|xdp)c`p(vX?~E8ggLpSdKh2xSQC>?I_F%G3kJDO;CFm&9L5GaKzYqLX%% z#HGBA?1kX8cPOa4srTtuji$y?6)c_H#t-H=%TFYi)I2Q7rYJeiftQBdzwF_@Si*d! z8ua9}O?*ZWrfFscex7TTj@2C2nEWEapAufU_5|`7`Qv`Pv3B6GswK}}kA4>`wJyam z0E|&#g*{iX!_pyb`B2fFPgH;ZW;;D;=s^YMt;xvzDue=vv!kltTX(YG3-0n);WPnGD5Y;Hqp+poHFPb6R3*YKA^BP!*Sg9`_Sj0%bdoSrA@{d| zolKsL3=lHHJamn#Vl2azVNT_b!rzM32chohXA|Xb>iCw_wbU!_9$lOWp<7Oa0FrLB ztjC}4kkj9lCowDfA>7W6(NtG__ZXZwBRH)q73J`zzDVuGC4SBv#L`Z@!#dLum z!Ht(V1r4ocA7mM%6=n6ShC;@(uaNSTqOOsCM^ZnoGdU8cexf@s-P1X&1Bf8yt;2p1 zj!Jg&P*w5VrCMk}s)3Q${_$E}K>SgKb(%a$M8PZ5;+c@2`}aEIM>l!jIw z(DAwBy_qp78PdE4ICIwMXy!&UN%eIpe}ST$2J6p(o>N&b8>Ni)}O@7=4lE~ob@n+BOm9c0G>0wI@b0#FsXeY%YmU{rAGDEk4 z+9s9Amd<>iLt-W^b2$)q2r0%t)!xL}{W8c_pOIEzPy{eKyn$CTMvVFtbn75-&KRER z1hXQ5~BNMW3#AmK-K(j{AD19EIq$F?b9Mmi^M5h3@Zy%U~im|pR zYiO!@bOhxs5@KdPM^DyDpq~_iE)aeODpAKvXhWm-D@TfwUk#t;XYhNi3nLk}SN(lI`-N`{>)Psz=bsQ8lXS6ma#d%?G? zRD?etDZ{g2kg!yk}|1|tji=$yW}dM>`BafV5y=x-4B z`#8@;-4bukW0#af)$-9eD$)6=lobwq8WT~ybG?P-;%0m#{W+PPXv|lzV2QJ{)QJCz z%hZcpPl4eL5XG)ip!D9X>yaJjg=4x|k@?6euWm8qK$xVU)gShP4n*%yKJC=H{Gz3C zNDCo@o9cvJM+Mag6c9I+k_g$^wX01akWsmMoZ|{7C*=Wi&Ku}eP<`H~-39C7-F-S7 zkV>ar`Ni__hDyY(3t!75f=k*yN4%jdVVV#37p5}|&CdZA@%Iz-l=w`ZWVz9)@GsGm zdlIS|R^iSC?yW(^2^u(hIbpha7(hOc?S<4&BxbpX9Wg1Fm0lFtM+0HCC=BC=);$7$ z&SxbCVY>rB1uVk1b;kN{4*a-lk3)?2SwC&523|roc9=v3Zj>w zMyBlTCC_h21BK`SihmH(odOND7&QK@>*C3}oATZui8rgz)RaeVzNC?RC#3{S{+o0}gP1DI3NF+$Il#$Ci$(J~1)7wiG?k-fhc90f~9WyTZZx6IHx z$(N(z@CP1FSZE8I5$GY`-Jggk&jfa|n_hFr{In&CW)0dEE@;N*FHJT6fg!9s=@pywoDbwy%Og7%FP^GkM37d8l7r~hC?X^Mb0+}G&kYBDMV~T`o0ACp4)YRk} z<%S9vD>mkRquZcI+Vj96j&bxF+7x7Aq*viv9ZSfZnmcLaVRzg01PlT2aG1;5rll`# z@Z-L!D=0f_ypG-nEwQecLLZJ?y2t~!+AE@RBx7K#%qbba#`bYu2FE3Nz>>Yb3d5+2 ziq?V0+FB@pKSfO@)-q5j`(d?(P(yQGC5o;B{EmPJc$q;McPt_BB)GGDuM>(gkOv_0 zNzgm}#b1#c7)5Ygc*`DrB+WWUX&q@hq3-wF-iMeU5%wkWTL>37j7vA55w;Wfet2|* zT}=ZJjqJ(ls4P{t2FoEFYHWrfDP+uieWugfD0fZ*XNdF&leUWLLV?si-&kQ>E?x#8 z`f+YpGJ&vG^q_J+HEXmh@1@@C2inHTBywbRq-!isFHbGkLT-n9WME^;SG$gbvAXKM zw0M$cUMoDJE1jx9fZH!KJjp^R#wI{pMKg@dl4H~#_y(T@dkbU(T#sBU0J#M>tk1kE zdv5k4`Q}VMwQR*hoa3#@zw|`l357p{fz2=9qNuQ7Umkc{WyptafBq}ny}{5aNEx?cu*^||G@ zS&biyF??mzuNo1|6{wOo=D)8#O3(F< zMUZU&5=Bx5769JC-kLnP3}mRA06x@Ubc;@mj+`^vLeno~Un1oPobBbp<3awj_!Mhl z8+7u1LOdN$rUKnkhnz_{E^pG^-wZ`U^gxxGTs;EH%-5%9Qs=I)slZ6c%tIGblZi2^ zQ3}*lb#j$}+72hm zt{=~J@EL8@6@Kl@6|*S-OpDEu`=Cai-DUt;hkY3z9VvN7uL(F2O~Vs3hH(OiN3uw6 zFFQ(Gr7n$gVQz7l7>4`QLAcGl9d?aTsQD8i%oNx{ti@`Y_yD$ZYhrn^q0y0Qtwef= zJJy%eVG}#*f-uA_BbdMs{bsYFU)Ma?F*`3AFjGG^%JmD^aG#rILb!lJ-&Y+V*nqI~ z+AyQ}2gu+`%C*c$0Jn2QFt*ZBgFR8;utz!?i_+zrS^Y>BAN0#~-g*EE!jo!4+ z8382KuY}GANZ=>jqjTVnd+R*{v%!0N#ibVNcvgcI)zlSd)LKp?A)bl#U0_oV3%D`F zZpkOgW4~`|5?9A+{c6$d+&s2T1wu7ujp*v5*Q}!ecbLdPzND4JTJhM598os&^P~zo z4o5ed&7~XoMCiUn2}Vl&vDH>HpA0oD*qUIX7sLk?i}WDNrUEk`24O_uc&J$V2I9E1 z;6SpT_K-#Pn`5Ywa1(9?!~wApReCWs=n_`q_QRKX1YfKd z-?6Rtjms5m5c*45!&T$xe)*j7lGWJ7r06#fV^kvc=_GEg7cb6IMpNag(REaYQg`GO zM_^!wK4>qsxF6rNjW!x_cFpi?2(^9T28#)*cXsW>D*@CbO(~F{EVeMPPh+H}H|<(0 z(HyTg5AKV~@rP7ZGjjX4I_JmQw189?a+t^!dryXITvRQK#L!75NwrzYcr)h6j@ubk z@ySbKH4Q_C6!`Z)!R0R8?l4VCH9r!P@|5tZQHOvRP-yhE68qZV%LJsbtk-C)r1vRD zInB_ji}z2=m5*gSi$KNGy8Quug2{P_z{DB{j&FZ-k`f_CbOuN^^+i(DBQFy^0_oz( z9~;n4O{y5d7c+73y%5Ce>(uFvYRaA$my{+ZzK3G5<0RjKGAO!mVX*1rjX= zOQl{N=dcNU-+0@zw%$%k5R7t%6)T9R3$q#rRhH5QHtq?-Ug|k#%OES+U#3JmD`KYt*}OZXai|b9 zfRO`ac$m(p^(T&->Kif}-0%~kh|@BQln4RIpEI({m?swvdq%%}p~Q*;lIv3P^sx5_>OewFyv~>?{4JGt|O;hAc`EUW$S9K1J|q5iOE0oDlPZ+ zCg7e?+Ji}5#Je?1sS8c%o*v+Ip`c|~0*Jo3E|CZA{;H>6FG|COQTkR9dH%Lv1^$zVVyFKaO24C=kLK#nK_#c4#CSjj5K3?o= z9SXctf%yVgISkc#)X5Ew8lDyR5T!?0PLdfkBPFwY3*sOeLX|QBJDdPAsh}oiyMcV% z3n@kUqDYy9*bF07+4}_bBHP<1@Mg0xJnlhc$D8I0@%Olxq*Di-MJW3WNF1DSXbrna z8?zAgMX&Hm$rf6Mkas`3Vj<;RE|)Kh7M~mUz~CX>fSR;=@qUf;z-SS+aN_(+_{*SRL!< zc*ILbzE`0?^+9#Y(k1e2y!~pkuuCD87$mh_K>l6-T`(!`bDMa2dYz(rMf9Sz%$Kpz z5i4i_^qup$l$ZZX%MC6|F4FL<rrjqDjc1i8zu$%{5`FCTm9V&0p)i4FXnK4FaL(a|$nR09>HA(EEBnB! z7V4_Q++1&Z1d2gz$+DjIjTQhcvh4c{K!%d>M4DvWa(8R!6m^u@6)gZIz7uD1!B8}SaYM2) zPLHDJ1hJUbx5^TQp7HRuz36M-sGyOU1duxkvlWPeJT8gZE49d2-u;`jX0}bt_PT=y zL{s#e%F%Raq(JP2?*!b~idbr0jN@tMEC_rk8Pa%3jzpX~`luJI^Z?`noVy-ESi z1|@>-hK6hx=F;|B)q1c^_Tz-0N3Jbx`wfi^Z4(8kUpTM|7bC*wQ`<4k)H*nVL`~w4 zE5>y~Gj13@hO;nF{M^4j#O;T*rWf&sAQtV{PJAvXO4l$}!#c+|@p`W#+X7>yx>-Nu z#i`*-!dtFijTc$$bjTJFW`N=9U#b!ibib?Xi_yKb>HU&kaMTj8byAPyc2GHMi37L@ zgvK5t&>aLv+y|OF@KK4a<4ZFPUQvi{;GE4jld^eZFiy3|aAX zJbs}MXT0WtuSq0%#u|#Hk>5tm;oG2;Foq|Yc_*i#_aYKoS(-vEOZ8lKK5*YO@V3G$y7s=CZRBykS6hIqW(PNb*J?v z53~N;PgqIeY;+lFQ{_GiJYCs=0XtwYPFUGw^O;i1pakaeKIQ~u_Z72iB zTU0KqO;osF2toCCZ_9{awPqidG`EY(mMAdm(I|5vMAmk2Wbj|0o4S=Y;N*Ls-k(=b z6Mc7ItTs2=mX-n-@p0Az<9JZ=vItgLizyTq4$ho7DOmz*etvqTs$k+M#>ZMLjs%i= z8(yu9z+l&4c#Wu)8RXtr}ajC)zd@awb1YR_wecfxs~VLf-5+cbpALFsRVgN$2=h(68+;> zPRMCR->#}YpjUf2P#Ygx=alvL+18mP_e>ubcA0g(0I!V%tPWks`Mj8(IIayhgr?pv z-1n7xd4IyoJp2`sT`@$sv-jH`=(HuY8!qU1-6B!?x0q!{ z!So(z=3zeP3#*lIlj|oYhGk(~MaAs?o??b7Xj5`>RbT}oeYB26^-+8uq7p04bHurO zbZ!;sPYC|;zW}0rk(pE`KzGdj92hH~ui$;t7vl{dNIBo3+SCi8Z<;^l8C~qp`7^oK zd8HC!boIxU&NHl`eEVG0*F0VL8mR4Wep4wX6x9Ws!wJ{D;c(hglSNbL`Kcy#W5Ux& zbj)hE75U@3g3YeG_MnbDg}vleGc_C2Haz^~ID}gW1PA8;dnEXgfbhBS-s%RuH3iq& zVf^X$0)L4j#nz}XfYz#1FEo1a%l5XDYyRALmeY{ zYiqjwF^XsJ{EHMt=2J_%jRjXXp;_Bagq^?dUnC5}HhfK7OGcn3$v^o*#fu>rll(NG zxsCL-o7g6t?C9%WMf%*^60g@`+OrQ?+h1PMu>}e2_YOJPRFhG0S=AIa2Tr)p*8CJ_ z3ZbMjq5`7~vAu8Pv#C-AoNg+M~-(IaLKU|Psf16QFe@TaC(F^3eD0)69n1kE8~KlAUWrjLvBmc(VI zA6{qTjj?>SU1~_3xJl9c$%p>q^Z@bLWc0&tlpLcbQ94*LAVR@Jm9`Rp>KsnHa1QQAqOpC{dLuovPTpC&WA7TmJ|mef}W3Q$W!7 z<|{P#$x6L!7=HZbK!R7}zD~XNDojt{b-e-dirVDraukcqGJ8%^s$wa#t2pU_LhnWG z`JjdBdqd(hi@1bZMNu~Tyqv~QDJE?mrtooa?PY*vo}(G$)mJ3=AKW@?{e{*UM`ZIG z&)iLZAS^cGTYL|Jb8Eihp+b6qSTBd09}@W{@e%rMeGBfUXmCTbUDN(OzAxItYs5V@^K%k-FlZxrB?pTFg&$$kb>mrAzax89e_$>|>`-X@Dog!{bx|E< zFcXcn$tG56XP)#e;S$D=TKcjZELA538qD*DB?FD|ks#BE4EnzAUP((MiPP?cxPlW$d`YqQ`-~}V^ z$=YCOgdmKF7`scE`KHm#q;BbfakKiKPPg1+nJSrI+ zAK_95#Kg*Tcw{kBnMd<#NysyT zUfA*Ww+;Q90|{^V?KMyUFLxDGdnyMR=?04)ISFovWl|s~|83%Y08v%k@$%?x7n(ArIs?nM)#5oT&ffL= zZ^Sx1s4s}0J2kpLuLK_aO4MX$8P+Tx%|A3zP*gDTqYil(UD8=0s3F$2DR7m5#`~9n zba@T_d^-LEnbR56FD5MTdyfP$Lc{~g@ZT~53y2R*Mu~KP!I5~@cn~>F6|--9@+_{X zPbO3>Y7HwLm9zW&J}f4+7@s)Zg0*F+D&(<};e2Z>PAcLqVaFlP7CX=y&6VELdO%h$ zPv_HiUd8@W<=C?nCs=RE!7qZ$?k$|@4ga=%Rb^khjkOj{ubF($FxN0+`QbfEcla#a z+s@%rM&qiv-ZKoxoc0JF_Nk>7g_w<(XLnt$SNs%BAgr->4u`jfE^MH>cp zc$I(SE1#`={X?b0Wdy0{)u*?Pc%up2ygYSI53KLk4TnyP=GUqPJME*(L6{GMZ5P2q z!d87A8BO_U?()9p;c6^{}0 zMCFPIsU>Mluda^Ve{IvNvBNO`AR^LT;)^+b>lqcWa zO?MQ3JpdD-P}%|Z6(Ne=XwO4b!&zzIJbbi*>R#FQbnBH+w@KU+;)q^WXCwv+hMHxc zN{Sl%q%LWR1*%lW7nJO4uis*zp85la@L!sn)5X0=lVTE@!9i~gjo z76>ent@pAzc8nWOqz{WTBt9#&C^;Nunnxb5D|@L0e95R%dfKx5WbV+D&AE;jOArk4 zlQ!HP?FZuAQ!k>G$8e5W_fC|Bmeki@*QuXDVbI!6Zw#4;xEr4^Jz~s14e}C0HvTQd zR{~s+iB?qF3i`~j-kfSrfY5fEa<2Z&D#}z^TW35_jU#TRrouqD{HX4UlKtB(ji364 z2<(jTwv$U3?BESwP>Apz-)*E`%c3!BB%??{2F%06hjR`BG2r?Kd&)Ny0EmLqH>>)= zp$tOJF9J#oBLp=-(R4p zmSBB-=&j#tWo-Nks~oJ^3Cs48EN7Tjh+)3S|HS;a(x^*u{fj{(e53p9>UE$z2GqUbIVfuob8yzj!)l_KENk{9cj9O)_ z-ctNY^@f|nJuX9&4&CHp$iDzXr3-7Df+rZy=jPqzSuD!k$wSC&n%{nxGLU;9$@Vi1 zoK8r`#^?QhRq8Q*x*dR9hH)IxmF9_~N82mPE+Edr!%|aG0;R!+&m5eeT4po};}ZxH z+vw)5zqa3Jj|q1=W1PLKziFY7fTCHHAN8hZOI*y``H_2i@h6DEJ!(q6A%9<@pws-V zcKL8b7FxyHOrM@6g7e`ljs`-r+16xfY5znz%fs*9%OlUK3c{led`*CMgbo@~D1+;d zjru$50`_l8FYFE9Ch9%?Tv#%0c$@}iB{JCsLZm%hRg|G2zyFc{``^`&PXG52Z1fMV zHi}ROk~qlOBfZz{G$k5pU6Zem8B-28&B1E)7J^ISB@*r|iiq~fNb|UG3@xOQj^-hH zrLQ&1{p+fe@BDk2pRJv>%g3{AFF)wgd3)*zEqel|g! zNU$wEuRR;1s?5w9_?3vTJ@3PJYX-LCBClHM4X(1?t0Q8wVkB;bvHA|fjrk?WOgQ@N z6n2c+*bkDYFQ%d#`?XO_8pi6vK={E6-iJ}Y#dO^9o>sae;1QS@JCAn}>(O{23liax zDBUOp5UosqW#9ei)VHUGq@OS*bNy(hO?WN(&=?s=Q4&1}3gN}AWrD#w=thB~CCBI0 zn6Y&{DWxV5#C-JLx zdu>O=9^~41&|SQ)O!7tetH-kChmh3Hs>KN&ItT?Uk#dq%~h{^4!&w?fA7ao%P zp@JD-gjstYco)eDC!Rh%3w zQ!%{pTUF`zvtB+l!EowKtTW4fFE45_ z+-|pJZkPLR1w)5pn)r)nX`%O(?g6~E==-PkR{{8(y1Y#Yo_76sr}v$=&70X7�=< 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%0A#J zy2Dcw^c1pyr*iK4gh;>C0M#0k6BwOt``T)19*p!JB(LE)Qpy5{aTbW4rm8Zkn?wUt z%fpfunYImwHdL=^o{lfH7=-^MJUgH_Sa_o0d3qhgsS5v7sw!)Ba||^=m1;$ZgL%1z z5~ZB2(QPCsiBcVvFa#)}l)L(2L4}-YSp@~Q(whV2loDjCO(PZ)!WxhFm)U$|{oybC zW;e3J$J8_CQ9S($O#kdImgAt|spCCQp>z(MKLYk@cYSC)tM0w+Bs9SQ{6GK*0R=!& z?kLRtJDmHyFZX#P_Z|I;8vntC^6axFbB$k$vWfZ-EJgeN7WMqUY_VUjOONa2`TWW$ zih|jGX1(V^LM0a>&2oek2+?(AUBh!IlmxcoED-%cgLC)++7XRx=D-*Lf;nIo<^eRE z18WCBO-@EJ%l`iV=M`aoewDavZn1p76a*$p<12+#*RD3N*=;}nbS;VTc#Eg_dBGN+5`Lmc#NvbCfyt(3s9pQK*Kq3c37VUxu54-p!umF^v4SA z(4Wnw;HJg$(piP#aw!OoXLhzE)!XZis%P%ps~RUrUsJsR)W%r_w(w6<%v?E*N%KE~ z89Wnd_)Zz;so{CLwr&A_1^hQqKhes}jW$3=-1rWF7UUh_+|T`apUumo>-GOS ztv3Y0L6jH4E z)J%$}egSD-1)@yDv)48~1pFNMg^uSOeSkvGdC+_-v|~Orf9|ud`OjhL-M_q4E?zcS zTvFm^mERBxghxh^rD%?()VAa8Z2Hxk*0!hX(ba2uhiCUW&<=oRa_~%dIp(|{=3R6V zX5e;&ycQ4|o&ZF0;JlZr%6D`Z8(o0*W`%ZNjrZlb?0&k=eHz`Y*AeO6m8&SdVy3Zb zQjoL5rdZ}TSj-3|2v~}`L{{x=%dl%-liK=;4fbK(rCdSuT#XuqZpjX7TC( zMN>Y6kY5L}fQg$35(i!dz7Fi5sxqUymbU=fn-$uzB5&q_)7Q;)A9m~Wbh>2${y$$V z7fdgd^NLIs`VD582Gb1GbScFY;c_nx1#!#fL{^EKjHPPZGwN`2QXSe>Zy)&k*J;*W zXwX)u)eWK@Ano{j$L~5^e#Pp_SqS4cg!m*dYl5}kX5dcX5vnSax=VQrpuJh4A`g;A zo=b1ibKY}Atp4+S*_zrMC(5R~=c^9Cka^+;y$Ru=Fq@O_-HB<%@}A%OM;QPB%D zPnA!vrFANZ_GXQ$J}gdk3J`ZdQ~*^te*4K8fq)FFGvo~g}v&VCBW^#r+~#C zy7x5j8t^^fIbHB$d;|1!pVt7@8Z0~q`kDSfROI0K*TCm+DvFJ!_5%L_JV{lhjwhZV z0M)S6@)BGJ0#XB5nRAyf8DWri#B)B7gJ->7Aexu})gaXxTj1;o(lGF+z!z}hM~7Pp z;P*KD5a}{=8lW1aT4NBLJ>B|p;1j?<0+Vr>>e~-xfwyq-)?cNnGNB)}2B?1J} zsk50CwoYTBuxPQ9}BU8 U!CSjzod5s;07*qoM6N<$f(k1I^8f$< literal 0 HcmV?d00001 diff --git a/grc/gui_qt/resources/data/rx_logo.grc b/grc/gui_qt/resources/data/rx_logo.grc new file mode 100755 index 0000000..e420323 --- /dev/null +++ b/grc/gui_qt/resources/data/rx_logo.grc @@ -0,0 +1,1711 @@ + + + Mon May 19 07:26:58 2014 + + options + + id + rx_logo + + + _enabled + True + + + title + + + + author + + + + description + + + + window_size + 1280, 1024 + + + generate_options + no_gui + + + category + Custom + + + run_options + run + + + run + True + + + max_nouts + 0 + + + realtime_scheduling + + + + _coordinate + (8, 8) + + + _rotation + 0 + + + + variable + + id + samp_rate + + + _enabled + True + + + value + 5000000 + + + _coordinate + (10, 170) + + + _rotation + 0 + + + + blocks_multiply_xx + + id + blocks_multiply_xx_0 + + + _enabled + True + + + type + float + + + num_inputs + 2 + + + vlen + N + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (637, 362) + + + _rotation + 0 + + + + import + + id + import_0 + + + _enabled + True + + + import + import numpy as np + + + _coordinate + (24, 97) + + + _rotation + 0 + + + + import + + id + import_0_0 + + + _enabled + True + + + import + import math + + + _coordinate + (120, 96) + + + _rotation + 0 + + + + digital_ofdm_cyclic_prefixer + + id + digital_ofdm_cyclic_prefixer_0 + + + _enabled + True + + + input_size + N + + + cp_len + cp_len + + + rolloff + 32 + + + tagname + "" + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (912, 133) + + + _rotation + 0 + + + + blocks_float_to_complex + + id + blocks_float_to_complex_0 + + + _enabled + True + + + vlen + N + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (853, 379) + + + _rotation + 0 + + + + blocks_multiply_const_vxx + + id + blocks_multiply_const_vxx_0 + + + _enabled + True + + + type + complex + + + const + ampl/math.sqrt(N) + + + vlen + 1 + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (1065, 214) + + + _rotation + 180 + + + + uhd_usrp_sink + + id + uhd_usrp_sink_0_0 + + + _enabled + True + + + type + fc32 + + + otw + + + + stream_args + + + + stream_chans + [] + + + dev_addr + + + + sync + + + + clock_rate + 0.0 + + + num_mboards + 1 + + + clock_source0 + + + + time_source0 + + + + sd_spec0 + + + + clock_source1 + + + + time_source1 + + + + sd_spec1 + + + + clock_source2 + + + + time_source2 + + + + sd_spec2 + + + + clock_source3 + + + + time_source3 + + + + sd_spec3 + + + + clock_source4 + + + + time_source4 + + + + sd_spec4 + + + + clock_source5 + + + + time_source5 + + + + sd_spec5 + + + + clock_source6 + + + + time_source6 + + + + sd_spec6 + + + + clock_source7 + + + + time_source7 + + + + sd_spec7 + + + + nchan + 1 + + + samp_rate + samp_rate + + + center_freq0 + freq + + + gain0 + txGain + + + ant0 + + + + bw0 + 0 + + + center_freq1 + 0 + + + gain1 + 0 + + + ant1 + + + + bw1 + 0 + + + center_freq2 + 0 + + + gain2 + 0 + + + ant2 + + + + bw2 + 0 + + + center_freq3 + 0 + + + gain3 + 0 + + + ant3 + + + + bw3 + 0 + + + center_freq4 + 0 + + + gain4 + 0 + + + ant4 + + + + bw4 + 0 + + + center_freq5 + 0 + + + gain5 + 0 + + + ant5 + + + + bw5 + 0 + + + center_freq6 + 0 + + + gain6 + 0 + + + ant6 + + + + bw6 + 0 + + + center_freq7 + 0 + + + gain7 + 0 + + + ant7 + + + + bw7 + 0 + + + center_freq8 + 0 + + + gain8 + 0 + + + ant8 + + + + bw8 + 0 + + + center_freq9 + 0 + + + gain9 + 0 + + + ant9 + + + + bw9 + 0 + + + center_freq10 + 0 + + + gain10 + 0 + + + ant10 + + + + bw10 + 0 + + + center_freq11 + 0 + + + gain11 + 0 + + + ant11 + + + + bw11 + 0 + + + center_freq12 + 0 + + + gain12 + 0 + + + ant12 + + + + bw12 + 0 + + + center_freq13 + 0 + + + gain13 + 0 + + + ant13 + + + + bw13 + 0 + + + center_freq14 + 0 + + + gain14 + 0 + + + ant14 + + + + bw14 + 0 + + + center_freq15 + 0 + + + gain15 + 0 + + + ant15 + + + + bw15 + 0 + + + center_freq16 + 0 + + + gain16 + 0 + + + ant16 + + + + bw16 + 0 + + + center_freq17 + 0 + + + gain17 + 0 + + + ant17 + + + + bw17 + 0 + + + center_freq18 + 0 + + + gain18 + 0 + + + ant18 + + + + bw18 + 0 + + + center_freq19 + 0 + + + gain19 + 0 + + + ant19 + + + + bw19 + 0 + + + center_freq20 + 0 + + + gain20 + 0 + + + ant20 + + + + bw20 + 0 + + + center_freq21 + 0 + + + gain21 + 0 + + + ant21 + + + + bw21 + 0 + + + center_freq22 + 0 + + + gain22 + 0 + + + ant22 + + + + bw22 + 0 + + + center_freq23 + 0 + + + gain23 + 0 + + + ant23 + + + + bw23 + 0 + + + center_freq24 + 0 + + + gain24 + 0 + + + ant24 + + + + bw24 + 0 + + + center_freq25 + 0 + + + gain25 + 0 + + + ant25 + + + + bw25 + 0 + + + center_freq26 + 0 + + + gain26 + 0 + + + ant26 + + + + bw26 + 0 + + + center_freq27 + 0 + + + gain27 + 0 + + + ant27 + + + + bw27 + 0 + + + center_freq28 + 0 + + + gain28 + 0 + + + ant28 + + + + bw28 + 0 + + + center_freq29 + 0 + + + gain29 + 0 + + + ant29 + + + + bw29 + 0 + + + center_freq30 + 0 + + + gain30 + 0 + + + ant30 + + + + bw30 + 0 + + + center_freq31 + 0 + + + gain31 + 0 + + + ant31 + + + + bw31 + 0 + + + affinity + + + + _coordinate + (1071, 382) + + + _rotation + 0 + + + + blocks_multiply_const_vxx + + id + blocks_multiply_const_vxx_1 + + + _enabled + True + + + type + complex + + + const + np.linspace(1.8,0.8,N/2).tolist() + np.linspace(0.8,1.8,N/2).tolist() + + + vlen + N + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (722, 260) + + + _rotation + 180 + + + + blocks_file_source + + id + blocks_file_source_0 + + + _enabled + True + + + file + fancy_logo.dat + + + type + byte + + + repeat + False + + + vlen + N + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (27, 342) + + + _rotation + 0 + + + + blocks_char_to_float + + id + blocks_char_to_float_1 + + + _enabled + True + + + vlen + N + + + scale + 1 + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (430, 350) + + + _rotation + 0 + + + + blocks_repeat + + id + blocks_repeat_0 + + + _enabled + True + + + type + byte + + + interp + nrepeat + + + vlen + N + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (234, 350) + + + _rotation + 0 + + + + blocks_vector_source_x + + id + blocks_vector_source_x_0_0 + + + _enabled + True + + + type + float + + + vector + np.zeros(N) + + + tags + [] + + + repeat + True + + + vlen + 512 + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (626, 456) + + + _rotation + 0 + + + + analog_random_source_x + + id + analog_random_source_x_0 + + + _enabled + True + + + type + byte + + + min + 0 + + + max + 2 + + + num_samps + 1000 + + + repeat + True + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (28, 477) + + + _rotation + 0 + + + + digital_map_bb + + id + digital_map_bb_0 + + + _enabled + True + + + map + [-1,1] + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (230, 501) + + + _rotation + 0 + + + + blocks_stream_to_vector + + id + blocks_stream_to_vector_0 + + + _enabled + True + + + type + byte + + + num_items + N + + + vlen + 1 + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (229, 590) + + + _rotation + 0 + + + + blocks_char_to_float + + id + blocks_char_to_float_0_0 + + + _enabled + True + + + vlen + N + + + scale + 1 + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (429, 582) + + + _rotation + 0 + + + + parameter + + id + N + + + _enabled + True + + + label + + + + value + 512 + + + type + intx + + + short_id + + + + _coordinate + (214, 7) + + + _rotation + 0 + + + + parameter + + id + nrepeat + + + _enabled + True + + + label + + + + value + 480 + + + type + long + + + short_id + + + + _coordinate + (299, 6) + + + _rotation + 0 + + + + parameter + + id + cp_len + + + _enabled + True + + + label + + + + value + 64 + + + type + long + + + short_id + + + + _coordinate + (387, 6) + + + _rotation + 0 + + + + parameter + + id + freq + + + _enabled + True + + + label + + + + value + 610e6 + + + type + eng_float + + + short_id + f + + + _coordinate + (480, 5) + + + _rotation + 0 + + + + parameter + + id + txGain + + + _enabled + True + + + label + + + + value + 24 + + + type + eng_float + + + short_id + + + + _coordinate + (575, 5) + + + _rotation + 0 + + + + parameter + + id + ampl + + + _enabled + True + + + label + ampl + + + value + 0.6 + + + type + eng_float + + + short_id + + + + _coordinate + (663, 5) + + + _rotation + 0 + + + + fft_vxx + + id + fft_0 + + + _enabled + True + + + type + complex + + + fft_size + N + + + forward + False + + + window + [] + + + shift + True + + + nthreads + 1 + + + affinity + + + + minoutbuf + 0 + + + maxoutbuf + 0 + + + _coordinate + (662, 117) + + + _rotation + 0 + + + + digital_map_bb_0 + blocks_stream_to_vector_0 + 0 + 0 + + + blocks_char_to_float_1 + blocks_multiply_xx_0 + 0 + 0 + + + blocks_char_to_float_0_0 + blocks_multiply_xx_0 + 0 + 1 + + + blocks_multiply_xx_0 + blocks_float_to_complex_0 + 0 + 0 + + + blocks_vector_source_x_0_0 + blocks_float_to_complex_0 + 0 + 1 + + + fft_0 + digital_ofdm_cyclic_prefixer_0 + 0 + 0 + + + blocks_stream_to_vector_0 + blocks_char_to_float_0_0 + 0 + 0 + + + analog_random_source_x_0 + digital_map_bb_0 + 0 + 0 + + + digital_ofdm_cyclic_prefixer_0 + blocks_multiply_const_vxx_0 + 0 + 0 + + + blocks_multiply_const_vxx_1 + fft_0 + 0 + 0 + + + blocks_float_to_complex_0 + blocks_multiply_const_vxx_1 + 0 + 0 + + + blocks_multiply_const_vxx_0 + uhd_usrp_sink_0_0 + 0 + 0 + + + blocks_file_source_0 + blocks_repeat_0 + 0 + 0 + + + blocks_repeat_0 + blocks_char_to_float_1 + 0 + 0 + + diff --git a/grc/gui_qt/resources/example_browser.ui b/grc/gui_qt/resources/example_browser.ui new file mode 100644 index 0000000..99d7f97 --- /dev/null +++ b/grc/gui_qt/resources/example_browser.ui @@ -0,0 +1,184 @@ + + + Dialog + + + + 0 + 0 + 754 + 407 + + + + Dialog + + + + + + Qt::Horizontal + + + + + 0 + 0 + + + + + 200 + 0 + + + + + 400 + 16777215 + + + + + example_flowgraph.grc + + + + + + + 0 + 0 + + + + + 250 + 0 + + + + + 600 + 16777215 + + + + + + + + 0 + 0 + + + + + 0 + 150 + + + + + 150 + 150 + + + + Image + + + true + + + Qt::AlignCenter + + + + + + + Title + + + true + + + + + + + Author + + + true + + + + + + + Generate options + + + + + + + Language + + + + + + + Qt::Horizontal + + + + + + + + 0 + 2 + + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + + + Close + + + + + + + Open + + + + + + + + + + + + + + diff --git a/grc/gui_qt/resources/example_browser_widget.ui b/grc/gui_qt/resources/example_browser_widget.ui new file mode 100644 index 0000000..45b2cad --- /dev/null +++ b/grc/gui_qt/resources/example_browser_widget.ui @@ -0,0 +1,202 @@ + + + example_browser_widget + + + + 0 + 0 + 786 + 430 + + + + Form + + + + + + Qt::Horizontal + + + 20 + + + + + 0 + 0 + + + + + 200 + 0 + + + + + 400 + 16777215 + + + + + 1 + + + + + + + 0 + 0 + + + + + 250 + 0 + + + + + 300 + 16777215 + + + + + + + + 0 + 0 + + + + + 0 + 150 + + + + + 150 + 150 + + + + Image + + + true + + + Qt::AlignCenter + + + + + + + Title + + + true + + + + + + + Author + + + true + + + + + + + Generate options + + + + + + + Language + + + + + + + Qt::Horizontal + + + + + + + + 0 + 2 + + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + 20 + + + + + + 0 + 0 + + + + Close + + + + + + + + 0 + 0 + + + + Open + + + + + + + + + + + + + + diff --git a/grc/gui_qt/resources/language/add-translation.txt b/grc/gui_qt/resources/language/add-translation.txt new file mode 100644 index 0000000..b27a523 --- /dev/null +++ b/grc/gui_qt/resources/language/add-translation.txt @@ -0,0 +1,17 @@ +# Adding a Translation + +1. Add new locale directory - /LC_MESSAGES +2. Copy grc.pot to /LC_MESSAGES/grc.po +3. Update fields +4. Convert to compiled version: + cd /LC_MESSAGES/; msgfmt -o grc.mo grc.po + +# Updating Translatable Strings + +1. Go to project root +2. Mark translatable strings with _(): + print ("example") --> print(_("example")) +3. Run xgettext: + xgettext -L Python --keyword=_ -d grc -o grc/gui_qt/resources/language/grc.pot `find . -name "*.py"` +4. Open grc/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po using Poedit or similar +5. Translation -> Update from POT file -> choose grc/gui_qt/resources/language/grc.pot diff --git a/grc/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po b/grc/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po new file mode 100644 index 0000000..9b3539e --- /dev/null +++ b/grc/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po @@ -0,0 +1,449 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: GNU Radio Companion\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-05-21 21:43+0200\n" +"PO-Revision-Date: 2024-05-21 21:45+0200\n" +"Last-Translator: Håkon Vågsether \n" +"Language-Team: US English \n" +"Language: grc\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.4.2\n" + +#: grc/gui_qt/components/variable_editor.py:210 +msgid "variable_editor_add_import" +msgstr "Add &Import" + +#: grc/gui_qt/components/variable_editor.py:214 +msgid "variable_editor_add_variable" +msgstr "Add &Variable" + +#: grc/gui_qt/components/variable_editor.py:220 +msgid "variable_editor_enable" +msgstr "&Enable" + +#: grc/gui_qt/components/variable_editor.py:224 +msgid "variable_editor_disable" +msgstr "&Disable" + +#: grc/gui_qt/components/variable_editor.py:230 +msgid "variable_editor_delete" +msgstr "De&lete" + +#: grc/gui_qt/components/variable_editor.py:236 +msgid "variable_editor_properties" +msgstr "&Properties..." + +#: grc/gui_qt/components/window.py:76 +msgid "window-title" +msgstr "GNU Radio Companion" + +#: grc/gui_qt/components/window.py:283 +msgid "new" +msgstr "&New" + +#: grc/gui_qt/components/window.py:286 +msgid "new-tooltip" +msgstr "Create a new flow graph" + +#: grc/gui_qt/components/window.py:291 +msgid "open" +msgstr "&Open..." + +#: grc/gui_qt/components/window.py:294 +msgid "open-tooltip" +msgstr "Open an existing flow graph" + +#: grc/gui_qt/components/window.py:298 +msgid "example_browser" +msgstr "&Examples..." + +#: grc/gui_qt/components/window.py:300 +msgid "example_browser-tooltip" +msgstr "Show example browser" + +#: grc/gui_qt/components/window.py:305 +msgid "close" +msgstr "&Close" + +#: grc/gui_qt/components/window.py:308 +msgid "close-tooltip" +msgstr "Close the current flow graph" + +#: grc/gui_qt/components/window.py:313 +msgid "close_all" +msgstr "C&lose All" + +#: grc/gui_qt/components/window.py:315 +msgid "close_all-tooltip" +msgstr "Close all open flow graphs" + +#: grc/gui_qt/components/window.py:319 grc/gui_qt/components/console.py:173 +msgid "save" +msgstr "&Save" + +#: grc/gui_qt/components/window.py:322 grc/gui_qt/components/console.py:173 +msgid "save-tooltip" +msgstr "Save the current flow graph" + +#: grc/gui_qt/components/window.py:327 +msgid "save_as" +msgstr "Save &As..." + +#: grc/gui_qt/components/window.py:330 +msgid "save_as-tooltip" +msgstr "Save the current flow graph under a new name" + +#: grc/gui_qt/components/window.py:333 +msgid "save_copy" +msgstr "Save Cop&y" + +#: grc/gui_qt/components/window.py:337 +msgid "screen_capture" +msgstr "Screen ca&pture" + +#: grc/gui_qt/components/window.py:340 +msgid "screen_capture-tooltip" +msgstr "Create a screen capture of the current flow graph" + +#: grc/gui_qt/components/window.py:345 +msgid "exit" +msgstr "E&xit" + +#: grc/gui_qt/components/window.py:348 +msgid "exit-tooltip" +msgstr "Exit GNU Radio Companion" + +#: grc/gui_qt/components/window.py:354 +msgid "undo" +msgstr "Undo" + +#: grc/gui_qt/components/window.py:357 +msgid "undo-tooltip" +msgstr "Undo the last change" + +#: grc/gui_qt/components/window.py:362 +msgid "redo" +msgstr "Redo" + +#: grc/gui_qt/components/window.py:365 +msgid "redo-tooltip" +msgstr "Redo the last change" + +#: grc/gui_qt/components/window.py:372 +msgid "cut" +msgstr "Cu&t" + +#: grc/gui_qt/components/window.py:375 +msgid "cut-tooltip" +msgstr "Cut the current selection's contents to the clipboard" + +#: grc/gui_qt/components/window.py:380 +msgid "copy" +msgstr "&Copy" + +#: grc/gui_qt/components/window.py:383 +msgid "copy-tooltip" +msgstr "Copy the current selection's contents to the clipboard" + +#: grc/gui_qt/components/window.py:388 +msgid "paste" +msgstr "&Paste" + +#: grc/gui_qt/components/window.py:391 +msgid "paste-tooltip" +msgstr "Paste the clipboard's contents into the current selection" + +#: grc/gui_qt/components/window.py:396 +msgid "delete" +msgstr "&Delete" + +#: grc/gui_qt/components/window.py:399 +msgid "delete-tooltip" +msgstr "Delete the selected blocks" + +#: grc/gui_qt/components/window.py:411 +msgid "select_all" +msgstr "Select all" + +#: grc/gui_qt/components/window.py:414 +msgid "select_all-tooltip" +msgstr "Select all items in the canvas" + +#: grc/gui_qt/components/window.py:418 +msgid "Select None" +msgstr "" + +#: grc/gui_qt/components/window.py:418 +#, fuzzy +#| msgid "select_all-tooltip" +msgid "select_none-tooltip" +msgstr "Select all items in the canvas" + +#: grc/gui_qt/components/window.py:423 +msgid "rotate_ccw" +msgstr "Rotate counterclockwise" + +#: grc/gui_qt/components/window.py:426 +msgid "rotate_ccw-tooltip" +msgstr "Rotate the selected block(s) 90 degrees counterclockwise" + +#: grc/gui_qt/components/window.py:431 +msgid "rotate_cw" +msgstr "Rotate clockwise" + +#: grc/gui_qt/components/window.py:434 +msgid "rotate_cw-tooltip" +msgstr "Rotate the selected block(s) 90 degrees clockwise" + +#: grc/gui_qt/components/window.py:440 +msgid "enable" +msgstr "Enable" + +#: grc/gui_qt/components/window.py:441 +msgid "disable" +msgstr "Disable" + +#: grc/gui_qt/components/window.py:442 +msgid "bypass" +msgstr "Bypass" + +#: grc/gui_qt/components/window.py:445 +msgid "block_inc_type" +msgstr "" + +#: grc/gui_qt/components/window.py:446 +msgid "block_dec_type" +msgstr "" + +#: grc/gui_qt/components/window.py:453 +msgid "vertical_align_top" +msgstr "Vertical align top" + +#: grc/gui_qt/components/window.py:454 +msgid "vertical_align_middle" +msgstr "Vertical align middle" + +#: grc/gui_qt/components/window.py:455 +msgid "vertical_align_bottom" +msgstr "Vertical align bottom" + +#: grc/gui_qt/components/window.py:461 +msgid "horizontal_align_left" +msgstr "Horizontal align left" + +#: grc/gui_qt/components/window.py:462 +msgid "horizontal_align_center" +msgstr "Horizontal align center" + +#: grc/gui_qt/components/window.py:463 +msgid "horizontal_align_right" +msgstr "Horizontal align right" + +#: grc/gui_qt/components/window.py:469 +msgid "create_hier_block" +msgstr "Create hierarchical block" + +#: grc/gui_qt/components/window.py:470 +msgid "open_hier_block" +msgstr "Open hierarchical block" + +#: grc/gui_qt/components/window.py:471 +msgid "toggle_source_bus" +msgstr "Toggle source bus" + +#: grc/gui_qt/components/window.py:472 +msgid "toggle_sink_bus" +msgstr "Toggle sink bus" + +#: grc/gui_qt/components/window.py:481 +msgid "flowgraph-properties" +msgstr "Properties" + +#: grc/gui_qt/components/window.py:483 +msgid "flowgraph-properties-tooltip" +msgstr "Show the properties for a flow graph" + +#: grc/gui_qt/components/window.py:491 +msgid "Zoom &in" +msgstr "Zoom &in" + +#: grc/gui_qt/components/window.py:497 +msgid "Zoom &out" +msgstr "Zoom &out" + +#: grc/gui_qt/components/window.py:503 +msgid "O&riginal size" +msgstr "O&riginal size" + +#: grc/gui_qt/components/window.py:509 +msgid "toggle_grid" +msgstr "Toggle grid" + +#: grc/gui_qt/components/window.py:509 +msgid "toggle_grid-tooltip" +msgstr "Toggle the canvas grid" + +#: grc/gui_qt/components/window.py:513 +msgid "errors" +msgstr "&Errors" + +#: grc/gui_qt/components/window.py:513 +msgid "errors-tooltip" +msgstr "View errors in the current flow graph" + +#: grc/gui_qt/components/window.py:518 +msgid "find" +msgstr "&Find Block" + +#: grc/gui_qt/components/window.py:521 +msgid "find-tooltip" +msgstr "Search for a block by name (and key)" + +#: grc/gui_qt/components/window.py:526 +msgid "about" +msgstr "&About" + +#: grc/gui_qt/components/window.py:526 grc/gui_qt/components/window.py:533 +msgid "about-tooltip" +msgstr "Show the about window" + +#: grc/gui_qt/components/window.py:531 +msgid "about-qt" +msgstr "About &Qt" + +#: grc/gui_qt/components/window.py:538 +msgid "process-generate" +msgstr "&Generate" + +#: grc/gui_qt/components/window.py:541 +msgid "process-generate-tooltip" +msgstr "Generate a python flow graph" + +#: grc/gui_qt/components/window.py:546 +msgid "process-execute" +msgstr "&Execute" + +#: grc/gui_qt/components/window.py:549 +msgid "process-execute-tooltip" +msgstr "Execute a python flow graph" + +#: grc/gui_qt/components/window.py:554 +msgid "process-kill" +msgstr "&Kill" + +#: grc/gui_qt/components/window.py:557 +msgid "process-kill-tooltip" +msgstr "Kill the current flow graph" + +#: grc/gui_qt/components/window.py:562 +msgid "help" +msgstr "&Help" + +#: grc/gui_qt/components/window.py:565 +msgid "help-tooltip" +msgstr "Show the help window" + +#: grc/gui_qt/components/window.py:570 +#, fuzzy +#| msgid "filter_design_tool" +msgid "&Filter Design Tool" +msgstr "Filter Design Tool" + +#: grc/gui_qt/components/window.py:572 +#, fuzzy +#| msgid "module_browser" +msgid "&OOT Module Browser" +msgstr "OOT Module Browser" + +#: grc/gui_qt/components/window.py:574 +msgid "Start profiler" +msgstr "" + +#: grc/gui_qt/components/window.py:575 +msgid "Stop profiler" +msgstr "" + +#: grc/gui_qt/components/window.py:579 +msgid "&Types" +msgstr "" + +#: grc/gui_qt/components/window.py:581 +msgid "&Keys" +msgstr "" + +#: grc/gui_qt/components/window.py:583 +msgid "&Get Involved" +msgstr "" + +#: grc/gui_qt/components/window.py:587 +msgid "preferences" +msgstr "Pre&ferences" + +#: grc/gui_qt/components/window.py:589 +msgid "preferences-tooltip" +msgstr "Show GRC preferences" + +#: grc/gui_qt/components/window.py:593 +msgid "reload" +msgstr "" + +#: grc/gui_qt/components/window.py:593 +#, fuzzy +#| msgid "redo-tooltip" +msgid "reload-tooltip" +msgstr "Redo the last change" + +#: grc/gui_qt/components/window.py:864 +msgid "ready-message" +msgstr "Ready" + +#: grc/gui_qt/components/console.py:174 +msgid "clear" +msgstr "Clear" + +#: grc/gui_qt/components/console.py:174 +msgid "clear-tooltip" +msgstr "Clear the console" + +#: grc/gui_qt/components/console.py:175 +msgid "show-level" +msgstr "Show message level" + +#: grc/gui_qt/components/console.py:178 +msgid "auto-scroll" +msgstr "" + +#~ msgid "snap_to_grid" +#~ msgstr "Snap to grid" + +#~ msgid "show_flowgraph_complexity" +#~ msgstr "Show Flowgraph Complexity" + +#~ msgid "zoom_in" +#~ msgstr "Zoom in" + +#~ msgid "zoom_out" +#~ msgstr "Zoom out" + +#~ msgid "zoom_reset" +#~ msgstr "Reset zoom level" + +#~ msgid "set_default_qt_gui_theme" +#~ msgstr "Set Default Qt GUI Theme" + +#~ msgid "print" +#~ msgstr "&Print" + +#~ msgid "print-tooltip" +#~ msgstr "Print the current flow graph" + +#~ msgid "block-library-tooltip" +#~ msgstr "Show the block library " diff --git a/grc/gui_qt/resources/logo/gnuradio_logo_icon-square.png b/grc/gui_qt/resources/logo/gnuradio_logo_icon-square.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a68412e5a88f3c6d95a1ba730e8db824eab8f7 GIT binary patch literal 5014 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 g34xFZD # Put a URL to a square image here that will be used as an icon on CGRAN +--- +This module contains blocks to do things. + +Please se the [website](https://github.com/haakov/gnuradio) for more + diff --git a/grc/gui_qt/resources/oot_browser.ui b/grc/gui_qt/resources/oot_browser.ui new file mode 100644 index 0000000..3a831e6 --- /dev/null +++ b/grc/gui_qt/resources/oot_browser.ui @@ -0,0 +1,275 @@ + + + Dialog + + + + 0 + 0 + 600 + 456 + + + + + 0 + 0 + + + + Dialog + + + + + + + + + 1 + 0 + + + + + 125 + 16777215 + + + + + + + + QLayout::SetFixedSize + + + + + + 0 + 0 + + + + + 16 + 75 + true + + + + gr-example + + + false + + + Qt::TextBrowserInteraction + + + + + + + + 0 + 0 + + + + + 11 + + + + Brief + + + true + + + Qt::TextBrowserInteraction + + + + + + + + + + 100 + 100 + + + + + + + cgran_logo.png + + + true + + + Qt::AlignCenter + + + + + + + + + Version + + + + + + + Tags + + + true + + + Qt::TextBrowserInteraction + + + + + + + Author(s) + + + Qt::MarkdownText + + + true + + + Qt::TextBrowserInteraction + + + + + + + Dependencies + + + true + + + Qt::TextBrowserInteraction + + + + + + + Repository + + + Qt::MarkdownText + + + true + + + true + + + Qt::TextBrowserInteraction + + + + + + + Copyright Owner + + + true + + + Qt::TextBrowserInteraction + + + + + + + Supported GNU Radio Versions + + + true + + + Qt::TextBrowserInteraction + + + + + + + License + + + true + + + Qt::TextBrowserInteraction + + + + + + + Website + + + Qt::MarkdownText + + + true + + + true + + + Qt::TextBrowserInteraction + + + + + + + + + + + true + + + true + + + true + + + + + + + + + + + + + + diff --git a/grc/gui_qt/resources/py_cmd_fg.png b/grc/gui_qt/resources/py_cmd_fg.png new file mode 100644 index 0000000000000000000000000000000000000000..6771aea12d1707696662ab0a4fb220007bc5c16a GIT binary patch literal 25912 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

7b03GB*aA=E@7q9EMhK!v6^}duj92tQU|X` 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

v#NSdmBy566Da!~#xT=*?$ zN%)kw2f$_7Exz+mrQBR+Ca$<3brp1jRMqV^ z8X$n0Onv*D)2n^}{05j1UlqO=N1HKRvvtJz>D~@U1rvNVi9HlH4Tc?e%Y_ymX1t9~d$rK)_ zjn(7uQ%e!5fofn{_9=T_g0l&D7dVXQK}tr~%Ct9a%CG^{WQq}}#}-eUMTVr&Y=1}!Vyx`)7o-G<{VAh88v{KOalwm6ANx5<;})n7OFE)or-Wf=!gOM sI2VBvpeIp|qwWcYwtir14i&Hd4 literal 0 HcmV?d00001 diff --git a/grc/gui_qt/resources/py_fg.png b/grc/gui_qt/resources/py_fg.png new file mode 100644 index 0000000000000000000000000000000000000000..b23ce6ef31dc033df9e9c3c314ca855c5c6b64a2 GIT binary patch literal 28106 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 z zyw8C{z^ox_cV*7@H(iBOLW|$hoU6*hH+HHXs(R<`(#J>{_^Uqpw9r#Z;&^VRVa9vd zPPu-Muz6yff2bI*b1HV{yEakO8JdaW$*VdSoR#-U*ncMv$oyD!OoYvwxOSW;>YK?a zlbeIYP}2)9eSCT2o<)=Xx_Z6Zn`eiTBPMA_@Z4cub?Bp~FpO)#4i9+ouR=JS-&*qvvQBqJ!(NzyNDB228U>CL<&JNpj zxFTn^4#4G`3LWoU?1Z-Y{24ZRVXXq6wC!G$Rurnjcf!rYi0y(#v%0g80Fv9}jg2!|RmocLd&F!aQhO~6N;0jX_ zLV9s*G?k!U%P?}ARjWyQxF)z9=Jf(Vkuq*sXJmSU0*y&oB$>Y_`^C}FtDEmklXuO^ z(f4%pA;S^vVLK!0_ns7x(~5p0OWq~BNej4nxSSOvd*yY=J$Ev>Va7`lxLA)4oOA6n zCen?1!R|(RXGRZUmJcsRYp++%W$(XM(nazD&-8SN6>U8Ex4*ndL+SEv3pq|JyP6{@ zDCCQ;5dJP*#2uV^ePLTIT$`xm`;B1WJ~-+6r4#MGVm%45|3{vr=}%y;04s*O{)20U zxy}iJjOh;d-@;L+MLoi@6e_AI&6K+K%(jwM`$kCG4+V|rs1 z@eC1p_JrG>&0mrc5#q>yW5A#70gu#s;ru6LInf9s66>2(;mx}mWIc1_;SV5exD=eK z$I-g`vU(COP9QoO2&%~2>1+sBhuZm2A=7}pVR$h6$BESjL%O7hUy=r8y&o*uMjnSt z$BOzVu#esPIj~`*+pE;3cpxg4A(*UwmxQv;Pi*iC8<#AGF=?C)o7rgvfRq_eTBCx~Y$9S;~)ebr2gPj_gS6?RD)ha74rz zrfPQboYV($BF;+cJ=)GdO-EFA`#n<6%^vHXh4e@N6Xv!u3`R0LxtG(?-knNLy|R7KH#6$z_oRMnO-w>^2Ym%~(7+o!oDI9DUO>7325U>8$0*6!Rz zjE$zfC-RsT7WyPzOs=U6Q7ZV@0-5LmCFk5m#6Pc!faYf4gb_w>@|Q`1enV7@&b+s; zRN850UK{S%V5=2q+D0Wd{G72)S%SjVIm>cZ(2^1pyas(^<+ccMMmT8&usvatJmn7# zH;(F@io2lmO60U7Kga=+pAc)YdH1uVrIFw*Q9_ncy=QuVPpjjJ^-j)O?f=fhq*8J?VVn95GBRNNPv|OL?%k4rI!+~ z@SWi0mF`(qZxj)^`7NeW9Ml?&a~L6&7vy}dsZ zLx1+|1SvD%J!J@u5{$CX67?~LiJ!ADd%=pUcky4}g_wOVn-b5kds`>fsuse7Glrhv z)FEyu4G~!{lYkKCP{uimf+Z&`J_{lj)y87HY-9|}7?{!18U$(S&Qm9cjJ zaopS5}KMCa6>h++Bkyinl)sxMW1oBh~+I5nOu(i zM%L$^z>EZ@F~BHR<_=E>wr5aGRXZ$_BDB;SW?6w}KedaQ2*}0))c6a3*)h=gb&MuR zo1hE<)SZ)5zQ<GKf`-=`8Faqhif z1KKi{@udXEG!2IwL=SM$8DZOjTuh6ymVrPfD}Q)Py9=$5$I{=?E59O-Q|yT&F*}_Q znUVo_gxAx0{(6qI?Spy3F6>6Cu&v+q<^x_%JXq5-lM@9tEQ%%bWKQxo&$Ta<+Mbnz ziuVgEBgXuD`f%xFRSOQ18}bLU5GU9>SXsHF8{02&j>{;>bq|HaD$%j~xY5MwuzX1W z8_R2!YEz;B{rSrgdY|BJmu;6x5B8uF#6n4^Def?-pa^hz#=cFM4*yD<6% z`?m$%K*xrnDSMa?A|Vsjn-R`J`I5MLSk@CCxY0B#WR6NooWj^Laso!JyD|>#!ZXHW zIaVuUT^YwTfo0gLPpdjIhzpI_vu!=}*tDGw1*P{)3tl)G1hAaK~kf1tn8?*R?=9mV^ zv>NS+w(-F+ODe-nXYRge@$*6+Yxf7*W>cNuPZUT86pm>mF@2UPQx`?V9llvrq}rUB#y&QN>8Jd41 zn4A7&k4-5ZY|OAoatPQq6t~f_c)D9Yf79=J6!?!5hkWe(-B>SB)cH*nrP&8@9OC&} zUMExlL^hDr!&!w;)KpbJoT9K`U71{W^Zom3PkB2TuwD zbN6SRdpms`E}X?wVmaHT7>>EUh9b2W><(+MN_AYC2$+TqhAkQFA-E6ljA)fAb4TE+ z@DWUX%Ml#yCZXG*`uw&KOPn~Qk7_ie_|oCfh#+Qq-E&VMOrzFd9Kj&)<9+5d6oe^- zq{&>0WKc4oD>b95ZS&qGa<%MIqYcNUD46X_fI?QWP|ck2F93HCz;zgO`iJbAd9tVX z^Xm&43;t@g{F~K`Cmr+N0>V2$#>ql2vZO|x*6#(in6kGrYn)wyLx|*Xw&Ne$51;1x zM5l%gax#3I*MbcUYu8?%>^DFfnf<3gw8iUu-$e9F=%cQg1kuKUN%*6r`Rj6q9ucy4 zd=)LlS#AwsWF-Tf*%DoF-(B*L=6#B|(NrhwZG!7=#x&n2>CpIvh8x~>RYX568kahT z;cwp?)Sa#|ReX7wX}4lse8Qkg`pI`dHoiQ)9wSFxO++U)K#tG8>74cj)$6C?u}Pvq zip&jk72qd}d^>`|Qw=}Ul1#VN&u+^m9=8jAMyH2vFNQHe6H?V1 zQ}bjMRd&8@qZ(0dd%L;s8*o}4;^F0-k>V~TSL~mZf8}2AMuruasjW6=zNV+Rju&!x z2i?nTUJv?-Y;&Xj0eQT{elIYM=qN0S!r|62Q7O=V>w8I-u=zNT%81sO-^XAJbgD7D z7+FCj73A*;Yw~ngCsoTIu>JICnLLq`TKqabyXkX42J}f`CzBMb$zJ`KW7ypiwnAg$%QM~BnVvwdisjUxCE0{AwvRK(h@|BSD|K;ldUxOSf4JLRC7u#3 za{gK!0&pi{qzb*7)Fs{$N)X_Nj}$O`6Em+UW5jjL0xf;|<}G4Ct0JoP=Ei0A*f9?? z0KdG>n%8b=srupu!Pnc1RXU(pdj{mpzR5hUjd;~a$W4TLyBDC`xOEd|ZkO`MBR%00 z6Jw-st`hCL!-hMYeL3xguoXL2guSb$Q@gu*jmrw3JbNcv>0bzTXa5 zBNpS35lrjx*@TrM6*=K~z zMjyE?FLomP_>g4Z7GY2P8KXAJpIt&x5h;+QkuefD#(_3eMh5|I7B!!85$>%@>M=eZ z9GevwZoj&RzV|P#?FcS8AUbO%JC=}P9k|SJb+tC>pOR0GEFH&&lNTX>)3M`!Uk9Zs z@SAdY@p#JwvV$UIo4E)Eir+k_1|tcMp|IZ_+O7z;KTWKtPjNDC1u;gpNQ(Q%@%B}K z-*`CKR4r?YIIK`UOeJ(2rBPVk`*aMQGGXm`izHmtvp4~0a=mv7>#OudaiIKJ6a`0Z zL4Y#2RCQ7hPY^mAA_E@P&8()Aoy{4Bi&csLWZFr-D5`3T6vBz;5z+eHTFYC3-@zqx zA+HUGw<6=qJ#KME$5MzrDuj+GcK(E9?&{jEEUs$^JB`I6P&V%Lg$2z^NS2;5=@(UU zAn~B%!)y3E=G{vHF{@3d6OjWOslLnRO=Oe=P~JKgJ$x5^%o<&ssi``wY;o&vt<;~M zyNqtz>u=xtGT!HVfaw(-t}EudlhbXqe^`xOlt%O+XQ&`lhJ4&`m^KR$ryGOOkDYtMEm-xh-6RUt1KKxo*NPYim&7#Q}+JvZ?jr zyU5EqY0l5gV-kAZgydA2%49ZC9+#Z{zOnA`*RusBhk@jc2K#4e>na87#xnLOziwRQ zhz>}dEXTf*N%)&)mWe^DqciW@`?JFLrjXF!_K;-12U(0w8HUX!T^R)Sz+Rvx>8|VAbzJWD&PjtKhO$_4Krb?VYuTo*ZSZSuXmIZawU#QI%?XX1N# zK3czT#6O}?rZ48`naF~+g-12j8P(Z^;?iQhXg{^`k*9d0+gP)^K3UY&CUaLU+|+BB zPF?}#1rr@~{QIupDxxJW+7$ojymuVuIs-eZg6f2BwNxT7qq00Ji(2u$f$>$+_4C<3 zqTy=@a(;LPaNS+RFL7jybl|-|AnUfKGpB!p^id_#EdosoT{I9%Hp4uhtySgR%2Tm4x2W+@nAKIkbO=55%y>g#drEdcC3Tdr%x zJoKl>1r!SIQmPDbmP*IAVm%#puUzWpl7Q(cJUQ-53V@RnvyjL3*Uv+Ht`Lt{;@-4} z_xi$4t+$ViP*3%6Zm?qdUganacYBIHysNNl|}Iy54z{xS}h01LU7L&_$5+g~^V`^IBvY#709WJxs{-nyR~H-?Ez1fer*OA8_^boQ3JZ z81E0hGEYTP4SguG*R_eZ)!mD55p|NbzmfYccpE0-zlpnx`K9I9)Pe{ezpn11=R2!9 zo`BkieLrC+LSXMQ!R~K9c4uvbQ)gG*xva_-N`w`=D^K*3_m}Y6$^E5Cevtd=xl_HG zX=;p+y0XwZagEjuLv=0MmBsK6mcLCu+TVnz4IOjxKk~;I5;ljh;bsr24FCoLtcj$5 zWiN?9UkwtC1?p_r_Ii!Q3U6aV0unY~$|9j^(cIPD8hZ6h29RKLGe_6dnbI|E`@7N5 zQlB-BfOMMo-`T2R+yDy)hys*hQR6`mIx2vU~h$33au z_<+~PRk$45^TY=A-aSqRUE+xNoVT|_8zucl$FQ)&%fRotnGY%-?QI`gR!4p%l88nr zuZIqZG)@p_ipIos0GkaCjBVJ9D(nuAeDWtWwQLbo5mYI^xtQM&HXOa}Xw$Y0u2Le^ z@X~|V2|T0j3NCCz7w#VD`RzpX1GJF#T2f0Y+R$a|q)4yym$}n#U|Zc5nl$eU;rw>h zV#**M0gAKp+*SsH_xX$OY`>9~#Mma3a_M5TQrqiD{7L+gv8Q0Cz|6t8&dimq0owD} zDyCGC#yp_&WM8l38~+`P&6S^cX+vrDOsN(>eu)MJ-zu$-K=f82&Ebd6sH)l9r#$E9 z*C{nqfJwk4>L~G;!#7W6A8_>v*S5QTX=;bC7pURm;scfzI=^5CfZInWA4C3nfpdX> zjAp9vTL1C*cb4PLZSt%J9xM>Nf&0F3(nINvj6<$ENuT)w6zToh@{&F&%mw7v-;iID zQhO`?roreEp6(tNtHOU@uV{rGp3KjPsKv(LqD#UdH>x;}r7B#1`2FBIu4Q)xrh%Z2DZf#K6jeXr_6l^;zkyAs7!z4~GA?MJ$ubzQhK7f}*YmUl{- zd}70AR(@g4iyL+&#(B6=m;7TKllfss7N1wIz@D^?$c^WPMp{?%*V+?NmSEBlTiO$2 zt0}{rZ7a_z9$jY~Mms4*q68}8{YveldxF`)3u$)bRiCPDht%P()LOkGNc^ig3#6)y z-{ut>rBGfGW7iy8>1@C!bSuexzl-b^fCVU~(Z+*lvNwem8-99`n4mifjA55cxEqLU z+6-pt3#*Z-eONqw5_kV*1x>tapnStYFLDsS?YQ$?n8|XBcUJX02PX`Ho%YOO)m4KC zC)AH-=1i)fF7uah&ah?&TTPNgDypqs$H%MQGkaDEqFSnp&A&4DQCEtkq_tJ|SGn}3 ze`)_s;L)uy*0r_ut-Ucpj}}}7fDd5@HZaZuMc36^KGqJOh~|b(rMm)1GZdZ<2>(ub zO}_tx#07|#TLZymN)Efy(QA9X-iFXR<$3j*I2Jj7G8RU?prH~Z*qgDK?+-PZ_4|*gr5+A(&j%^&hRn;z~F|- zUi-W^qD_YfkHy|g3Aq@#+uZF5oH6&G>HF8@R8Hu2N+`Or19Fqo30%kecVWcD=5?FFD9KfI3|_|+bclo`zSFa{i@ z_ouIH4F>NVC%^K)q$YPBYOxbZj>peHj8L1je(DqWitTO$$E$lx z6jSxFfJBII6fH1BkNjS4m!?U4zVghzYL>XCzzRDWYR_XjaeVFRZ}BZIB}kixiXNVR zeICu=Ha8elQLr_EB6&36Rpp2n7;y_zwG1d;cJ?f$p_1hS|nbU5T7V#xOg{x`ulvtI+}3laP?~)w)RNM{7N`Nv?sqvY&9n z;HyencV`{XBR`iP-bT{*k4xS)JqKMVaKo@;w*DDmp4_6)&*<$a(#R(Kc%)*###fX4 zbxREzJx{D!Ca^}bBZYOczV~Tgq#OLCyvt9M;yf<(-Om>F`5YV$PAcrA#n;)c^6N#2C+#`4Fs=|ja^f}wWHvfy>osKZS&wgJCN}ARc~Y8FKb2cZ2Iu#4IzP?2yJ_J zV%LW2c-qX|<()mXpVyzJ=v@m7ds6_3xYF%X3mVfId>6$Hclu#kppccD-xDKCGU{h^ ztpRRC%O8>r?f0`Cw`M|De;4ELQdO+31B=a&aRV#%_h96I-BwOnd8Y_BBusShl zFjU#4m9atc^oV^3nIRPTvRV91G2N}~J~lS>6pWH|5<3M4sapvmNcuc;cP+Hq!0*F= zn$d~NlI;gTLzC*4YCQ#x-hi38KaI8C)pV3b*_El768?|4OA*j%!+?vS%w;E|g;Y#0 zk8m(U7^9Gt@|=1Hfzs5j*u+S|jb>RVfLlVT?RMAd(|vi&mue5&4^&r|nm^r5W%;|T zt5D%|gHMvG{iuAZQIhlls3#5S=j`!cr&YOmD5c4ipfd68=IplN>v02ydd^$6 zicZ5Ge~f2Pb{4aLl`)C8nrE9;5TRUKR_y z$i?~0tVVP4{fg1+U4{C@g`m}yV0?>;%7ltdFR}mDTlKx=sN$X0ju>&$VPn@K7bEBc zg;@B}+(Z0%CX%dk6xcCfgr@m+SqySZv0x?q)A@IK*)HqKHGV(N0HYTPsT8W1eA86rzGO^KgA|Q9^X;#FgG@s6chq zI7?A^s*WCu)YmKfWds7E^5G64Tv?cR!wuSS&1LAXZC2i0@M9Aj`H8^;ID8vCITXIi ziBic0Ob1B#Gc_sFK8(}H?&Y9q$+v}6{V1c+T1O^diUWh`l zt#b<}bZuatku{70CN0SCnmrKS?%(sCB}}Apzo2KpzEB9of8X#<#m*p#T$VC1{-m`m zkF*>=EHVdw^{9@}E1ON&P6ZZRZx-tVFwyj=D+}Lzg0qbKhO_0EmpMNlvI^|F5@p(- zy?Ue1IP}*>5f&J7dZD8)=q{?{S!@8QlD+zmwzW)LUD~KD)Sbdr18fK^mbBQwxK3o2 zwu{zi-bTQ5|&OnEpDuYt4Q9_ zN%Fg_tpAp-g#2pp&XvEj6pt@7n#|X&R9e`&TNiqdh(qKzcD1$H8o?AFmt4-6h7*XX! z>i==HM@)2<(xr-0nj&G1r7{o=moia1Rzk13!UA8i-F}YRtsqMm1PSAb6n|~V$So0W zm&-sfD|B!0)e?}JM`!qfOf|MVmZ-~-P*)AMHQi@*^@`D~KqEe(!_70nzL|TJ;|QC{ zOZS#Bz=zMwG`qrOE>MoO$A=yfFw2;~yX*$k(1PB-$=CJ6j9)7awcWmJ#SRx6!EqH@ zEEd!8Kg)23phR-^cr9N&>I_{r_Z=yy9)$^^yI)u^#-Z0NIL23t5saCI_;#_yc{`oId2#obbBUJhu#%+GP0Q4awVd5a16!^RTU1>nFTo6 zz07r3rDB)XO(_yfJg~P1aCjBz$=4xjM?L}2-rQ+IpV!TCM78T97vi%9fh!Dq{riZ1 zFz(L7=JdA7$M+kp5L7`TmoLm3a79XAKlYZJ>RjvxTm=6_AJ%o_lMqh!p5Yqcj#~pc zh{T)KBl7Yl^}iHmd$^Bml5ITEaeTPSOR*?ZY#smXPZ2kjz@HpR^J$i0@5Q2S|1|AK zK7f+Ln#EH~5ObalzZLv$?b(k2*Z$R>-20Z(M#H%jXjC&cH7G51wRh-hZKtc_cI~}Q z{r61Q)le<;b-6w<&-;h<>MOvPq&{i2W5XXMt;&DyghA1b_jA_9+ux-PQM>yAve-!u zk-e)Ox8_${DfcbAWs{j;HlZWNyF`uf&l$J1=$mI-GQ>I2C=C?aKYcE@ZlaqQ@Wt#J zTP<|^OkUTjO{QtFG-bhAvWH2T{sA3wd|`$ne|IZ&S}xGo3Ddy#OL=^Q!aF)>sG9OQ8`l>n8VWC`Rb@5H*y&l}vyL8$a zqXB|Q_6cmhV>I#kr>5#UN7%v5EcFM4)j#V#`=r8?zNVZiYD7rYaMzRa7eJq@jpl4k zW%c!h6PC$m7yY9;_!&L7?8pnQJJIO{+^x)J8;JX)e7@j@q}<32+pHy@B8m7@DERAo z`Kp@|%q*JO*}K;%Y!p-Nl=dnJ)I?}iH_o)S6!&jXlWUTj`>v$bR2+vSt~I(AU@PHs z{$Pd;?ZMZK*gG)4Xb?bT2XiYUYy$RmChfZ4c5!X;+3zjBSN^2#4daA{pH_Mew@`+X zRrAizouVD|=$$JtgK*_=#>1Z)v#H0Oe4_mgoTep>gS%jU0PxE@NJXbgVFW|OM|&s> za|x>@>=R`AloQcXvmK!!|3)t7w!PzyH7A_EVYmK(LMVv-H`bs0outxV^2?f4`!g@; zzLGropZ-888s$6ZePe3dOtN=zD}w4JVPMM&lG$Z!9>^UZ(BP;`o$?qo=cJKZKbkJ~1VwqyGlSfYTCZVigCP0*hb=~bwFcD6 zzqpU>h8>Ym|5whNAhC-lL06-;@LWp#?OqJ%19FQ-mBy?lK;cw{SP7hV&9?k?F{ z>g=hYO`J*XqdhVFAcvduDl=+hCBM9vW2#r?1_o*@F@mt zxEa0tRpgbRwkW$!`eH@{Pmu@HYYQSZ1`fbCbz`g^8efP(Y|Bct1KCFgg zO}$u;5D)a4(oZ+lEG<}6KU8t8@zkQ^$Mpy3%nglG!Z$E_S<=HziVr?>rF>4N8v8{$ zbS3q5fvVOv^tuXCFyrq=T1dW_&=Ab%H4R;VxC|58L|$SU1RSE*3-?nblDLbKL`4f7 zrPM*WJ~Y%kotD;Oan~H=K&w!Q7S!D(2IAZ-Ij6bgJ0;a_e(qP>j2sYT>RP;vUPQMg zIZ1eBq=IjSWB8$9gd(3(kp%+}gaH^VcPmdrvgbz{)Si4?;VVNgKY9{bhr|EItXW%0 zo-Cuh?olLrG?lE}FfK0~L(P*`9yBiiJ`(g5B!I(T{^$Ci=I(cy+-#f9)`XRmj<9#d9MGod5|&2lRQy$QwX zHZ9{Xt+y48LB}2cnLb`_F?p_Q)n?89P`b;CWpht3F9VpHFxI8_I<>hAdWWs0i{v!6 z4^@A{%(3b^M@;xVVd%gzj3>2$ufoqly1&sV_ikSRypt2H)EuC` z7d~?}%r7N!f3rWDgQ}$)K2Uqw;4`$qm)?dx9*#6UQs9HJ$xdpXo9C6oAk$#|C^Bjw zdJ9!6zU4Cp6^xO^jNB-;12cEE2(Rg4Vvqs~#+6X{ei+Dkrbhdmyd+?b_Y0Io`HEht z8ntP0iMZ@QdX$G>PIppNyto>6*UM)yat(~X+S{Hm&(>c+ABo9gU~+-S?PCYIJ?xyx z$**JWOpS2?2eirg3>ShMU4-n7hSg(>?!~1iSVRC7vt5ZnkU~+G+4?rk>g)dHk2CQX z87Df2KMM`@D;Ym%!a^L!p+07cOd|hj{YzJ3&wEjsBB$ms4Qj(m`vSGB1dImq7NuBC zp}4xW6_(}`qC34s76~C5-p?OC3+h`DbUh(@XeII)q|q}24k0e@LZJ%F5 zLzGYbk#*{#8CnQ0BqP1y3qQgY)?u+@Xje1~yH>bP z9#^PfYO`r}N$3;&wk8i5zx9jd1-O#OoerD5Z&Losch5>O>Jz#hT3%Gy!c)RhLG4jK zeBeaBV_|XdR{ggD)k11yj$*6nArUtSk{rz@hQ7EkRI|ij`5GU0SsGcsWWUkTHQa5@ zyuRgSKMB^4zPOG+_wk&%U5|VA{T|TwEm?60jyX^l));-z(gC*~hj4|%$R9aL zr5fFTb}Fqwc8cyOS@)Jw7tgs@xzPdH6x_|F21})Gi`5+u3qLD<6x&t42mD~GoWxq$ zGMGt_$IZX(pR}voAS%qn44GpXm}AE3K}cK@y&s~(K6Wslw@*-MHB$3ArX6`>-}Gok zGT-vs*Ur#F0fobpJpXVWBL6)PRn6dC^Xqa$L~p-9pR~xFgSDp9`A5%eiT^7rF4WO~ zK2d5;9e=co{7qCgOxv}sLWWAV`-DbDz3be51D;#_F;?3bcOnHmJFbCt%)d}lBU9&3 zbr@k!!sGk6-o%Y|Z8nO0^oo7;K&9r?1CLg^Pumsnq!pkR2BJm*K_l)~w0WIw8$e@S z;F)L}-wE1=BHb6{i*|JC;;EG+tnt_!h4O7Y9o9!`6a@s=Ug2Nv7eHmV4=+2~#Z5YG zSLuA5&d4YbfVf_H+qA0P`)r4&+xG7WV7I~3qUXi`EzPI4LA?wyQ z+LmY}VtJ8>?M0)uTNbqw1+4*5YsdcmqKG9#Y>P<5BI;N~qju{b$0i!Fa3X?j36``%vUMJ> zpn^#9lyw>Uhrt-XW{{K9_bG9xJipxg*Pp&gTHJeSNd>DTiy|zBIQPflW#LLVB-T9--zm`zF4Wu7<9rr6c535j8bYr8u zwuVQ%_Wwh|YW;&iKeg#kgbFOm#m4IJUKb_qxisRk7MJy+cz~^7d*E7%wC+b>YpWNY zungQn;-GGTtAA7WQkoK%m{%Nh-=JTn4bWXay6kYpy@d_ac1;S;t{)jbh_>!z3W_Dd z5GiMTbR?t8Mlhmm69zlg^tURhkTM)>Y|F+HYeQm5PZd{P;_A|@G1j_`e3@9s=do41 z*Swf#6Q8n-7UFH6)+?m#OFzj+M;vCKcgT#&A{MkHj0XD$ss7O zSknfmbc_aWjhVCC{-L4AfT%Foa`wy7M+HkBipG-w~r zZux>o*1pOeP4BSWYmItv1uPRA8@QwB-^esm;A>{f+P;y_%Rzr#po4V@>$wb_*7rWVIKDTM27IA!ACN>-leB=q-@Wmrs-}FXnx2kMB_L`HqTL{9*<=Zp1V(YdktfT~` z(i*yRPs4nkiLEfa4BM30Y!rpmD^`F6P3snA;DJi#ziN1=?H5#zl^yy>y(P2oM@{EBg$IP5SYVu#vHBVu#>;Dhi~`R%&r@O-$YtuCHqnmbTUu0b;;P9OfJ zZ`$>4s*z+Rnelb2j9=-Y{i)j5 z+myH?oKm$f6R)_3{!QB}PSw6lyy7JN+x~aaH+lnTkHbv~Pb8zL&1kEd+M!1h4e7K! zl^lsMZRl6o+aBB++^^aDqF!6+l+2S5NJg7Zr8#9U2IJ3W%BwxJFB8wNhrUVM^GgR# zDf*_@FCIdb{qIIv5p3}nPxAB zs~)yklv`2yS!E20R5LJAMOCCCXYZ|lq#r*RJb{x|+<})v-KgBY(4=pI6EPb_p<2rf z)kab3-`mz3MGeq|)q2((;2RZNvwQWnIhoR$L<9d_zksLKzsLOeQkH2mjp~A0ohG>L zaj%sk46(}CBRY()l<<s%;tQRA&v-U8}d@^u}G8)A%+fKr~-oCmphgn2n;Iz2Y#y zH8ks?tJz`R2Sp7~tLg7O-e@U2r{==+}5pGsCjBl6kkoAmmi#6?K!!?2@iYM(5@sfa~14JeH zZ`;Fx8a$T^R^HE>>psf3ZKKMEaF*y`JjCV4@TNBtR+ZdD=TB1{S#Y~BfSDfK;Oe(L~dqy^Up{eKrIzr0XsbY(a z-}lJs`MeWf-L_iT?onz7FiM9%-G3%3JvxT1=h`hi5#ILJaNn0NWZcHxtG1$AZ1ClL zQeOmTuDie`FeNT|uUK>Uipjz&7J00<5ttO913eAZ@t*yoBN-g+pV50(#@8@w{foFF zodDXo^HOcXq`x_%2%ZR@ zH#>k%S+RO_nC#=gitRIwxqNQT+k8R16`g9KY6U*!U8*g-)bPIyC*i`rmD_dL*y_Gi z5EitCZqMJh*hw=b?$aAZ$@^xJ?EL)ey$!*naiH6Nxpo{igF;~=9q=iL= z2+}DHLVDMTdrBfS^{ch__0VpH(IF00yB-7UORSp;Su+UMM%b z+D{y%@=TL?okDS*nlYK`1NVwWzhJrZOnNiA4qEafB4Tl3m`r1PXL!cJ^Dj1igw?i# zE^U){2^gNxT0l;L|HeMW%kZ;yjc%4=Ivu81O$`}5Q>}+3G47JtD9XeuHXB8m@4_o6 zbLG&d(uynW29Y zMbm!QR?*?Ow3hJezHS#5S%jxTURUEvQG!M#`5I{Z-_4Xbd(W>_>o=+Qw@fTzqv>1n zwXm3~YqciM86M4SGiz01Ela(o0=8Uu%U#Qtv2_`}t0poqwk_ zr)(y6(#I(=g!@EMI$w+%{GZz-gWcv00Nv4Z_5|HDH1;8|ZDatIR(VG6TM=J_Rp9y~ zHo~WgFEV;hndPvh-4F7xR$J0kEr}CF#TGjm@)Je9KD$ny+wGZ*xD9KdBKnmut+$C< zZ08Nh`2724EvrIt7fa$_X7u`q*bKA!0V^?Q+|zYIJA^Q$G~5oq?`ap_#74??kUWm?^woJ>9z}RVki5cSDaFf zh<9^V03EqmbSKd2unqo!PQQ%a;i}7Gw;>dPHfy|Q5?%t^PCt~XzgFT&qP5<8TTG~> z9*?YlhsCkgUGAyX%Q~Q9G-C#Cm-ZLT!(Y72hw&vK9vcrDq~mSEe-fXC%lp)1U`(}M zC!IY|?U-c1oG2>U6Gdr{Q@KrceqmKs0JSXLD{Q`lrAkExag+cpUYr$P3o0%YIH6P% zaT1#m|zumA_ z*!pv&y)IS9O#We@XA&^d!!gc|>!GQN%jM1UGbf7j=M|^5hBiB!Q5b2np*_Mj6LT%4 zEW;z5aW6qjNOrNC)FYvpg;SN=QB)n&iX-X)R@+&Aw;Mb>6icnlGueycbu6YQ+8v-9 zSlRk>(YDeioxr7pBR^9|F!r=d+%{!b$)ne1Hi`=0ziH`N8U6q5oqLoV)tT6T->vGN zo`+`i1fdsLARZcog^-Pn0Ru9`4)z*nv6F0kZ0`a#*v@WZlHD9<<2~#333l+}L*gYd z*xrB->j&nM1!K&^5}*eHBwmsb=rLeP=snWBXS%!U=8vlCsqX2X>6-4Ish+vtId!I| zyQ{jo`rcoC-~C?CZyi5CHOq;aXdo%knuca{HWm&ffI&1_i3ZRHm55EAPZ_pzOwOZB z4Rl0H3V3~0M%cm1uYBXrsD`; zMeI1@1SWF~6r`-yxDnS16Jmh|m1izjKmS}tTqMbdXURPLIOXSSN{29oCs!@J@Zrfl z-Av+ob`%v|()zx%Z%MH?Tr+sWVFrf&PbCMiaok13u^&(D`bSJO9AxxCRIS5P+7C6N_HaGWbVNE~fd(2( z?owWOV-nY)Ry=O`uB_hDUl~9xSr|{1$DPKSSYiLBDnm2^JnQ3bBV)iRl^-NN`3O{1zp!Doe7d(m*7;hcE)h^4eqBne zhZ>W(4z=P+C#bqrmi>09a)63%j-##YY8iG4l_{23xXNpTp(dFx3Kh8!O0@4Q6|rZ` z{G)nN|8=ntredY%DlyrOqi9_4kBg&dq&z9hpMRtp2eQ$sIgYbM#t{Z&iHeQs*?>fj zB1mgzg0AgUQ9{FOnWJc+r5+ncQH{0YYQVMX@a)5tTdH+1UjvP!fhJ@k0wt*u*sj;u zZL%?u6p0+iI^+(aM;$Cut3g%gro0rl?h8%*!pSejB(5K=xN6$Nt#ZNoz10J#ra7J} ziDpPg3(rc3MG`LyXtMFrh>EO%4!UVygDw?{RedkK)Gdb#!B}c?m+-<IBf6f=cd9e_%PB2ampzJ@IIVcp8_%S9 z*~4-i;c8j2aZfD{t`O33oEL)PSf(f9KEnKDTc}jV#H*BxfFtmZ8IL;42v(N4H%LbP}2m%aK!B`1AQite&oa~Rbi<_h?Ay3{WgxGDdQ*_mRHC+pOTfE4%G5P6k1Nk zb$Yzzbdh#5(ZH3Q6)OHadFQ?`F(=YK88^!TC(4qARCIn?>M32b9EcV(iK&uFY#c>( z;V2sZpu2IJFRL{?eGSw-5l5p=G#tgD+v#Usgw`JO?3vhz9}7A;l0~!7jt0;^XOIrJ ze}XRVRMSMI49!U@&^j?0MK!rLRhQRs)t}1+&p$g+qt8a9Gp}~&U7~exFk$Z_;J9qL`-NHu2N*bs)Mlz;7mr*M8I<= zB7ev7^}i6A*tIPi?Y@Vo_)QIQ$%^K{U?=A`ol*9Ft#&iZRNJHi^aJ)>n(b)eRQs~W zunjyTT51I_9MvQiOh!>0wBoACaYX+=Q2!|H>(@`#D74XPUmWMH(q1>nj?9rr`&pv6 zB=tU??>#&TQ(B{NSkz9Yv*D8W)=Mlcf1g+m^=TmSYIJ(Ch+U zMf6&hZ+v~CwawxH&1UwIj9)B$;0Ssn`yN^CL;})9O=ptn-8A9mut*k#!-|F_5tk|) zwxstPC{_!sMFAoClfTq@sCNH7mi73^0}ti>DbE@y_P9_6$1~7IIrh*v*0>{_>tH3v zj~i5X?@dq!$LE3Hy3!H;70YYnI)a{52* z#fXuzIOPqY?QuIX*RRUntsOcB4@TOz$x89b#^t!Grr{DJFQ#j8ed7hR+R@4jypul6 z37r|&XF6^aD@MG=byE?TRm&)H3>Az-11Q3dxSpVeu(^L%N1hwvip!z5^Qw3;@5E51w5G`u5hhL9Ph>ejc3sB8#aX!hlgS+W> zqAk8GY?#X}O{*hiF^zi~`yX+A@+_97PK&hffHTCSgF6AY%;0vN+g#D4CE5+5)oRS0 zvz{_Joh2Se?%Zj_$oNe77velYcDg<6jcCRT!e`n(!nxM0n#0o-SZJmA(wsGs+jwm6 zeTRMS0OhSp>JgKDAYv(lR;y_$m837Nbykv5WW-1mfbM@c^9Ilx*OPS7Jlhj}nRZsP zmA{>P9j8jN2!yI5qP^nD-=km zgXb9Oi6u?;QYnc>j6?+J>5t=R&E|NXqPNy}|KOV(O-E1LKeuTK|8w4FIa4*E#X*aU zD?wpmJ+AYSTu~2UzG~pE*`H>0%Q=ycw14O*52bgLRe~eqD=&A<=|$`EcRV#Qi&orR zNxLa!zE@ISZA6~B5hG)Z9a5#923Zr!X(B?GPP4Y>H9j}zqmlP-Z$5*&6JO+~$Dih( z(r@GHd@U~Oq;z1 zvW{j#V#;YEIye$i;vG($ptNEMb0=1WOx65em*cV_(AzeGUe(*yPR&) zZia+aUiJHgviaJ~YuIu8A$s)`2-(S7%Pi9OJrAx$I&_Zo!hg*3u0_ZwlF{Ny%UMn+ zr$1A%*M+j2Xfj4pLE(N0`}=?aXcU0%B=hQWk_SPb7P~p(o44;Uz{4ju@n`d{8JBlm ziK4yveVo^P27j9Er(@tf-W)o>p7de%WxMFnLw;*vZ-6C4(V~(pv|2eUaSG?87I9W; zA#HYZWM@%W(r$*kJD;H!jTE@Tqrem3fF7bV-9vL@`MLM2W}MCa_80g~*A`wGIzX59 z+VI(;u*JegNQp(GBuI%(qq1m_B)=c(;E)@W1_ps6ND3jOM_TViYlc>%$i^sutM@#6 zBhdibeiyELvwj%it8qI?C&ZY0`d;I*=623*S~TwCDTFq=nUyn^v2w;T&uVcUvf9CM zo!nfsL}4o%TU9UkT%&pHDhN5kGlJaj~jn1t+GTee6F8-IdGbWrRT&{WhkE|dD@SD zFzzSZr!)NhvB&AjR=0Mhtt?VjlIBDs&2}TriAGX(Lsj7ku)XIUZtvQNph;Ar6oLS+ z4t9)t|D>{MNi@=uXrjqZk+PB`RRUXCC{ajgGPT8|Vc$rI7IOrRqv){dQDlf%2|)M1 zn|%)SdvQKtS73vCn4cW`9T_)UR~NgZ{~-VQ_#^a^@k5WQGho*b?TwshFdk`EDI66i z?Zosbs!fj~BjW<77tW;ajq?eM2DHVG`gd~Q;osK{L^}o!@|Q;*U>`jQE?NcQR8`Pw z-7fYH*0y_rAT(PklpB}Yv8A-BIJWIAH+)LRNGmoH8K3}JSN$~pS5jk%%bf!+^V6fh zW56AX_1SLg+sj`Z{yDqcu9{rgj1nH|dI?w8wmjkttC3ixG3hbVHLGG0N7dR)kD_S} z(1yDQ_x7Y3>Y$vLMvKc2`(NR25B`EfL-FfO;%JwD?%cqakN$!^^pX|OHRxHdwD9m? z2e0+k_GfE~Wg(JUo5bZhg34*z8z-&UXq`0yD8P<6GdITn)CKKtkEP$^i-+&yi4!j~ zi5o!6$tW_8B2#^8f&u!AIjwu@ z>ub0iaQeCR)TOT4%>>{L_axsu^h^FQSetex z4Jtu=`q7sY-eVj^#$=xafM)CP>IyDGhE3UneD(Ol{OSH5@vGy{^6tQ)N!3bEYna0~ z+CGZbLH@k1DO5H)$GPF)y*zRJMf#k9svbQhaRO-JS2vC#<0zVVv8T!x(YXR2kQLbO z_Oh*eBUblDKBVSwacT+Yq!w~|>J;WA+DJ(QB9v`XYryqQaVugL(v@v1`FP)(Je4`% z^?K5^ybf@X4BzN}l9fF#@u|k;T-0(Fi&LkNvXW(eUya7mF1=1a$1+_U7&^|r^f6u< z>OkS5vEl_z?cG8puU$s~Cn=E&KbqFYIJ8cPUq+DurvYe%hOG;{;2h_ro=&oTuV8D< zOqHNr&EkyYZ06am%(9wjR1GA>&dY7j;xOc9=y3)(m_Es?*>|{p#s~TM%!{LGZYs%F zW?#j+Blj~jK^mL_UgHGY`Zghbn>a;Me9)fH(uTR5Vztt2dEZw=;o|z=v!5eQ5Bt+6 z*y$dp!|7pAG?r^2TH~mgg^ZQVD53y9LhLe%CatBh5d+W&`dHB2Q(HP`DV=cBJf{!y z?BJ0R)2nc(1*z(yJ-5|?lh3od=^W-JXGS`cvzr$2ADUKkd*3GS6Ez@`cl_$#KD@*O zrQslhJmhwu`rh$wECNL<6p1%^6|R2+^4kA;>70Tqg3_`0X$u|*#Y2RO6cQP=GiQxc zEz1UXHQ;LCIT4D=lm@8sCsL64lU16m@&FP%r$d$ArC?Lq+k%|+eR_!dPOOa_b14X) zp7kNlQ>{LgYadT3I=G2=-_3IIzen)BTz?Dx2jvP%3up_Ch0-c#)`S0AjDFu}Cb4l8 z88HBzbh&?E2iy9i=byJFTKG@1t}(hn)!|8j)>#6K_30=&2716aiVQ%fj11y(XXld) zIMFkpR?Rq@KS?d~3`@UEpP2Dp0Yr5XWosg0Cu@>XrMh2fsI=8O?F)R$7pT`PB&Vo(Ln3# zuH&^7;)h+EI5c!D^8O1N=JJ=Vm;39pMxq+*Gh7!tn+*Xnl1a>jWfYa=DAHNrYqEUp z)urG$0;Ym1?du=F)+-QxW8i53T9p|J{9kF};n|L{xT0`H zXmd3;@7Y63e`gVR`dVi$x~Vwegi(qqqbJf)G>B5y$nv#!jmr``cS8?H?CXI0%m^_6 ztsBWIKd@fl!GS%z(z`qIF;c35znFauDN$%s0DNp%pqZ1lxM|NWmK@z3GR-~nU3@Uf zSmt&m{oFXHB= z3v)0wQd(v^nj1QHu;j?@9E2p-UD{jY6akTdqM)v*?!~hw^?$;uW=(f*J?z9 zNCwe9K)pd7JT9)8R-^RXXjCb~i+{I9;3%C3Us zXqRn$d-&d=Uvb0HUmFdysL38O+jsBs;3=Vs2ma##mP*8Hf+xf99QMT5!cjyNM^WK1 z?(u~&_wnD8(WUdnhukzTckkq{_y3H~AHJWT5AK?<-1vIZ zJd2v`J}~h$O@*M84^QQ7qO=E5k@bMwj|=UdYbSjvN`r6lyc_Z~s{rhM2OpU9TR6q@wMIA`6a8tU$k zf&OeohsfAnW+WPbPQ3OcE_a+*OMBD%n3ZUuE8EBOU2pKq-YvZBc4Ot78m>Q`jNLxc zbob2m8#?mgDFHl1qm&>yXo6*l_t%H0r9jEB1}Zs%4nr-cnND=+IpJ-Q8k!yA>_k^& zSZa>jW*NW$bW#Y)PI~y+iM1@0Hty}-${wo^Jblxwo|GZ89L)_K+lL)SzPCLGPZ3}1 z^ucKfmIbz@uq=gT4V&~z6@)lRvG~x@;8_i=lfnpqLp8mCS@%7x3dWX5w_Ld{Yeu30 z=!A=<1&!wJzE`pQM0d;eO2Fl(6B^AkjG3_6e$%eES#mVwZ6_a|Ic?JtYzuEw0Yvd2 zsz52TT+f)@cAr=t>Nm z?K?eqO6W@Zv7H_~Jjr3M;znS z+FnpuP~npLb~7psKnqF8}FRbVWEwatb&1kdO$OP$GBM~xvz3;9loLrFn&S7{`5EL(j z;7L9_6TbH81JtqvO9HjQ9S#5h3D`+QK~!(N4^m&l4InC1JEJJ77ha#MU;S_mAu3l^ z_{%(F=Boi{Od(1Mt!6r24b9xN#Z5tmQDC;`yypQtB?M1Z08fTB(9qgw;6Osc-gat{ zQIvn|^78D3gmrBVr}Q6Hu=7EdZ+y+nRRd5XF_&46=7zo7y&!p?HTh=yFbr)Kz>_?9 z+P2EAg<7F_&~OM-?$)Js$OB)sj3S~HWcK%S-LL2x#IWfdms}3|yb5k>kBOL|WCKtm zv6I5w_Al?+$&#bHbFY?$r|&4TJYa_5nXp2kOhM?91nZtUZ{b9w9Fx7)J&JNCvx;pW z`CRqaMbvGTnFY>|%}6x>H4@VWjKJIedj zI<~X;=pG-Oey2~-sIbFGJZF&|geT?TDZ}uz3*o6ffC8dJP9vEVM^O>zj*!KO{ea%y zo~#(2+b_Ko;m*pmSGm)S(CAV;j7jU0i0ldF8Y;B*Zt5-yv_fk^d&O`-i`GQy)tl0& zg^ttQ?CPZX89W&hvIlIdw^ziO>~l$hS|;vH$u+SrtxT0G*S@=#MMpbwedYtxS_m9J zz85_Yo)Utmgy2a5JSF6DmjZa!B%^4Q*UP?8m%+@(^eb0<&hL3N9?kP=wEh)P$vk?E z^Vi>RX5jcND+<6TA??)if0zcK5oiWx_*;wrf0O@u!hgTzAET_O@jEV7E6$(G$CFBC zP1hg6(e&>+p`ZGjm)sW*r{lATfA8(9*POnvX-$!7TZUH02smj%6_I2(P(E<_1ZkPM zp!iUzQV_m03{T02r_K+S58zz0jH0sOTDI&&}x;uzCPh{$~4NZ19v4ntgBv0B!QY8UWPx_meW02LxdC4D7G3lFLqSRaYpXVY*y= zt#J&^G=aG(0k!5XLdjYC$Pm9lRC>x;hD*kCOfRkJDqACc0p=knW?KiG2Vn4Xz# z+mNt*hmjw?WaYw@B$V$S6`m6nI3ImDAp8og|DN`ZFPHL$>s=SOGGu=S_!h9Vasud| zN&Chx7!=k9pjH7un|yEv+Gn%>d{_fjA&63r78=OET1@lx%haWdTI6yiq^2@EPYmkU zfA_9_{D(XBu{fXTt$+5u`PW^ux;JEVDfUUtc8pSeK! zFg*Q2A;U#N$T!_QAbEdj{L6cl&DP~L|jWcs>P;XxQ z2mQ<)JM__dIqx6e_Wlo?fA*{|P`DmCnpq!$*NA~2-s?(vv6_|=lqFugr|%pJgVW9n zRSLy=hBVC)>z>spJ;x}=sE*b-nUY4kSIUY_8x00)2T&^n&|n?3DYOOVdjQa|1{xWh zg>v~U5;tF{K6LsFxxCS?g1ItDl5shHs82ut@LqS_|J_}mEf*fW=R?;mJhkyt#o<$i zNh}2eEEKjB6hqDx2?_Gu1Fdruc!o9GVGBN1j-puJ%R#;*=dU;IkgEjHLYM|y0MJB8 z3vCQ-f%chX*ny;oR=2Gz!2b7DvgqOk>hjqsKAcd@jr)alH3Qulw!C%ReeUnJ=xy)x z*LQ};ym`p4@4W2BIWrSil~_v~Q)`o;e{-lhlJW~A2ZfM5ZPQx=wSp`o%Xcz~Mgnhp zsIgo$cutz5C{DBfo6^4FHiN&~02D^9gNC)xU_CUv_E~EEvnF-h?zvPgTRvAVX-l#y zX~|NFNqsm+)7dkmx9;!fg&)7-Z2ip<(o<=$%%6`u@(&;V%AB_3Cr5S46jOLKc_wyH z=vb6s`Mr!RAC|#d=WuSjc&+=C0Wc~b1eD%Qj-6AnOb^Ui#lV5&6!DyMYhF!3w@eWGEX9A zUuo!bTn=Tmyq9tG!TyZy=p590w;pkKf8#|ur|ZCZ<x9+w2r=N}(Do-DHWUv8J;OWRc8ZhQWgaPS>VGblvsl)H1`XDVra>^rEwt%(z1o z3(7Tl!_W(_W#$6vL*Yq5pZ8pA4`G~IXKAf?iff&K-i63#q_G(SNA9nF+iAz)es z&kLsM&$N@De)qx|*Ij+;cN4a{t{ixY&d)@G;kF%;7uPBIg^(k`(>RL!i~Tc5e%0?6 zZ)6exS_n}o0Zl~(ai2!&Uyi(R&Gju!_IE^>SK1`bZ|F80!#NB_@f*Q0rpay`MWe_c zP~0NRH#}}e=Hvjh6hx&6G)K#)H!_Xng-1R-^Ma+bzHM84k#KYJut^-Mj+8%0bRIav zTBlcR**J>IkU`MjXK?5?S-qv-jL69aXeo#m0@Xm%NS>T{@e?arW~lE7`d^INZ(WAr zmA5u(97R#ZMfCk-?Z1)tKi6SKV0-{tO0zWJG%~h2^xD7tXd7kcp^_GnJMGZiW z7@1tC?zrT19Qz^^tI&D{S}#R719ZzIoX#O&2k4z>wxO*ZG^SrMJ%^^Q{C_k}9n%#z RZ%hCH002ovPDHLkV1i#_Ln{CP literal 0 HcmV?d00001 diff --git a/grc/main.py b/grc/main.py new file mode 100755 index 0000000..32fb6a3 --- /dev/null +++ b/grc/main.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 + +# Copyright 2009-2020 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +import argparse +import gettext +import locale +import logging +import logging.handlers +import os +import platform +import sys +from pathlib import Path + + +VERSION_AND_DISCLAIMER_TEMPLATE = """\ +GNU Radio Companion (%s) - +This program is part of GNU Radio. +GRC comes with ABSOLUTELY NO WARRANTY. +This is free software, and you are welcome to redistribute it. +""" + +LOG_LEVELS = { + 'debug': logging.DEBUG, + 'info': logging.INFO, + 'warning': logging.WARNING, + 'error': logging.ERROR, + 'critical': logging.CRITICAL, +} + +# Load GNU Radio +# Do this globally so it is available for both run_gtk() and run_qt() +try: + from gnuradio import gr +except ImportError as ex: + # Throw a new exception with more information + print("Cannot find GNU Radio! (Have you sourced the environment file?)", file=sys.stderr) + + # If this is a background session (not launched through a script), show a Tkinter error dialog. + # Tkinter should already be installed default with Python, so this shouldn't add new dependencies + if not sys.stdin.isatty(): + import tkinter + from tkinter import messagebox + # Hide the main window + root = tkinter.Tk() + root.withdraw() + # Show the error dialog + # TODO: Have a more helpful dialog here. Maybe a link to the wiki pages? + messagebox.showerror("Cannot find GNU Radio", "Cannot find GNU Radio!") + + # Throw the new exception + raise Exception("Cannot find GNU Radio!") from None + + +# Enable Logging +# Do this globally so it is available for both run_gtk() and run_qt() +# TODO: Advanced logging - https://docs.python.org/3/howto/logging-cookbook.html#formatting-styles +# Note: All other modules need to use the 'grc.' convention +log = logging.getLogger('grc') +# Set the root log name +# Since other files are in the 'grc' module, they automatically get a child logger when using: +# log = logging.getLogger(__name__) +# This log level should be set to DEBUG so the logger itself catches everything. +# The StreamHandler level can be set independently to choose what messages are sent to the console. +# The default console logging should be WARNING +log.setLevel(logging.DEBUG) + + +def run_gtk(args, log): + ''' Runs the GTK version of GNU Radio Companion ''' + + import gi + gi.require_version('Gtk', '3.0') + gi.require_version('PangoCairo', '1.0') + from gi.repository import Gtk + + # Delay importing until the logging is setup + from .gui.Platform import Platform + from .gui.Application import Application + + # The platform is loaded differently between QT and GTK, so this is required both places + log.debug("Loading platform") + platform = Platform( + version=gr.version(), + version_parts=(gr.major_version(), gr.api_version(), + gr.minor_version()), + prefs=gr.prefs(), + install_prefix=gr.prefix() + ) + platform.build_library() + + log.debug("Loading application") + app = Application(args.flow_graphs, platform) + log.debug("Running") + sys.exit(app.run()) + + +def run_qt(args, log): + ''' Runs the Qt version of GNU Radio Companion ''' + + import platform + import locale + import gettext + + from .gui_qt import grc + from .gui_qt import helpers + from .gui_qt import properties + + # Delay importing until the logging is setup + from .gui_qt.Platform import Platform + + ''' Global Settings/Constants ''' + # Initialize a class with all of the default settings and properties + # TODO: Move argv to separate argument parsing class that overrides default properties? + # TODO: Split settings/constants into separate classes rather than a single properites class? + settings = properties.Properties(sys.argv) + + ''' Translation Support ''' + # Try to get the current locale. Always add English + lc, encoding = locale.getdefaultlocale() + if lc: + languages = [lc] + languages += settings.DEFAULT_LANGUAGE + log.debug("Using locale - %s" % str(languages)) + + # Still run even if the english translation isn't found + language = gettext.translation(settings.APP_NAME, settings.path.LANGUAGE, languages=languages, + fallback=True) + if type(language) == gettext.NullTranslations: + log.error("Unable to find any translation") + log.error("Default English translation missing") + else: + log.info("Using translation - %s" % language.info()["language"]) + # Still need to install null translation to let the system handle calls to _() + language.install() + + ''' OS Platform ''' + # Figure out system specific properties and setup defaults. + # Some properties can be overridden by preferences + # Get the current OS + if platform.system() == "Linux": + log.debug("Detected Linux") + settings.system.OS = "Linux" + # Determine if Unity is running.... + try: + # current_desktop = os.environ['DESKTOP_SESSION'] + current_desktop = os.environ['XDG_CURRENT_DESKTOP'] + log.debug("Desktop Session - %s" % current_desktop) + if current_desktop == "Unity": + log.debug("Detected GRC is running under unity") + # Use the native menubar rather than leaving it in the window + settings.window.NATIVE_MENUBAR = True + except: + log.warning("Unable to determine the Linux desktop system") + + elif platform.system() == "Darwin": + log.debug("Detected Mac OS X") + settings.system.OS = "OS X" + # Setup Mac specific QT elements + settings.window.NATIVE_MENUBAR = True + elif platform.system() == "Windows": + log.warning("Detected Windows") + settings.system.OS = "Windows" + else: + log.warning("Unknown operating system") + + ''' Preferences ''' + # TODO: Move earlier? Need to load user preferences and override the default properties/settings + + # The platform is loaded differently between QT and GTK, so this is required both places + log.debug("Loading platform") + # TODO: Might be beneficial to rename Platform to avoid confusion with the builtin Python module + # Possible names: internal, model? + model = Platform( + version=gr.version(), + version_parts=(gr.major_version(), gr.api_version(), gr.minor_version()), + prefs=gr.prefs(), + install_prefix=gr.prefix() + ) + model.build_library() + + # Launch GRC + app = grc.Application(settings, model, args.flow_graphs) + sys.exit(app.run()) + + +def get_config_file_path(config_file: str = "grc.conf") -> str: + oldpath = os.path.join(os.path.expanduser("~/.gnuradio"), config_file) + try: + from gnuradio.gr import paths + newpath = os.path.join(paths.userconf(), config_file) + if os.path.exists(newpath): + return newpath + if os.path.exists(oldpath): + log.warn(f"Found specification for config path '{newpath}', but file does not exist. " + + f"Old default config file path '{oldpath}' exists; using that. " + + "Please consider moving configuration to new location.") + return oldpath + # Default to the correct path if both are configured. + # neither old, nor new path exist: create new path, return that + path_parts = Path(newpath).parts[:-1] + pathdir = os.path.join(*path_parts) + os.makedirs(pathdir, exist_ok=True) + return newpath + except ImportError: + log.warn("Could not retrieve GNU Radio configuration directory from GNU Radio. Trying defaults.") + xdgconf = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdgcand = os.path.join(xdgconf, config_file) + if os.path.exists(xdgcand): + return xdgcand + if os.path.exists(oldpath): + log.warn(f"Using legacy config path '{oldpath}'. Please consider moving configuration " + + f"files to '{xdgcand}'.") + return oldpath + # neither old, nor new path exist: create new path, return that + path_parts = Path(xdgcand).parts[:-1] + pathdir = os.path.join(*path_parts) + os.makedirs(pathdir, exist_ok=True) + return xdgcand + + +def get_state_directory() -> str: + oldpath = os.path.expanduser("~/.grc_gnuradio") + try: + from gnuradio.gr import paths + newpath = paths.persistent() + if os.path.exists(newpath): + return newpath + if os.path.exists(oldpath): + log.warn(f"Found specification for persistent state path '{newpath}', but file does not exist. " + + f"Old default persistent state path '{oldpath}' exists; using that. " + + "Please consider moving state to new location.") + return oldpath + # Default to the correct path if both are configured. + # neither old, nor new path exist: create new path, return that + os.makedirs(newpath, exist_ok=True) + return newpath + except (ImportError, NameError): + log.warn("Could not retrieve GNU Radio persistent state directory from GNU Radio. Trying defaults.") + xdgstate = os.getenv("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + xdgcand = os.path.join(xdgstate, "gnuradio") + if os.path.exists(xdgcand): + return xdgcand + if os.path.exists(oldpath): + log.warn(f"Using legacy state path '{oldpath}'. Please consider moving state " + + f"files to '{xdgcand}'.") + return oldpath + # neither old, nor new path exist: create new path, return that + os.makedirs(xdgcand, exist_ok=True) + return xdgcand + + +def main(): + grc_version_from_config = "" + grc_qt_config_file = get_config_file_path('grc_qt.conf') + if os.path.isfile(grc_qt_config_file): + try: + from qtpy.QtCore import QSettings + qsettings = QSettings(grc_qt_config_file, QSettings.IniFormat) + grc_version_from_config = qsettings.value('grc/default_grc', "", type=str) + except Exception as e: + log.warning("main.py could not read grc_qt.conf") + log.warning(e) + + # Argument parsing + parser = argparse.ArgumentParser( + description=VERSION_AND_DISCLAIMER_TEMPLATE % gr.version()) + parser.add_argument('flow_graphs', nargs='*') + + # Custom Configurations + # TODO: parser.add_argument('--config') + + # Logging support + parser.add_argument('--log', choices=['debug', 'info', 'warning', 'error', 'critical'], default='info') + # TODO: parser.add_argument('--log-output') + + # Graphics framework (QT or GTK) + gui_group = parser.add_argument_group('Framework') + gui_group_exclusive = gui_group.add_mutually_exclusive_group() + gui_group_exclusive.add_argument("--qt", dest='framework', action='store_const', const='qt', + help="GNU Radio Companion (QT)") + gui_group_exclusive.add_argument("--gtk", dest='framework', action='store_const', const='gtk', + help="GNU Radio Companion (GTK)") + + # Default options if not already set with add_argument() + args = parser.parse_args() + + # Print the startup message + py_version = sys.version.split()[0] + log.info("Starting GNU Radio Companion {} (Python {})".format(gr.version(), py_version)) + + # File logging + log_file = os.path.join(get_state_directory(), "grc.log") + try: + fileHandler = logging.FileHandler(log_file) + file_msg_format = '%(asctime)s [%(levelname)s] %(message)s' + if args.log == 'debug': + file_msg_format += ' (%(name)s:%(lineno)s)' + fileHandler.setLevel(logging.DEBUG) + log.info(f'Logging to {log_file} (DEBUG and higher)') + else: + fileHandler.setLevel(logging.INFO) + log.info(f'Logging to {log_file} (INFO and higher)') + file_formatter = logging.Formatter(file_msg_format) + fileHandler.setFormatter(file_formatter) + log.addHandler(fileHandler) + except (PermissionError, FileNotFoundError) as e: + log.error(f'Cannot write to {log_file} - {e}') + + # GUI Framework + if args.framework == 'qt': + run_qt(args, log) + elif args.framework == 'gtk': + run_gtk(args, log) + else: # args.framework == None + if grc_version_from_config == 'grc_qt': + run_qt(args, log) + else: + run_gtk(args, log) diff --git a/grc/scripts/CMakeLists.txt b/grc/scripts/CMakeLists.txt new file mode 100644 index 0000000..b0c264c --- /dev/null +++ b/grc/scripts/CMakeLists.txt @@ -0,0 +1,14 @@ +# Copyright 2011 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +######################################################################## +GR_PYTHON_INSTALL( + PROGRAMS gnuradio-companion grcc + DESTINATION ${GR_RUNTIME_DIR} +) + +add_subdirectory(freedesktop) diff --git a/grc/scripts/freedesktop/CMakeLists.txt b/grc/scripts/freedesktop/CMakeLists.txt new file mode 100644 index 0000000..356f07a --- /dev/null +++ b/grc/scripts/freedesktop/CMakeLists.txt @@ -0,0 +1,28 @@ +# Copyright 2011-2012 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +######################################################################## + +find_program(HAVE_XDG_UTILS xdg-desktop-menu) + +if(UNIX AND HAVE_XDG_UTILS AND ENABLE_POSTINSTALL) + configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/grc_setup_freedesktop.in + ${CMAKE_CURRENT_BINARY_DIR}/grc_setup_freedesktop + FILE_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + @ONLY) + install ( + CODE "execute_process(COMMAND ${CMAKE_CURRENT_BINARY_DIR}/grc_setup_freedesktop install)" + ) +endif(UNIX AND HAVE_XDG_UTILS AND ENABLE_POSTINSTALL) + +if(UNIX AND ENABLE_TESTING) + find_program(APPSTREAMCLI appstream-util) + if(APPSTREAMCLI) + GR_ADD_TEST(metainfo_test "${APPSTREAMCLI}" validate-relax "${CMAKE_CURRENT_SOURCE_DIR}/org.gnuradio.grc.metainfo.xml") + endif(APPSTREAMCLI) +endif(UNIX AND ENABLE_TESTING) diff --git a/grc/scripts/freedesktop/README b/grc/scripts/freedesktop/README new file mode 100644 index 0000000..b1d1543 --- /dev/null +++ b/grc/scripts/freedesktop/README @@ -0,0 +1,20 @@ +################################################## +# Freedesktop Notes +################################################## + +This directory contains icons, a mime type, and menu files for grc. +Installation of these files requires a set of tools called xdg-utils. +xdg-utils will install files in a standard way according to the freedesktop.org standards. + +Desktop environments that implement these standards are gnome, kde, xfce, and others. +If you have one of these desktop environments, xdg-utils should come installed. +If xdg-utils is not installed, then this directory will be ignored by the build. + +The gtk IconTheme module locates the installed icons for use inside the grc app. +On recent versions of gtk, IconTheme ignores the icons installed into the default theme. +This presents a problem for the gnome file system browser, nautilus, and the grc app. +As a solution, icons are also installed under the gnome theme. + +*.png files - these are the icons of various sizes +*.desktop files - these are the menu items for grc executables +*.yml (*.xml) file - this is the mime type for the saved flow graphs (and legacy flow graphs) diff --git a/grc/scripts/freedesktop/convert.sh b/grc/scripts/freedesktop/convert.sh new file mode 100755 index 0000000..e2cba26 --- /dev/null +++ b/grc/scripts/freedesktop/convert.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +SRC="gnuradio_logo_icon-square.svg" +SIZES="16 24 32 48 64 128 256" + +for size in $SIZES +do + inkscape --without-gui \ + --export-png="grc-icon-$size.png" \ + --export-area=8.0:8.0:141.316:141.316 \ + --export-width=$size \ + --export-height=$size \ + $SRC; +done diff --git a/grc/scripts/freedesktop/gnuradio-grc.desktop b/grc/scripts/freedesktop/gnuradio-grc.desktop new file mode 100644 index 0000000..6c7284d --- /dev/null +++ b/grc/scripts/freedesktop/gnuradio-grc.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=GNU Radio Companion +Comment=Graphical tool for creating signal flow graphs and generating flow-graph source code with the GNU Radio framework +Exec=gnuradio-companion %F +Categories=Development;HamRadio;Science; +Keywords=SDR;Radio;HAM;RF; +MimeType=application/gnuradio-grc; +Icon=gnuradio-grc +StartupWMClass=gnuradio-companion diff --git a/grc/scripts/freedesktop/gnuradio-grc.xml b/grc/scripts/freedesktop/gnuradio-grc.xml new file mode 100644 index 0000000..a5cb95d --- /dev/null +++ b/grc/scripts/freedesktop/gnuradio-grc.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/grc/scripts/freedesktop/gnuradio_logo_icon-square.svg b/grc/scripts/freedesktop/gnuradio_logo_icon-square.svg new file mode 100644 index 0000000..3b54bf4 --- /dev/null +++ b/grc/scripts/freedesktop/gnuradio_logo_icon-square.svg @@ -0,0 +1,124 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/grc/scripts/freedesktop/grc-icon-128.png b/grc/scripts/freedesktop/grc-icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..13efe806ba6cd737653127a326ce287c1dbfb24a GIT binary patch literal 4758 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 b6ajt%hmw*31MD@A00000NkvXXu0mjfHAdeb literal 0 HcmV?d00001 diff --git a/grc/scripts/freedesktop/grc-icon-24.png b/grc/scripts/freedesktop/grc-icon-24.png new file mode 100644 index 0000000000000000000000000000000000000000..a12476812503d62a4a47b92d799441745b62a58f GIT binary patch literal 840 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 literal 0 HcmV?d00001 diff --git a/grc/scripts/freedesktop/grc-icon-32.png b/grc/scripts/freedesktop/grc-icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..a345aace3c8d65cc1045b8cde6e1ffa106a0963d GIT binary patch literal 1148 zcmV-?1cUpDP)M798=2hV>=ddbBV;FKj#o3~MhfLb5}+((nG zl}vu6WNaf#D0IRctCsfhG60j*d&L5*nx9zB`>{Ptb`n5 zj6ea>%@lu_?IF4D#jy>Dv;nKBF|SoaUNJQ7eV}PGaiz_?qiOGKwLW8y001J>K&`gn zURI#VYRREo7+#Gacn@)&q`@52lnxQ*gH6D2wE|)@*mN;pl`X>A^E*oUjd>n0uOsZ5x4r6NL|PDOC*gm_wD$nw zB$<()WFYr*IsfGabee9I=jWLIAxr@Eb8KHq#y(We*L*0s2sLHapzMySf1|R6j9n%f z+km4FqgvPnTQr^6wBQ18Xs0krDk(YOe>@xKefckNr4(XuPPasfdwI$IVU z)aRDoG)tal8EOs@-k3lb{a^nWC?AIDnj@eaqv{3FZ%}fGtYh|)!VgT^W*Ng@cmX>} zV$DYg#p3)Vp0`nYvsE2TyrDC0OfLL%QMZ;-8q9N<6bCtq>9Kyx7g O0000?Zu(TU5=Giz*1l_Faxj% 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 + + +org.gnuradio.grc +gnuradio-grc.desktop +CC0-1.0 +GPL-3.0+ +GNU Radio Companion +

Graphical Signal Processing Flow Graph Design Tool + +

+ The GNU Radio Companion (GRC) is a tool that allows users of all levels to + design and modify high-rate signal processing applications. It is the GUI + frontend for GNU Radio application designers. +

+

+ GNU Radio is centered around the concept of the Block, which is a + node in a signal processing flow graph. Since GNU Radio itself comes with a lot + of useful blocks already, one can start developing real-time and + hardware-interfacing or simulation flow graphs right away, using GRC as the tool + to connect and parameterize these blocks. +

+

+ GRC itself generates a program in a target language (Python or C++), which + contains all the signal flow setup as defined visually. +

+
+ + Development + Engineering + IDE + GUIDesigner + HamRadio + DataVisualization + + + + GNU Radio Companion + https://upload.wikimedia.org/wikipedia/commons/5/5d/GNU_Radio_Companion_%283.8.1.0%29_Screenshot.png + + +GNU Radio +https://www.gnuradio.org +https://wiki.gnuradio.org/ +https://github.com/gnuradio/gnuradio/issues +https://lists.gnu.org/mailman/listinfo/discuss-gnuradio +https://chat.gnuradio.org + + gnuradio-companion + grcc + + diff --git a/grc/scripts/gnuradio-companion b/grc/scripts/gnuradio-companion new file mode 100755 index 0000000..38a421f --- /dev/null +++ b/grc/scripts/gnuradio-companion @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +import os +import sys + + +GR_IMPORT_ERROR_MESSAGE = """\ +Cannot import gnuradio. + +Is the Python path environment variable set correctly? + All OS: PYTHONPATH + +Is the library path environment variable set correctly? + Linux: LD_LIBRARY_PATH + Windows: PATH + +See https://wiki.gnuradio.org/index.php/ModuleNotFoundError +""" + + +def die(error, message): + msg = "{0}\n\n({1})".format(message, error) + try: + import gi + gi.require_version('Gtk', '3.0') + from gi.repository import Gtk + d = Gtk.MessageDialog( + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.CLOSE, + text=msg, + ) + d.set_title(type(error).__name__) + d.run() + sys.exit(1) + except ImportError: + sys.exit(type(error).__name__ + '\n\n' + msg) + except Exception as _exception: + print( + "While trying to display an error message, another error occurred", + file=sys.stderr) + print(_exception, file=sys.stderr) + print("The original error message follows.", file=sys.stderr) + sys.exit(type(error).__name__ + '\n\n' + msg) + + +def check_gtk(): + try: + import gi + gi.require_version('Gtk', '3.0') + gi.require_version('PangoCairo', '1.0') + gi.require_foreign('cairo', 'Context') + + from gi.repository import Gtk + success = Gtk.init_check()[0] + if not success: + # Don't display a warning dialogue. This seems to be a Gtk bug. If it + # still can display warning dialogues, it does probably work! + print( + "Gtk init_check failed. GRC might not be able to start a GUI.", + file=sys.stderr) + + except Exception as err: + die(err, "Failed to initialize GTK. If you are running over ssh, " + "did you enable X forwarding and start ssh with -X?") + + +def check_gnuradio_import(): + try: + from gnuradio import gr + except ImportError as err: + die(err, GR_IMPORT_ERROR_MESSAGE) + + +def check_blocks_path(): + if 'GR_DONT_LOAD_PREFS' in os.environ and not os.environ.get('GRC_BLOCKS_PATH', ''): + die(EnvironmentError("No block definitions available"), + "Can't find block definitions. Use config.conf or GRC_BLOCKS_PATH.") + + +def run_main(): + script_path = os.path.dirname(os.path.abspath(__file__)) + source_tree_subpath = "/grc/scripts" + + if not script_path.endswith(source_tree_subpath): + # run the installed version + from gnuradio.grc.main import main + else: + print("Running from source tree") + sys.path.insert(1, script_path[:-len(source_tree_subpath)]) + from grc.main import main + sys.exit(main()) + + +if __name__ == '__main__': + check_gnuradio_import() + check_gtk() + check_blocks_path() + run_main() diff --git a/grc/scripts/grcc b/grc/scripts/grcc new file mode 100755 index 0000000..e3f5df1 --- /dev/null +++ b/grc/scripts/grcc @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +import os +import sys + + +GR_IMPORT_ERROR_MESSAGE = """\ +Cannot import gnuradio. + +Is the Python path environment variable set correctly? + All OS: PYTHONPATH + +Is the library path environment variable set correctly? + Linux: LD_LIBRARY_PATH + Windows: PATH + +See https://wiki.gnuradio.org/index.php/ModuleNotFoundError +""" + + +def die(error, message): + msg = "{0}\n\n({1})".format(message, error) + exit(type(error).__name__ + '\n\n' + msg) + + +def check_gnuradio_import(): + try: + from gnuradio import gr + except ImportError as err: + die(err, GR_IMPORT_ERROR_MESSAGE) + + +def run_main(): + script_path = os.path.dirname(os.path.abspath(__file__)) + source_tree_subpath = "/grc/scripts" + + if not script_path.endswith(source_tree_subpath): + # run the installed version + from gnuradio.grc.compiler import main + else: + print("Running from source tree") + sys.path.insert(1, script_path[:-len(source_tree_subpath)]) + from grc.compiler import main + exit(main()) + + +if __name__ == '__main__': + check_gnuradio_import() + run_main() diff --git a/grc/tests/.gitignore b/grc/tests/.gitignore new file mode 100644 index 0000000..f477b80 --- /dev/null +++ b/grc/tests/.gitignore @@ -0,0 +1,3 @@ +.pytest_cache +resources/top_block.py +tests/resources/tests/ \ No newline at end of file diff --git a/grc/tests/CMakeLists.txt b/grc/tests/CMakeLists.txt new file mode 100644 index 0000000..5072c19 --- /dev/null +++ b/grc/tests/CMakeLists.txt @@ -0,0 +1,23 @@ +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +include(GrPython) + +if(ENABLE_TESTING) + GR_PYTHON_CHECK_MODULE_RAW( + "pytest" + "import pytest" + PYTEST_FOUND + ) + + if(PYTEST_FOUND) + set(GR_TEST_ENVIRONS "GRC_BLOCKS_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/../blocks\"") + GR_ADD_TEST(grc_tests ${QA_PYTHON_EXECUTABLE} -B -m pytest --ignore ${CMAKE_CURRENT_SOURCE_DIR}/test_qtbot.py ${CMAKE_CURRENT_SOURCE_DIR} -m \"not examples\") + # To run the grcc tests over examples manually, run + # python3 -B -m pytest gnuradio/grc/tests -m "examples" + endif() +endif(ENABLE_TESTING) diff --git a/grc/tests/__init__.py b/grc/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grc/tests/resources/file1.block.yml b/grc/tests/resources/file1.block.yml new file mode 100644 index 0000000..ee858f7 --- /dev/null +++ b/grc/tests/resources/file1.block.yml @@ -0,0 +1,38 @@ +id: block_key +label: testname + +parameters: +- id: vlen + label: Vector Length + category: test + dtype: int + default: '1' + hide: ${ 'part' if vlen == 1 else 'none' } +- id: out_type + label: Vector Length + dtype: string + default: complex + hide: part +- id: a + label: Alpha + dtype: ${ (out_type) } + default: '0' + +inputs: +- domain: stream + dtype: complex + vlen: ${ 2 * vlen } +- domain: message + id: in2 + +outputs: +- domain: stream + dtype: ${ out_type } + vlen: ${ vlen } +asserts: +- ${ vlen > 0 } + +templates: + make: blocks.complex_to_mag_squared(${vlen}) + +file_format: 1 diff --git a/grc/tests/resources/file2.block.yml b/grc/tests/resources/file2.block.yml new file mode 100644 index 0000000..4595272 --- /dev/null +++ b/grc/tests/resources/file2.block.yml @@ -0,0 +1,31 @@ +id: blocks_and_const_xx +label: And Const + +parameters: +- id: type + label: IO Type + dtype: enum + options: [int, short, byte] + option_attributes: + fcn: [ii, ss, bb] + hide: part +- id: const + label: Constant + dtype: int + default: '0' + +inputs: +- domain: stream + dtype: ${ type } + +outputs: +- domain: stream + dtype: ${ type } + +templates: + imports: from gnuradio import blocks + make: blocks.and_const_${type.fcn}(${const}) + callbacks: + - set_k(${const}) + +file_format: 1 diff --git a/grc/tests/resources/file3.block.yml b/grc/tests/resources/file3.block.yml new file mode 100644 index 0000000..2350b34 --- /dev/null +++ b/grc/tests/resources/file3.block.yml @@ -0,0 +1,66 @@ +id: variable_qtgui_check_box +label: QT GUI Check Box + +parameters: +- id: label + label: Label + dtype: string + hide: ${ ('none' if label else 'part') } +- id: type + label: Type + dtype: enum + default: int + options: [real, int, string, bool, raw] + option_labels: [Float, Integer, String, Boolean, Any] + option_attributes: + conv: [float, int, str, bool, eval] + hide: part +- id: value + label: Default Value + dtype: ${ type } + default: 'True' +- id: 'true' + label: 'True' + dtype: ${ type } + default: 'True' +- id: 'false' + label: 'False' + dtype: ${ type } + default: 'False' +- id: gui_hint + label: GUI Hint + dtype: gui_hint + hide: part +value: ${ value } + +asserts: +- ${value in (true, false)} + +templates: + imports: from PyQt5 import Qt + var_make: self.${id} = ${id} = ${value} + callbacks: + - self.set_${id}(${value}) + - self._${id}_callback(${id}) + make: |- + <% + win = '_%s_check_box'%id + if not label: + label = id + %> + ${win} = Qt.QCheckBox(${label}) + self._${id}_choices = {True: ${true}, False: ${false}} + self._${id}_choices_inv = {${true}: True, ${false}: False} + self._${id}_callback = lambda i: Qt.QMetaObject.invokeMethod(${win}, "setChecked", Qt.Q_ARG("bool", self._${id}_choices_inv[i])) + self._${id}_callback(self.${id}) + ${win}.stateChanged.connect(lambda i: self.set_${id}(self._${id}_choices[bool(i)])) + ${gui_hint()(win)} + +documentation: |- + This block creates a variable check box. Leave the label blank to use the variable id as the label. + + A check box selects between two values of similar type. The values do not necessarily need to be of boolean type. + + The GUI hint can be used to position the widget within the application. The hint is of the form [tab_id@tab_index]: [row, col, row_span, col_span]. Both the tab specification and the grid position are optional. + +file_format: 1 diff --git a/grc/tests/resources/test_compiler.grc b/grc/tests/resources/test_compiler.grc new file mode 100644 index 0000000..cc56ace --- /dev/null +++ b/grc/tests/resources/test_compiler.grc @@ -0,0 +1,253 @@ + + + + Thu Sep 15 12:56:40 2016 + + options + + author + + + + window_size + + + + category + [GRC Hier Blocks] + + + comment + + + + description + + + + _enabled + 1 + + + _coordinate + (8, 8) + + + _rotation + 0 + + + generate_options + no_gui + + + hier_block_src_path + .: + + + id + top_block + + + max_nouts + 0 + + + qt_qss_theme + + + + realtime_scheduling + + + + run_command + {python} -u {filename} + + + run_options + run + + + run + True + + + thread_safe_setters + + + + title + + + + + blocks_add_const_vxx + + alias + + + + comment + + + + const + 1 + + + affinity + + + + _enabled + True + + + _coordinate + (360, 28) + + + _rotation + 0 + + + id + blocks_add_const_vxx_0 + + + type + complex + + + maxoutbuf + 0 + + + minoutbuf + 0 + + + vlen + 1 + + + + blocks_null_sink + + alias + + + + bus_conns + [[0,],] + + + comment + + + + affinity + + + + _enabled + True + + + _coordinate + (504, 32) + + + _rotation + 0 + + + id + blocks_null_sink_0 + + + type + complex + + + num_inputs + 1 + + + vlen + 1 + + + + blocks_vector_source_x + + alias + + + + comment + + + + affinity + + + + _enabled + True + + + _coordinate + (208, 12) + + + _rotation + 0 + + + id + blocks_vector_source_x_0 + + + maxoutbuf + 0 + + + minoutbuf + 0 + + + type + complex + + + repeat + False + + + tags + [] + + + vlen + 1 + + + vector + (0, 0, 0) + + + + blocks_add_const_vxx_0 + blocks_null_sink_0 + 0 + 0 + + + blocks_vector_source_x_0 + blocks_add_const_vxx_0 + 0 + 0 + + diff --git a/grc/tests/resources/test_cpp.grc b/grc/tests/resources/test_cpp.grc new file mode 100644 index 0000000..2ee40c0 --- /dev/null +++ b/grc/tests/resources/test_cpp.grc @@ -0,0 +1,88 @@ +options: + parameters: + author: '' + category: '[GRC Hier Blocks]' + cmake_opt: '' + comment: '' + copyright: '' + description: '' + gen_cmake: 'On' + gen_linking: dynamic + generate_options: qt_gui + hier_block_src_path: '.:' + id: horn + max_nouts: '0' + output_language: cpp + placement: (0,0) + qt_qss_theme: '' + realtime_scheduling: '' + run: 'True' + run_command: '{python} -u {filename}' + run_options: prompt + sizing_mode: fixed + thread_safe_setters: '' + title: horn + window_size: '' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [8, 8] + rotation: 0 + state: enabled + +blocks: +- name: samp_rate + id: variable + parameters: + comment: '' + value: '32000' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [184, 12] + rotation: 0 + state: enabled +- name: blocks_null_sink_0 + id: blocks_null_sink + parameters: + affinity: '' + alias: '' + bus_structure_sink: '[[0,],]' + comment: '' + num_inputs: '1' + type: complex + vlen: '1' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [688, 104.0] + rotation: 0 + state: true +- name: blocks_null_source_0 + id: blocks_null_source + parameters: + affinity: '' + alias: '' + bus_structure_source: '[[0,],]' + comment: '' + maxoutbuf: '0' + minoutbuf: '0' + num_outputs: '1' + type: complex + vlen: '1' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [336, 160.0] + rotation: 0 + state: true + +connections: +- [blocks_null_source_0, '0', blocks_null_sink_0, '0'] + +metadata: + file_format: 1 diff --git a/grc/tests/test_block_flags.py b/grc/tests/test_block_flags.py new file mode 100644 index 0000000..9969eeb --- /dev/null +++ b/grc/tests/test_block_flags.py @@ -0,0 +1,29 @@ + +from grc.core.blocks._flags import Flags + + +def test_simple_init(): + assert 'test' not in Flags() + assert 'test' in Flags(' test') + assert 'test' in Flags('test, foo') + assert 'test' in Flags({'test', 'foo'}) + + +def test_deprecated(): + assert Flags.DEPRECATED == 'deprecated' + assert Flags('this is deprecated').deprecated is True + + +def test_extend(): + f = Flags('a') + f.set('b') + assert isinstance(f, Flags) + f.set(u'b') + assert isinstance(f, Flags) + f = Flags(u'a') + f.set('b') + assert isinstance(f, Flags) + f.set(u'b') + assert isinstance(f, Flags) + + assert f.data == {'a', 'b'} diff --git a/grc/tests/test_block_templates.py b/grc/tests/test_block_templates.py new file mode 100644 index 0000000..bd3dd49 --- /dev/null +++ b/grc/tests/test_block_templates.py @@ -0,0 +1,47 @@ +import pytest + +from grc.core.blocks._templates import MakoTemplates +from grc.core.errors import TemplateError + + +class Block(object): + namespace_templates = {} + + templates = MakoTemplates(None) + + def __init__(self, **kwargs): + self.namespace_templates.update(kwargs) + + +def test_simple(): + t = MakoTemplates(_bind_to=Block(num='123'), test='abc${num}') + assert t['test'] == 'abc${num}' + assert t.render('test') == 'abc123' + assert 'abc${num}' in t._template_cache + + +def test_instance(): + block = Block(num='123') + block.templates['test'] = 'abc${num}' + assert block.templates.render('test') == 'abc123' + assert block.templates is block.__dict__['templates'] + + +def test_list(): + templates = ['abc${num}', '${2 * num}c'] + t = MakoTemplates(_bind_to=Block(num='123'), test=templates) + assert t['test'] == templates + assert t.render('test') == ['abc123', '123123c'] + assert set(templates) == set(t._template_cache.keys()) + + +def test_parse_error(): + with pytest.raises(TemplateError): + MakoTemplates(_bind_to=Block(num='123'), + test='abc${num NOT CLOSING').render('test') + + +def test_parse_error2(): + with pytest.raises(TemplateError): + MakoTemplates(_bind_to=Block(num='123'), + test='abc${ WRONG_VAR }').render('test') diff --git a/grc/tests/test_cheetah_converter.py b/grc/tests/test_cheetah_converter.py new file mode 100644 index 0000000..02944a3 --- /dev/null +++ b/grc/tests/test_cheetah_converter.py @@ -0,0 +1,134 @@ +"""""" + +import functools +import grc.converter.cheetah_converter as parser + + +def test_basic(): + c = parser.Converter(names={'abc'}) + for convert in (c.convert_simple, c.convert_hard, c.to_python): + assert 'abc' == convert('$abc') + assert 'abc' == convert('$abc()') + assert 'abc' == convert('$(abc)') + assert 'abc' == convert('$(abc())') + assert 'abc' == convert('${abc}') + assert 'abc' == convert('${abc()}') + + assert c.stats['simple'] == 2 * 6 + assert c.stats['hard'] == 1 * 6 + + +def test_simple(): + convert = parser.Converter(names={'abc': {'def'}}) + assert 'abc' == convert.convert_simple('$abc') + assert 'abc.def' == convert.convert_simple('$abc.def') + assert 'abc.def' == convert.convert_simple('$(abc.def)') + assert 'abc.def' == convert.convert_simple('${abc.def}') + try: + convert.convert_simple('$abc.not_a_sub_key') + except NameError: + assert True + else: + assert False + + +def test_conditional(): + convert = parser.Converter(names={'abc'}) + assert '(asb_asd_ if abc > 0 else __not__)' == convert.convert_inline_conditional( + '#if $abc > 0 then asb_$asd_ else __not__') + + +def test_simple_format_string(): + convert = functools.partial(parser.Converter( + names={'abc'}).convert_simple, spec=parser.FormatString) + assert '{abc}' == convert('$abc') + assert '{abc:eval}' == convert('$abc()') + assert '{abc}' == convert('$(abc)') + assert '{abc:eval}' == convert('$(abc())') + assert '{abc}' == convert('${abc}') + assert '{abc:eval}' == convert('${abc()}') + + +def test_hard_format_string(): + names = {'abc': {'ff'}, 'param1': {}, 'param2': {}} + convert = functools.partial(parser.Converter( + names).convert_hard, spec=parser.FormatString) + assert 'make_a_cool_block_{abc.ff}({param1}, {param2})' == \ + convert('make_a_cool_block_${abc.ff}($param1, $param2)') + + +converter = parser.Converter(names={'abc'}) +c2p = converter.to_python + + +def test_opts(): + assert 'abc abc abc' == c2p('$abc $(abc) ${abc}') + assert 'abc abc.abc abc' == c2p('$abc $abc.abc ${abc}') + assert 'abc abc[''].abc abc' == c2p('$abc $abc[''].abc() ${abc}') + + +def test_nested(): + assert 'abc(abc) abc + abc abc[abc]' == c2p( + '$abc($abc) $(abc + $abc) ${abc[$abc]}') + assert '(abc_abc_)' == c2p('(abc_$(abc)_)') + + +def test_nested2(): + class Other(parser.Python): + nested_start = '{' + nested_end = '}' + assert 'abc({abc})' == converter.convert('$abc($abc)', spec=Other) + + +def test_nested3(): + class Other(parser.Python): + start = '{' + end = '}' + assert '{abc(abc)}' == converter.convert('$abc($abc)', spec=Other) + + +def test_with_string(): + assert 'abc "$(abc)" abc' == c2p('$abc "$(abc)" ${abc}') + assert 'abc \'$(abc)\' abc' == c2p('$abc \'$(abc)\' ${abc}') + assert 'abc "\'\'$(abc)" abc' == c2p('$abc "\'\'$(abc)" ${abc}') + + +def test_if(): + result = converter.to_mako(""" + #if $abc > 0 + test + #else if $abc < 0 + test + #else + bla + #end if + """) + + expected = """ + % if abc > 0: + test + % elif abc < 0: + test + % else: + bla + % endif + """ + assert result == expected + + +def test_hash_end(): + result = converter.to_mako('$abc#slurp') + assert result == '${abc}\\' + + +def test_slurp_if(): + result = converter.to_mako(""" + $abc#slurp + #if $abc + """) + + expected = """ + ${abc} + % if abc: + """ + assert result == expected diff --git a/grc/tests/test_compiler.py b/grc/tests/test_compiler.py new file mode 100644 index 0000000..4e0e262 --- /dev/null +++ b/grc/tests/test_compiler.py @@ -0,0 +1,29 @@ +# Copyright 2016 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import pytest + +from argparse import Namespace +from os import path +import tempfile + +from grc.compiler import main + + +def test_compiler(capsys): + args = Namespace( + output=tempfile.gettempdir(), + user_lib_dir=False, + grc_files=[path.join(path.dirname(__file__), + 'resources', 'test_compiler.grc')], + run=True + ) + + main(args) + + out, err = capsys.readouterr() + assert not err diff --git a/grc/tests/test_cpp.py b/grc/tests/test_cpp.py new file mode 100644 index 0000000..6f585a5 --- /dev/null +++ b/grc/tests/test_cpp.py @@ -0,0 +1,29 @@ +# Copyright 2019 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import pytest + +from argparse import Namespace +from os import path +import tempfile + +from grc.compiler import main + + +def test_cpp(capsys): + args = Namespace( + output=tempfile.gettempdir(), + user_lib_dir=False, + grc_files=[path.join(path.dirname(__file__), + 'resources', 'test_cpp.grc')], + run=True + ) + + main(args) + + out, err = capsys.readouterr() + assert not err diff --git a/grc/tests/test_evaled_property.py b/grc/tests/test_evaled_property.py new file mode 100644 index 0000000..468f16b --- /dev/null +++ b/grc/tests/test_evaled_property.py @@ -0,0 +1,106 @@ +import collections +import numbers + +from grc.core.utils.descriptors import Evaluated, EvaluatedEnum, EvaluatedPInt + + +class A(object): + def __init__(self, **kwargs): + self.called = collections.defaultdict(int) + self.errors = [] + self.namespace = kwargs + + def add_error_message(self, msg): + self.errors.append(msg) + + @property + def parent_block(self): + return self + + def evaluate(self, expr): + self.called['evaluate'] += 1 + return eval(expr, self.namespace) + + @Evaluated(int, 1) + def foo(self): + self.called['foo'] += 1 + return eval(self._foo) + + bar = Evaluated(numbers.Real, 1.0, name='bar') + + test = EvaluatedEnum(['a', 'b'], 'a', name='test') + + lala = EvaluatedPInt() + + +def test_fixed_value(): + a = A() + a.foo = 10 + + assert not hasattr(a, '_foo') + assert a.foo == 10 + assert a.called['foo'] == 0 + delattr(a, 'foo') + assert a.foo == 10 + assert a.called['foo'] == 0 + + +def test_evaled(): + a = A() + a.foo = '${ 10 + 1 }' + assert getattr(a, '_foo') == '10 + 1' + assert a.foo == 11 and a.foo == 11 + assert a.called['foo'] == 1 + assert a.called['evaluate'] == 0 + delattr(a, 'foo') + assert a.foo == 11 and a.foo == 11 + assert a.called['foo'] == 2 + assert not a.errors + a.foo = u'${ 10 + 2 }' + assert a.foo == 12 + + +def test_evaled_with_default(): + a = A() + a.bar = '${ 10 + 1 }' + assert getattr(a, '_bar') == '10 + 1' + assert a.bar == 11.0 and type(a.bar) == int + assert a.called['evaluate'] == 1 + assert not a.errors + + +def test_evaled_int_with_default(): + a = A(ll=10) + a.lala = '${ ll * 2 }' + assert a.lala == 20 + a.namespace['ll'] = -10 + assert a.lala == 20 + del a.lala + assert a.lala == 1 + assert not a.errors + + +def test_evaled_enum_fixed_value(): + a = A() + a.test = 'a' + assert not hasattr(a, '_test') + assert a.test == 'a' and type(a.test) == str + assert not a.errors + + +def test_evaled_enum(): + a = A(bla=False) + a.test = '${ "a" if bla else "b" }' + assert a.test == 'b' + a.namespace['bla'] = True + assert a.test == 'b' + del a.test + assert a.test == 'a' + assert not a.errors + + +def test_class_access(): + a = A() + a.foo = '${ meme }' + descriptor = getattr(a.__class__, 'foo') + assert descriptor.name_raw == '_foo' diff --git a/grc/tests/test_examples.py b/grc/tests/test_examples.py new file mode 100644 index 0000000..25b518b --- /dev/null +++ b/grc/tests/test_examples.py @@ -0,0 +1,106 @@ +import os + +from os import path + +import pytest + +from grc.core.platform import Platform + +try: + os.mkdir(path.join(path.dirname(__file__), 'resources/tests')) +except FileExistsError: + pass + +# Gather blocks +BLOCK_PATHS = [] +ROOT = path.join(path.dirname(__file__), '../..') +BLOCK_PATHS = [path.abspath(path.join(ROOT, 'grc/blocks')), + path.abspath(path.join(ROOT, 'build/gr-uhd/grc'))] +for file_dir in os.scandir(ROOT): + # If it is a module + if path.isdir(file_dir) and file_dir.name.startswith("gr-"): + BLOCK_PATHS.append(path.abspath(path.join(file_dir, "grc"))) + +# These examples are known to fail and need to be resolved +# But skip now to allow test to pass and prevent further +# regression on the vast majority of examples +BLACKLISTED = ['transmitter_sim_hier.grc', + 'uhd_packet_rx.grc', + 'uhd_packet_tx_tun.grc', + 'packet_loopback_hier.grc', + 'uhd_packet_tx.grc', + 'uhd_packet_rx_tun.grc', + 'formatter_ofdm.grc', + 'ber_curve_gen_ldpc.grc', + 'ber_curve_gen.grc', + 'polar_ber_curve_gen.grc', + 'tpc_ber_curve_gen.grc', + 'comparing_resamplers.grc', + 'pfb_sync_test.grc', + 'soapy_receive.grc', + 'soapy_receive2.grc', + 'soapy_transmit.grc', + 'fm_radio_receiver_soapyremote.grc', + 'fm_radio_receiver_soapy.grc', + 'uhd_siggen_gui.grc', + 'filter_taps_loader.grc', + 'uhd_fft.grc' + ] + + +def gather_examples(): + global ROOT, BLACKLISTED + example_paths = [] + for file_dir in os.scandir(ROOT): + # If it is a module + if path.isdir(file_dir) and file_dir.name.startswith("gr-") and not file_dir.name.startswith('gr-trellis'): + try: + for pos_ex in os.scandir(path.join(file_dir, "examples")): + if path.isfile(pos_ex) and pos_ex.name.endswith(".grc") and not path.basename(pos_ex) in BLACKLISTED: + example_paths.append(pos_ex) + elif path.isdir(pos_ex): + for pos_ex2 in os.scandir(pos_ex): + if path.isfile(pos_ex2) and pos_ex2.name.endswith(".grc") and not path.basename(pos_ex2) in BLACKLISTED: + example_paths.append(pos_ex2) + + except FileNotFoundError: + continue + return example_paths + + +def print_proper(element): + if element.is_block: + return element.name + return f"{element.parent.name} - {element}" + + +@pytest.mark.examples +@pytest.mark.parametrize("example", gather_examples()) +def test_all_examples(example): + global BLOCK_PATHS + + print(example.path) + + platform = Platform( + name='GNU Radio Companion Compiler', + prefs=None, + version='0.0.0', + ) + platform.build_library(BLOCK_PATHS) + + flow_graph = platform.make_flow_graph(example.path) + flow_graph.rewrite() + flow_graph.validate() + + assert flow_graph.is_valid(), (example.name, [ + f"{print_proper(elem)}: {msg}" for elem, msg in flow_graph.iter_error_messages()]) + + generator = platform.Generator(flow_graph, path.join( + path.dirname(__file__), 'resources/tests')) + generator.write() + + +if __name__ == '__main__': + examples = gather_examples() + for example in examples: + test_all_examples(example) diff --git a/grc/tests/test_expr_utils.py b/grc/tests/test_expr_utils.py new file mode 100644 index 0000000..637fb34 --- /dev/null +++ b/grc/tests/test_expr_utils.py @@ -0,0 +1,40 @@ +import operator + +import pytest + +from grc.core.utils import expr_utils + +id_getter = operator.itemgetter(0) +expr_getter = operator.itemgetter(1) + + +def test_simple(): + objects = [ + ['c', '2 * a + b'], + ['a', '1'], + ['b', '2 * a + unknown * d'], + ['d', '5'], + ] + + expected = [ + ['d', '5'], + ['a', '1'], + ['b', '2 * a + unknown * d'], + ['c', '2 * a + b'], + ] + + out = expr_utils.sort_objects(objects, id_getter, expr_getter) + + assert out == expected + + +def test_circular(): + test = [ + ['c', '2 * a + b'], + ['a', '1'], + ['b', '2 * c + unknown'], + ] + + # Should fail due to circular dependency + with pytest.raises(Exception): + expr_utils.sort_objects(test, id_getter, expr_getter) diff --git a/grc/tests/test_generator.py b/grc/tests/test_generator.py new file mode 100644 index 0000000..931d3e9 --- /dev/null +++ b/grc/tests/test_generator.py @@ -0,0 +1,41 @@ +# Copyright 2016 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import pytest + +from os import path +import tempfile + +from grc.core.platform import Platform + + +def test_generator(): + # c&p form compiler code. + grc_file = path.join(path.dirname(__file__), + 'resources', 'test_compiler.grc') + out_dir = tempfile.gettempdir() + block_paths = [ + path.join(path.dirname(__file__), '../../grc/blocks'), + path.join(path.dirname(__file__), '../../gr-blocks/grc') + ] + + platform = Platform( + name='GNU Radio Companion Compiler', + prefs=None, + version='0.0.0', + ) + platform.build_library(block_paths) + + flow_graph = platform.make_flow_graph(grc_file) + flow_graph.rewrite() + flow_graph.validate() + + assert flow_graph.is_valid() + + generator = platform.Generator( + flow_graph, path.join(path.dirname(__file__), 'resources')) + generator.write() diff --git a/grc/tests/test_qtbot.py b/grc/tests/test_qtbot.py new file mode 100644 index 0000000..bfcdb3a --- /dev/null +++ b/grc/tests/test_qtbot.py @@ -0,0 +1,946 @@ +import pytest +import gettext +import locale +import threading +import sys + +from pytestqt.plugin import qapp + +import time + +import pyautogui as pag + +import logging + +from qtpy import QtTest, QtCore, QtGui, QtWidgets, QT6 +from os import path, remove + +from gnuradio import gr +from grc.gui_qt import properties +from grc.gui_qt.grc import Application +from grc.gui_qt.components.window import MainWindow +from grc.gui_qt.Platform import Platform + +log = logging.getLogger("grc") + + +@pytest.fixture(scope="session") +def qapp_cls_(): + settings = properties.Properties([]) + settings.argv = [""] + + """ Translation Support """ + # Try to get the current locale. Always add English + lc, encoding = locale.getlocale() + if lc: + languages = [lc] + languages += settings.DEFAULT_LANGUAGE + log.debug("Using locale - %s" % str(languages)) + + # Still run even if the english translation isn't found + language = gettext.translation( + settings.APP_NAME, settings.path.LANGUAGE, languages=languages, fallback=True + ) + if type(language) == gettext.NullTranslations: + log.error("Unable to find any translation") + log.error("Default English translation missing") + else: + log.info("Using translation - %s" % language.info()["language"]) + # Still need to install null translation to let the system handle calls to _() + language.install() + + model = Platform( + version=gr.version(), + version_parts=(gr.major_version(), gr.api_version(), gr.minor_version()), + prefs=gr.prefs(), + install_prefix=gr.prefix(), + ) + model.build_library() + app = Application(settings, model, []) + app.MainWindow.showMaximized() + return app + + +def global_pos(block, view): + scene_pos = block.mapToScene(block.boundingRect().center()) + global_pos = view.viewport().mapToGlobal(view.mapFromScene(scene_pos)) + return global_pos + + +def type_text(qtbot, app, keys): + for key in keys: + # Each sequence contains a single key. + # That's why we use the first element + keycode = QtGui.QKeySequence(key)[0] + if QT6: + qtbot.keyClick(app.focusWidget(), keycode.key(), QtCore.Qt.NoModifier) + else: + qtbot.keyClick(app.focusWidget(), keycode, QtCore.Qt.NoModifier) + + +def keystroke(qtbot, app, key): + qtbot.keyClick(app.focusWidget(), key, QtCore.Qt.NoModifier) + qtbot.wait(100) + + +def ctrl_keystroke(qtbot, app, key): + qtbot.keyClick(app.focusWidget(), key, QtCore.Qt.ControlModifier) + qtbot.wait(100) + + +def gather_menu_items(menu): + ret = {} + for act in menu.actions(): + ret[act.text()] = act + return ret + + +def add_block_from_query(qtbot, app, query): + qtbot.keyClick(app.focusWidget(), QtCore.Qt.Key_F, QtCore.Qt.ControlModifier) + type_text(qtbot, app, query) + qtbot.wait(10) + pag.press('down') + qtbot.wait(10) + pag.press('enter') + qtbot.wait(10) + + +def find_blocks(flowgraph, block_type): + blocks = [] + for b in flowgraph.blocks: + if b.key == block_type: + blocks.append(b) + + if len(blocks) == 0: + return None + if len(blocks) == 1: + return blocks[0] + return blocks + + +def click_on(qtbot, app, item, button="left"): + scaling = app.MainWindow.screen().devicePixelRatio() + view = app.MainWindow.currentView + click_pos = scaling * global_pos(item.gui, view) + pag.click(click_pos.x(), click_pos.y(), button=button) + qtbot.wait(100) + + +def undo(qtbot, app): + qtbot.keyClick(app.focusWidget(), QtCore.Qt.Key_Z, QtCore.Qt.ControlModifier) + qtbot.wait(100) + + +def redo(qtbot, app): + qtbot.keyClick( + app.focusWidget(), + QtCore.Qt.Key_Z, + QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier, + ) + qtbot.wait(100) + + +def delete_block(qtbot, app, block): + view = app.MainWindow.currentView + scaling = app.MainWindow.screen().devicePixelRatio() + click_pos = scaling * global_pos(block.gui, view) + pag.click(click_pos.x(), click_pos.y(), button="left") + qtbot.wait(100) + qtbot.keyClick(app.focusWidget(), QtCore.Qt.Key_Delete) + qtbot.wait(100) + + +def menu_shortcut(qtbot, app, menu_name, menu_key, shortcut_key): + menu = app.MainWindow.menus[menu_name] + qtbot.keyClick(app.focusWidget(), menu_key, QtCore.Qt.AltModifier) + qtbot.wait(100) + qtbot.keyClick(menu, shortcut_key) + qtbot.wait(100) + +# Start by closing the flowgraph that pops up on start + + +def test_file_close_init(qtbot, qapp_cls_, monkeypatch): + win = qapp_cls_.MainWindow + monkeypatch.setattr( + QtWidgets.QMessageBox, + "question", + lambda *args: QtWidgets.QMessageBox.Discard, + ) + + qtbot.wait(100) + + assert win.tabWidget.count() == 1 + menu_shortcut(qtbot, qapp_cls_, "file", QtCore.Qt.Key_F, QtCore.Qt.Key_L) + assert win.tabWidget.count() == 1 + + +def test_delete_block(qtbot, qapp_cls_): + qtbot.wait(100) + var = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "variable") + assert var is not None + + delete_block(qtbot, qapp_cls_, var) + qtbot.wait(100) + assert len(qapp_cls_.MainWindow.currentFlowgraph.blocks) == 1 + undo(qtbot, qapp_cls_) + assert len(qapp_cls_.MainWindow.currentFlowgraph.blocks) == 2 + + +def test_add_null_sink(qtbot, qapp_cls_): + qtbot.wait(100) + add_block_from_query(qtbot, qapp_cls_, "null sin") + + n_sink = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "blocks_null_sink") + assert n_sink is not None + + delete_block(qtbot, qapp_cls_, n_sink) + + +def test_add_null_source(qtbot, qapp_cls_): + qtbot.wait(100) + add_block_from_query(qtbot, qapp_cls_, "null sou") + + n_sou = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "blocks_null_source") + assert n_sou is not None + + delete_block(qtbot, qapp_cls_, n_sou) + + +def test_add_throttle(qtbot, qapp_cls_): + qtbot.wait(100) + add_block_from_query(qtbot, qapp_cls_, "throttle") + + throttle = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "blocks_throttle") + assert throttle is not None + + delete_block(qtbot, qapp_cls_, throttle) + + +def test_right_click(qtbot, qapp_cls_): + qtbot.wait(100) + add_block_from_query(qtbot, qapp_cls_, "throttle") + + throttle = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "blocks_throttle") + assert throttle is not None + qtbot.wait(100) + + def close(): + qtbot.keyClick(throttle.gui.right_click_menu, QtCore.Qt.Key_Escape) + + QtCore.QTimer.singleShot(200, close) + click_on(qtbot, qapp_cls_, throttle, button="right") + qtbot.wait(100) + + delete_block(qtbot, qapp_cls_, throttle) + + +def test_errors(qtbot, qapp_cls_): + menu = qapp_cls_.MainWindow.menus["build"] + + def assert_and_close(): + assert qapp_cls_.activeWindow() != qapp_cls_.MainWindow + qtbot.keyClick(qapp_cls_.activeWindow(), QtCore.Qt.Key_Escape) + + qtbot.wait(100) + add_block_from_query(qtbot, qapp_cls_, "throttle") + qtbot.wait(100) + qtbot.keyClick(qapp_cls_.focusWidget(), QtCore.Qt.Key_B, QtCore.Qt.AltModifier) + qtbot.wait(100) + QtCore.QTimer.singleShot(200, assert_and_close) + # qtbot.keyClick(menu, QtCore.Qt.Key_E) # Not necessary since it's already selected (it's the first item) + qtbot.keyClick(menu, QtCore.Qt.Key_Enter) + qtbot.wait(300) + + throttle = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "blocks_throttle") + assert throttle is not None + + delete_block(qtbot, qapp_cls_, throttle) + + +def test_open_properties(qtbot, qapp_cls_): + qtbot.wait(100) + qtbot.mouseDClick( + qapp_cls_.MainWindow.currentView.viewport(), + QtCore.Qt.LeftButton, + pos=qapp_cls_.MainWindow.currentView.mapFromScene( + qapp_cls_.MainWindow.currentFlowgraph.options_block.gui.pos() + + QtCore.QPointF(15.0, 15.0) + ), + ) + qtbot.wait(100) + assert qapp_cls_.MainWindow.currentFlowgraph.options_block.gui.props_dialog.isVisible() + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Escape) + assert ( + not qapp_cls_.MainWindow.currentFlowgraph.options_block.gui.props_dialog.isVisible() + ) + + +def test_change_id(qtbot, qapp_cls_): + opts = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "options") + assert opts.params["title"].value == "Not titled yet" + qtbot.mouseDClick( + qapp_cls_.MainWindow.currentView.viewport(), + QtCore.Qt.LeftButton, + pos=qapp_cls_.MainWindow.currentView.mapFromScene( + opts.gui.pos() + QtCore.QPointF(15.0, 15.0) + ), + ) + qtbot.wait(100) + qtbot.mouseDClick( + opts.gui.props_dialog.edit_params[1], + QtCore.Qt.LeftButton, + ) + type_text(qtbot, qapp_cls_, "changed") + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Enter) + assert opts.params["title"].value == "Not changed yet" + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Enter) + qtbot.wait(100) + undo(qtbot, qapp_cls_) + assert opts.params["title"].value == "Not titled yet" + redo(qtbot, qapp_cls_) + assert opts.params["title"].value == "Not changed yet" + + +def test_rotate_block(qtbot, qapp_cls_): + qtbot.wait(100) + opts = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "options") + + # Still running into issues with what has focus depending on the order of tests run. + # This is a problem! Tests should be able to run independently without affecting other + # tests. Need to track this down, but for those tests that are failing for this reason, + # something like below seems to be a workaround + click_on(qtbot, qapp_cls_, opts) + qtbot.wait(400) + click_on(qtbot, qapp_cls_, opts) + + old_rotation = opts.states["rotation"] + + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Left) + new_rotation = opts.states["rotation"] + assert new_rotation == (old_rotation - 90) % 360 + + undo(qtbot, qapp_cls_) + new_rotation = opts.states["rotation"] + assert new_rotation == old_rotation + + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Right) + new_rotation = opts.states["rotation"] + assert new_rotation == old_rotation + 90 + + undo(qtbot, qapp_cls_) + new_rotation = opts.states["rotation"] + assert new_rotation == old_rotation + + +def test_disable_enable(qtbot, qapp_cls_): + qtbot.wait(100) + var = find_blocks(qapp_cls_.MainWindow.currentFlowgraph, "variable") + click_on(qtbot, qapp_cls_, var) + + assert var is not None + assert var.state == "enabled" + + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_D) + assert var.state == "disabled" + + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_E) + assert var.state == "enabled" + + +def test_move_blocks(qtbot, qapp_cls_): + fg = qapp_cls_.MainWindow.currentFlowgraph + view = qapp_cls_.MainWindow.currentView + scaling = qapp_cls_.MainWindow.screen().devicePixelRatio() + + qtbot.wait(100) + add_block_from_query(qtbot, qapp_cls_, "throttle") + + throttle = find_blocks(fg, "blocks_throttle") + variable = find_blocks(fg, "variable") + assert throttle is not None + + click_on(qtbot, qapp_cls_, variable) + qtbot.wait(100) + + start_throttle = scaling * global_pos(throttle.gui, view) + start_variable = scaling * global_pos(variable.gui, view) + pag.moveTo(start_throttle.x(), start_throttle.y()) + pag.mouseDown() + + def drag(): + for i in range(20): + pag.move(0, scaling * 10) + + drag_t = threading.Thread(target=drag) + drag_t.start() + while drag_t.is_alive(): + qtbot.wait(50) + pag.mouseUp() + qtbot.wait(100) + assert scaling * global_pos(throttle.gui, view) != start_throttle + undo(qtbot, qapp_cls_) + assert scaling * global_pos(throttle.gui, view) == start_throttle + redo(qtbot, qapp_cls_) + assert scaling * global_pos(throttle.gui, view) != start_throttle + + # Variable shouldn't move + assert scaling * global_pos(variable.gui, view) == start_variable + delete_block(qtbot, qapp_cls_, throttle) + + +def test_connection(qtbot, qapp_cls_): + fg = qapp_cls_.MainWindow.currentFlowgraph + view = qapp_cls_.MainWindow.currentView + scaling = qapp_cls_.MainWindow.screen().devicePixelRatio() + + qtbot.wait(100) + for block in ["null sou", "null sin"]: + add_block_from_query(qtbot, qapp_cls_, block) + + n_src = find_blocks(fg, "blocks_null_source") + n_sink = find_blocks(fg, "blocks_null_sink") + + assert len(fg.connections) == 0 + + start = scaling * global_pos(n_sink.gui, view) + pag.moveTo(start.x(), start.y()) + pag.mouseDown() + + def drag(): + for i in range(20): + pag.move(scaling * 10, 0) + + drag_t = threading.Thread(target=drag) + drag_t.start() + while drag_t.is_alive(): + qtbot.wait(50) + pag.mouseUp() + + click_on(qtbot, qapp_cls_, n_src.sources[0]) + click_on(qtbot, qapp_cls_, n_sink.sinks[0]) + assert len(fg.connections) == 1 + + undo(qtbot, qapp_cls_) + assert len(fg.connections) == 0 + + redo(qtbot, qapp_cls_) + assert len(fg.connections) == 1 + + connection = next(iter(fg.connections)) # get a connection without removing it + + # delete connection with delete key press event + assert len(fg.connections) == 1 + click_on(qtbot, qapp_cls_, connection) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Delete) + assert len(fg.connections) == 0 + qtbot.wait(100) + undo(qtbot, qapp_cls_) + + # delete connection with double click + click_on(qtbot, qapp_cls_, connection) + click_on(qtbot, qapp_cls_, connection) + assert len(fg.connections) == 0 + qtbot.wait(100) + + for block in [n_src, n_sink]: + delete_block(qtbot, qapp_cls_, block) + + +def test_num_inputs(qtbot, qapp_cls_): + fg = qapp_cls_.MainWindow.currentFlowgraph + view = qapp_cls_.MainWindow.currentView + scaling = qapp_cls_.MainWindow.screen().devicePixelRatio() + + qtbot.wait(100) + for block in ["null sou", "null sin"]: + add_block_from_query(qtbot, qapp_cls_, block) + + n_src = find_blocks(fg, "blocks_null_source") + n_sink = find_blocks(fg, "blocks_null_sink") + + assert len(n_sink.sinks) == 1 + + start = scaling * global_pos(n_sink.gui, view) + pag.moveTo(start.x(), start.y()) + pag.mouseDown() + + def drag(): + for i in range(20): + pag.move(scaling * 10, 0) + + drag_t = threading.Thread(target=drag) + drag_t.start() + while drag_t.is_alive(): + qtbot.wait(50) + pag.mouseUp() + + click_on(qtbot, qapp_cls_, n_src.sources[0]) + click_on(qtbot, qapp_cls_, n_sink.sinks[0]) + qtbot.wait(100) + + click_pos = scaling * global_pos(n_sink.gui, view) + pag.doubleClick(click_pos.x(), click_pos.y(), button="left") + qtbot.wait(100) + param_index = 0 + for i in range(len(n_sink.gui.props_dialog.edit_params)): + if n_sink.gui.props_dialog.edit_params[i].param.key == 'num_inputs': + param_index = i + + qtbot.mouseDClick(n_sink.gui.props_dialog.edit_params[param_index], QtCore.Qt.LeftButton) + type_text(qtbot, qapp_cls_, "2") + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Enter) + assert len(n_sink.sinks) == 2 + assert len(fg.connections) == 1 + + click_pos = scaling * global_pos(n_sink.gui, view) + pag.doubleClick(click_pos.x(), click_pos.y(), button="left") + qtbot.wait(100) + qtbot.mouseDClick(n_sink.gui.props_dialog.edit_params[param_index], QtCore.Qt.LeftButton) + type_text(qtbot, qapp_cls_, "1") + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Enter) + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Enter) + qtbot.wait(100) + assert len(n_sink.sinks) == 1 + assert len(fg.connections) == 1 + + # I think loses focus makes delete_fail the first time. This makes it work, but is a hack + click_on(qtbot, qapp_cls_, n_src) + pag.click(click_pos.x() + 50, click_pos.y() + 50, button="left") + + for block in [n_src, n_sink]: + delete_block(qtbot, qapp_cls_, block) + qtbot.wait(100) + assert len(fg.blocks) == 2 + + +def test_bus(qtbot, qapp_cls_): + fg = qapp_cls_.MainWindow.currentFlowgraph + view = qapp_cls_.MainWindow.currentView + scaling = qapp_cls_.MainWindow.screen().devicePixelRatio() + + qtbot.wait(100) + add_block_from_query(qtbot, qapp_cls_, "null sin") + + n_sink = find_blocks(fg, "blocks_null_sink") + + assert len(n_sink.sinks) == 1 + + click_pos = scaling * global_pos(n_sink.gui, view) + pag.doubleClick(click_pos.x(), click_pos.y(), button="left") + qtbot.wait(100) + param_index = 0 + for i in range(len(n_sink.gui.props_dialog.edit_params)): + if n_sink.gui.props_dialog.edit_params[i].param.key == 'num_inputs': + param_index = i + + qtbot.mouseDClick(n_sink.gui.props_dialog.edit_params[param_index], QtCore.Qt.LeftButton) + type_text(qtbot, qapp_cls_, "2") + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Enter) + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_Enter) + qtbot.wait(100) + assert len(n_sink.sinks) == 2 + + # Enable bus port + qtbot.wait(100) + more_menu = qapp_cls_.MainWindow.menus["more"] + menu_shortcut(qtbot, qapp_cls_, "edit", QtCore.Qt.Key_E, QtCore.Qt.Key_M) + qtbot.wait(100) + qtbot.keyClick(more_menu, QtCore.Qt.Key_Up) + qtbot.wait(100) + qtbot.keyClick(more_menu, QtCore.Qt.Key_Enter) + qtbot.wait(100) + assert len(n_sink.sinks) == 3 + assert n_sink.sinks[2].dtype == 'bus' + + # Disable bus port + qtbot.wait(100) + more_menu = qapp_cls_.MainWindow.menus["more"] + menu_shortcut(qtbot, qapp_cls_, "edit", QtCore.Qt.Key_E, QtCore.Qt.Key_M) + qtbot.wait(100) + qtbot.keyClick(more_menu, QtCore.Qt.Key_Up) + qtbot.wait(100) + qtbot.keyClick(more_menu, QtCore.Qt.Key_Enter) + qtbot.wait(100) + assert len(n_sink.sinks) == 2 + + # Test undo + undo(qtbot, qapp_cls_) + qtbot.wait(100) + assert len(n_sink.sinks) == 3 + qtbot.wait(100) + undo(qtbot, qapp_cls_) + qtbot.wait(100) + assert len(n_sink.sinks) == 2 + + delete_block(qtbot, qapp_cls_, n_sink) + qtbot.wait(100) + + +def test_bypass(qtbot, qapp_cls_): + scaling = qapp_cls_.MainWindow.screen().devicePixelRatio() + + fg = qapp_cls_.MainWindow.currentFlowgraph + view = qapp_cls_.MainWindow.currentView + + qtbot.wait(100) + for block in ["null sou", "throttle"]: + add_block_from_query(qtbot, qapp_cls_, block) + + n_src = find_blocks(fg, "blocks_null_source") + throttle = find_blocks(fg, "blocks_throttle") + + # Bypass the throttle block + click_on(qtbot, qapp_cls_, throttle) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_B) + assert throttle.state == "bypassed" + undo(qtbot, qapp_cls_) + assert throttle.state == "enabled" + redo(qtbot, qapp_cls_) + assert throttle.state == "bypassed" + qtbot.wait(100) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_E) + assert throttle.state == "enabled" + + # Try to bypass the null source, this shouldn't work + click_on(qtbot, qapp_cls_, n_src) + keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_B) + assert n_src.state == "enabled" + + for block in [throttle, n_src]: + delete_block(qtbot, qapp_cls_, block) + + +def test_file_save(qtbot, qapp_cls_, monkeypatch, tmp_path): + fg_path = tmp_path / "test_save.grc" + monkeypatch.setattr( + QtWidgets.QFileDialog, "selectedFiles", lambda *args, **kargs: (str(fg_path), "") + ) + monkeypatch.setattr( + QtWidgets.QFileDialog, "exec_", lambda *args: QtWidgets.QFileDialog.Accepted + ) + + assert not fg_path.exists(), "File/Save (setup): File already exists" + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_S) + assert fg_path.exists(), "File/Save: Could not save file" + + +def test_file_save_as(qtbot, qapp_cls_, monkeypatch, tmp_path): + fg_path = tmp_path / "test.grc" + monkeypatch.setattr( + QtWidgets.QFileDialog, "selectedFiles", lambda *args, **kargs: (str(fg_path), "") + ) + monkeypatch.setattr( + QtWidgets.QFileDialog, "exec_", lambda *args: QtWidgets.QFileDialog.Accepted + ) + + qtbot.wait(100) + + menu_shortcut(qtbot, qapp_cls_, "file", QtCore.Qt.Key_F, QtCore.Qt.Key_A) + assert fg_path.exists() + + +def test_file_save_copy(qtbot, qapp_cls_, monkeypatch, tmp_path): + fg_path = tmp_path / "test_copy.grc" + monkeypatch.setattr( + QtWidgets.QFileDialog, "selectedFiles", lambda *args, **kargs: (str(fg_path), "") + ) + monkeypatch.setattr( + QtWidgets.QFileDialog, "exec_", lambda *args: QtWidgets.QFileDialog.Accepted + ) + qtbot.wait(100) + + assert not fg_path.exists(), "File/Save Copy (setup): File already exists" + menu_shortcut(qtbot, qapp_cls_, "file", QtCore.Qt.Key_F, QtCore.Qt.Key_Y) + assert fg_path.exists(), "File/Save Copy: Could not save file" + + +# TODO: File/Open + +def test_file_screen_capture_pdf(qtbot, qapp_cls_, monkeypatch, tmp_path): + fg_path = tmp_path / "test.pdf" + monkeypatch.setattr( + QtWidgets.QFileDialog, "getSaveFileName", lambda *args, **kargs: (str(fg_path), "") + ) + qtbot.wait(100) + + assert not fg_path.exists(), "File/Screen Capture (setup): PDF already exists" + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_P) + assert fg_path.exists(), "File/Screen Capture: Could not create PDF" + + +def test_file_screen_capture_png(qtbot, qapp_cls_, monkeypatch, tmp_path): + fg_path = tmp_path / "test.png" + assert not fg_path.exists() + monkeypatch.setattr( + QtWidgets.QFileDialog, "getSaveFileName", lambda *args, **kargs: (str(fg_path), "") + ) + qtbot.wait(100) + + assert not fg_path.exists(), "File/Screen Capture (setup): PNG already exists" + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_P) + assert fg_path.exists(), "File/Screen Capture: Could not create PNG" + + +def test_file_screen_capture_svg(qtbot, qapp_cls_, monkeypatch, tmp_path): + fg_path = tmp_path / "test.svg" + assert not fg_path.exists() + monkeypatch.setattr( + QtWidgets.QFileDialog, "getSaveFileName", lambda *args, **kargs: (str(fg_path), "") + ) + qtbot.wait(100) + + assert not fg_path.exists(), "File/Screen Capture (setup): SVG already exists" + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_P) + assert fg_path.exists(), "File/Screen Capture: Could not create SVG" + + +def test_file_preferences(qtbot, qapp_cls_): + menu = qapp_cls_.MainWindow.menus["file"] + items = gather_menu_items(menu) + + def assert_and_close(): + assert qapp_cls_.activeWindow() != qapp_cls_.MainWindow + qtbot.keyClick(qapp_cls_.activeWindow(), QtCore.Qt.Key_Enter) + + qtbot.keyClick(qapp_cls_.focusWidget(), QtCore.Qt.Key_F, QtCore.Qt.AltModifier) + qtbot.wait(100) + QtCore.QTimer.singleShot(200, assert_and_close) + qtbot.keyClick(menu, QtCore.Qt.Key_F) + qtbot.wait(600) + assert qapp_cls_.activeWindow() == qapp_cls_.MainWindow + qtbot.wait(100) + + +def test_file_examples(qtbot, qapp_cls_): + menu = qapp_cls_.MainWindow.menus["file"] + items = gather_menu_items(menu) + + def assert_and_close(): + assert qapp_cls_.activeWindow() != qapp_cls_.MainWindow + qtbot.keyClick(qapp_cls_.activeWindow(), QtCore.Qt.Key_Escape) + + qtbot.keyClick(qapp_cls_.focusWidget(), QtCore.Qt.Key_F, QtCore.Qt.AltModifier) + qtbot.wait(100) + QtCore.QTimer.singleShot(200, assert_and_close) + qtbot.keyClick(menu, QtCore.Qt.Key_E) + qtbot.wait(600) + assert qapp_cls_.activeWindow() == qapp_cls_.MainWindow + qtbot.wait(100) + + +def test_edit_actions(qtbot, qapp_cls_): + pass + + +def test_edit_select_all(qtbot, qapp_cls_): + qtbot.keyClick(qapp_cls_.focusWidget(), QtCore.Qt.Key_A, QtCore.Qt.ControlModifier) + qtbot.wait(100) + + +def test_edit_cut_paste(qtbot, qapp_cls_): + fg = qapp_cls_.MainWindow.currentFlowgraph + + qtbot.wait(100) + var = find_blocks(fg, "variable") + assert var is not None, "Edit/Cut and paste (setup): Could not find variable block" + + click_on(qtbot, qapp_cls_, var) + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_X) + qtbot.wait(100) + + var = find_blocks(fg, "variable") + assert var is None, "Edit/Cut and paste: Could not cut variable block" + + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_V) + qtbot.wait(100) + + var = find_blocks(fg, "variable") + assert var is not None, "Edit/Cut and paste: Could not paste variable block" + + qtbot.wait(100) + + +def test_edit_copy_paste(qtbot, qapp_cls_): + fg = qapp_cls_.MainWindow.currentFlowgraph + + qtbot.wait(100) + var = find_blocks(fg, "variable") + assert var is not None, "Edit/Copy and paste (setup): Could not find variable block" + + click_on(qtbot, qapp_cls_, var) + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_C) + qtbot.wait(100) + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_V) + + vars = find_blocks(fg, "variable") + assert isinstance(vars, list), "Edit/Copy and paste: Could not paste variable block" + assert len(vars) == 2, "Edit/Copy and paste: Could not paste variable block" + assert ( + vars[0].name != vars[1].name + ), "Edit/Copy and paste: Newly pasted variable block's ID is the same as the original block's ID" + + delete_block(qtbot, qapp_cls_, vars[1]) + + +def test_view_actions(qtbot, qapp_cls_): + pass + + +def test_build_actions(qtbot, qapp_cls_): + pass + + +def test_tools_actions(qtbot, qapp_cls_): + pass + + +def test_tools_oot_browser(qtbot, qapp_cls_): + menu = qapp_cls_.MainWindow.menus["tools"] + items = gather_menu_items(menu) + + def assert_open(): + assert qapp_cls_.activeWindow() != qapp_cls_.MainWindow + + qtbot.keyClick(qapp_cls_.focusWidget(), QtCore.Qt.Key_T, QtCore.Qt.AltModifier) + qtbot.wait(100) + QtCore.QTimer.singleShot(100, assert_open) + qtbot.keyClick(menu, QtCore.Qt.Key_O) + qtbot.wait(200) + qtbot.keyClick(qapp_cls_.activeWindow(), QtCore.Qt.Key_Escape) + qtbot.wait(200) + + +def test_reports_actions(qtbot, qapp_cls_): + pass + + +def test_help_windows(qtbot, qapp_cls_): + def assert_and_close(): + assert qapp_cls_.activeWindow() != qapp_cls_.MainWindow + qtbot.keyClick(qapp_cls_.activeWindow(), QtCore.Qt.Key_Enter) + + def test_help_window(menu_key): + qtbot.keyClick(qapp_cls_.focusWidget(), QtCore.Qt.Key_H, QtCore.Qt.AltModifier) + qtbot.wait(300) + QtCore.QTimer.singleShot(200, assert_and_close) + qtbot.keyClick(menu, menu_key) + qtbot.wait(600) + assert qapp_cls_.activeWindow() == qapp_cls_.MainWindow + qtbot.wait(100) + + menu = qapp_cls_.MainWindow.menus["help"] + qtbot.wait(100) + + for key in [ + QtCore.Qt.Key_H, + QtCore.Qt.Key_T, + QtCore.Qt.Key_K, + QtCore.Qt.Key_G, + QtCore.Qt.Key_A, + QtCore.Qt.Key_Q, + ]: + test_help_window(key) + + +def test_file_new_close(qtbot, qapp_cls_, monkeypatch): + win = qapp_cls_.MainWindow + monkeypatch.setattr( + QtWidgets.QMessageBox, + "question", + lambda *args: QtWidgets.QMessageBox.Discard, + ) + qtbot.wait(100) + + menu_shortcut(qtbot, qapp_cls_, "file", QtCore.Qt.Key_F, QtCore.Qt.Key_N) + assert win.tabWidget.count() == 2, "File/New" + + for i in range(3, 5): + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_N) + assert win.tabWidget.count() == i, "File/New" + + for i in range(1, 4): + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_W) + assert win.tabWidget.count() == 4 - i, "File/Close" + + +def test_generate(qtbot, qapp_cls_, monkeypatch, tmp_path): + fg = qapp_cls_.MainWindow.currentFlowgraph + view = qapp_cls_.MainWindow.currentView + scaling = qapp_cls_.MainWindow.screen().devicePixelRatio() + fg_path = tmp_path / "test_generate.grc" + py_path = tmp_path / "default.py" + monkeypatch.setattr( + QtWidgets.QFileDialog, "selectedFiles", lambda *args, **kargs: (str(fg_path), "") + ) + monkeypatch.setattr( + QtWidgets.QFileDialog, "exec_", lambda *args: QtWidgets.QFileDialog.Accepted + ) + + qtbot.wait(100) + for block in ["null sou", "null sin"]: + add_block_from_query(qtbot, qapp_cls_, block) + + n_src = find_blocks(fg, "blocks_null_source") + n_sink = find_blocks(fg, "blocks_null_sink") + + assert len(fg.connections) == 0 + + start = scaling * global_pos(n_sink.gui, view) + pag.moveTo(start.x(), start.y()) + pag.mouseDown() + + def drag(): + for i in range(20): + pag.move(scaling * 10, 0) + + drag_t = threading.Thread(target=drag) + drag_t.start() + while drag_t.is_alive(): + qtbot.wait(50) + pag.mouseUp() + + click_on(qtbot, qapp_cls_, n_src.sources[0]) + click_on(qtbot, qapp_cls_, n_sink.sinks[0]) + assert not fg_path.exists(), "File/Save (setup): .grc file already exists" + assert not py_path.exists(), "File/Save (setup): Python file already exists" + menu_shortcut(qtbot, qapp_cls_, "build", QtCore.Qt.Key_B, QtCore.Qt.Key_G) + qtbot.wait(500) + assert fg_path.exists(), "File/Save: Could not save .grc file" + assert py_path.exists(), "File/Save: Could not save Python file" + + +def test_file_close_all(qtbot, qapp_cls_, monkeypatch): + win = qapp_cls_.MainWindow + monkeypatch.setattr( + QtWidgets.QMessageBox, + "question", + lambda *args: QtWidgets.QMessageBox.Discard, + ) + + qtbot.wait(100) + + for i in range(1, 4): + ctrl_keystroke(qtbot, qapp_cls_, QtCore.Qt.Key_N) + + assert win.tabWidget.count() == 4, "File/Close All" + menu_shortcut(qtbot, qapp_cls_, "file", QtCore.Qt.Key_F, QtCore.Qt.Key_L) + assert win.tabWidget.count() == 1, "File/Close All" + + +def test_quit(qtbot, qapp_cls_, monkeypatch): + monkeypatch.setattr( + QtWidgets.QMessageBox, + "question", + lambda *args: QtWidgets.QMessageBox.Discard, + ) + qapp_cls_.MainWindow.actions["exit"].trigger() + assert True + time.sleep(1) diff --git a/grc/tests/test_xml_parser.py b/grc/tests/test_xml_parser.py new file mode 100644 index 0000000..28ec770 --- /dev/null +++ b/grc/tests/test_xml_parser.py @@ -0,0 +1,29 @@ +# Copyright 2017 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +from os import path +import sys + +from grc.converter import flow_graph + + +def test_flow_graph_converter(): + filename = path.join(path.dirname(__file__), + 'resources', 'test_compiler.grc') + + data = flow_graph.from_xml(filename) + + flow_graph.dump(data, sys.stdout) + + +def test_flow_graph_converter_with_fp(): + filename = path.join(path.dirname(__file__), + 'resources', 'test_compiler.grc') + + with open(filename, 'rb') as fp: + data = flow_graph.from_xml(fp) + + flow_graph.dump(data, sys.stdout) diff --git a/grc/tests/test_yaml_checker.py b/grc/tests/test_yaml_checker.py new file mode 100644 index 0000000..87f8e5f --- /dev/null +++ b/grc/tests/test_yaml_checker.py @@ -0,0 +1,74 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-2.0-or-later +# + +import yaml + +from grc.core.schema_checker import Validator, BLOCK_SCHEME + + +BLOCK1 = """ +id: block_key +label: testname + +parameters: +- id: vlen + label: Vec Length + dtype: int + default: 1 +- id: out_type + label: Vec Length + dtype: string + default: complex +- id: a + label: Alpha + dtype: ${ out_type } + default: '0' + +inputs: +- label: in + domain: stream + dtype: complex + vlen: ${ 2 * vlen } +- name: in2 + domain: message + id: in2 + +outputs: +- label: out + domain: stream + dtype: ${ out_type } + vlen: ${ vlen } + +templates: + make: blocks.complex_to_mag_squared(${ vlen }) + +file_format: 1 +""" + + +def test_min(): + checker = Validator(BLOCK_SCHEME) + assert checker.run({'id': 'test', 'file_format': 1}), checker.messages + assert not checker.run({'name': 'test', 'file_format': 1}) + + +def test_extra_keys(): + checker = Validator(BLOCK_SCHEME) + assert checker.run({'id': 'test', 'abcdefg': 'nonsense', 'file_format': 1}) + assert checker.messages == [ + ('block', 'warn', "Ignoring extra key 'abcdefg'")] + + +def test_checker(): + checker = Validator(BLOCK_SCHEME) + data = yaml.safe_load(BLOCK1) + passed = checker.run(data) + if not passed: + print() + for msg in checker.messages: + print(msg) + + assert passed, checker.messages