diff --git a/CMakeLists.txt b/CMakeLists.txt index a634d8c..43d433c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,9 +95,11 @@ if(ENABLE_GRC) # 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 dir with prefix /usr/local + # 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) @@ -154,6 +156,12 @@ GR_PYTHON_INSTALL( 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" diff --git a/core/Config.py b/core/Config.py index 18f94e5..1d823d7 100644 --- a/core/Config.py +++ b/core/Config.py @@ -50,6 +50,10 @@ class Config(object): 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 = ( diff --git a/core/Constants.py b/core/Constants.py index 13384df..1aac44d 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -23,6 +23,7 @@ DEFAULT_HIER_BLOCK_LIB_DIR = os.path.expanduser('~/.grc_gnuradio') DEFAULT_FLOW_GRAPH_ID = 'default' CACHE_FILE = os.path.expanduser('~/.cache/grc_gnuradio/cache_v2.json') +EXAMPLE_CACHE_FILE = os.path.expanduser('~/.cache/grc_gnuradio/example_cache.json') BLOCK_DESCRIPTION_FILE_FORMAT_VERSION = 1 # File format versions: diff --git a/core/blocks/block.py b/core/blocks/block.py index f4f2acc..bc509e2 100644 --- a/core/blocks/block.py +++ b/core/blocks/block.py @@ -612,6 +612,13 @@ class Block(Element): 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 ############################################## diff --git a/core/cache.py b/core/cache.py index 59daa4b..eec6375 100644 --- a/core/cache.py +++ b/core/cache.py @@ -16,9 +16,10 @@ logger = logging.getLogger(__name__) class Cache(object): - def __init__(self, filename, version=None): + 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 @@ -35,17 +36,21 @@ class Cache(object): def load(self): try: self.need_cache_write = False - logger.debug(f"Loading block cache from: {self.cache_file}") + 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) - logger.debug(f"Cache version {cacheversion}") + if self.log: + logger.debug(f"Cache version {cacheversion}") self._cachetime = cache.get("cached-at", 0) if cacheversion == self.version: - logger.debug("Loaded block cache") + if self.log: + logger.debug("Loaded cache") self.cache = cache["cache"] else: - logger.info(f"Outdated cache {self.cache_file} found, " + if self.log: + logger.info(f"Outdated cache {self.cache_file} found, " "will be overwritten.") raise ValueError() except (IOError, ValueError): @@ -59,7 +64,8 @@ class Cache(object): cached = self.cache[filename] if int(cached["cached-at"] + 0.5) >= modtime: return cached["data"] - logger.info(f"Cache for {filename} outdated, loading yaml") + if self.log: + logger.info(f"Cache for {filename} outdated, loading yaml") except KeyError: pass @@ -76,7 +82,8 @@ class Cache(object): if not self.need_cache_write: return - logger.debug('Saving %d entries to json cache', len(self.cache)) + 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 = { diff --git a/core/default_flow_graph.grc b/core/default_flow_graph.grc index 02af6b8..7654db6 100644 --- a/core/default_flow_graph.grc +++ b/core/default_flow_graph.grc @@ -22,7 +22,7 @@ blocks: value: '32000' states: coordinate: - - 184 + - 200 - 12 rotation: 0 state: enabled diff --git a/core/platform.py b/core/platform.py index fa8922a..1d88e25 100644 --- a/core/platform.py +++ b/core/platform.py @@ -48,6 +48,7 @@ class Platform(Element): self.blocks = self.block_classes self.domains = {} + self.examples_dict = {} self.connection_templates = {} self.cpp_connection_templates = {} self.connection_params = {} @@ -115,6 +116,9 @@ class Platform(Element): 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 diff --git a/grc.conf.in b/grc.conf.in index 1dbb13b..9e5bf42 100644 --- a/grc.conf.in +++ b/grc.conf.in @@ -5,6 +5,7 @@ [grc] global_blocks_path = @blocksdir@ local_blocks_path = +examples_path = @examplesdir@ default_flow_graph = xterm_executable = @GRC_XTERM_EXE@ canvas_font_size = 8 diff --git a/gui_qt/Config.py b/gui_qt/Config.py new file mode 100644 index 0000000..ba27444 --- /dev/null +++ b/gui_qt/Config.py @@ -0,0 +1,19 @@ +import os + +from ..core.Config import Config as CoreConfig + + +class Config(CoreConfig): + + name = 'GNU Radio Companion' + + gui_prefs_file = os.environ.get( + 'GRC_QT_PREFS_PATH', os.path.expanduser('~/.gnuradio/grc_qt.conf')) + + def __init__(self, install_prefix, *args, **kwargs): + CoreConfig.__init__(self, *args, **kwargs) + self.install_prefix = install_prefix + + @property + def wiki_block_docs_url_prefix(self): + return self._gr_prefs.get_string('grc-docs', 'wiki_block_docs_url_prefix', '') \ No newline at end of file diff --git a/gui_qt/Constants.py b/gui_qt/Constants.py new file mode 100644 index 0000000..4d3ebe1 --- /dev/null +++ b/gui_qt/Constants.py @@ -0,0 +1,121 @@ +#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 os +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 = [('STRING', Gtk.TargetFlags.SAME_APP, 0)] +''' + +# 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 +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/gui_qt/Platform.py b/gui_qt/Platform.py new file mode 100644 index 0000000..49fb979 --- /dev/null +++ b/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/gui_qt/Utils.py b/gui_qt/Utils.py new file mode 100644 index 0000000..2b83a60 --- /dev/null +++ b/gui_qt/Utils.py @@ -0,0 +1,110 @@ +import numbers +import os +import logging +from pathlib import Path + +from qtpy import QtGui, QtCore + +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) + if file_path.suffix == ".png": + rect = fg_view.viewport().rect() + + pixmap = QtGui.QPixmap(rect.size()) + painter = QtGui.QPainter(pixmap) + + fg_view.render(painter, QtCore.QRectF(pixmap.rect()), 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 + rect = fg_view.viewport().rect() + + generator = QtSvg.QSvgGenerator() + generator.setFileName(str(file_path)) + generator.setSize(rect.size()) + painter = QtGui.QPainter(generator) + fg_view.render(painter) + painter.end() + elif file_path.suffix == ".pdf": + log.warning("PDF screen capture not implemented") + return # TODO diff --git a/gui_qt/__init__.py b/gui_qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui_qt/base.py b/gui_qt/base.py new file mode 100644 index 0000000..3b0e678 --- /dev/null +++ b/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/gui_qt/components/__init__.py b/gui_qt/components/__init__.py new file mode 100644 index 0000000..611751b --- /dev/null +++ b/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/gui_qt/components/block_library.py b/gui_qt/components/block_library.py new file mode 100644 index 0000000..3b7c191 --- /dev/null +++ b/gui_qt/components/block_library.py @@ -0,0 +1,322 @@ +# 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) +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) + self.parent = parent + self.setObjectName("block_library::search_bar") + self.returnPressed.connect(self.on_return_pressed) + + def on_return_pressed(self): + label = self.text() + if label in self.parent._block_tree_flat: + block_key = self.parent._block_tree_flat[label].key + self.parent.add_block(block_key) + self.setText("") + self.parent.populate_tree(self.parent._block_tree) + else: + log.info(f"No block named {label}") + + +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 handle_clicked(self): + if self.isExpanded(self.currentIndex()): + self.collapse(self.currentIndex()) + else: + self.expand(self.currentIndex()) + + 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.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 + library.clicked.connect(library.handle_clicked) + 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.InlineCompletion) + completer.setCaseSensitivity(Qt.CaseInsensitive) + completer.setFilterMode(Qt.MatchContains) + self._search_bar.setCompleter(completer) + + self._search_bar.textChanged.connect( + lambda x: self.populate_tree( + self._block_tree, get_items(completer.completionModel()) + ) + ) + + # 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 = {} + + def createActions(self, actions): + pass + + def createMenus(self, actions, menus): + pass + + def createToolbars(self, actions, toolbars): + pass + + 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 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()) + self._library.expand( + self._model.item(0, 0).index() + ) # TODO: Should be togglable in prefs + 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/gui_qt/components/canvas/__init__.py b/gui_qt/components/canvas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui_qt/components/canvas/block.py b/gui_qt/components/canvas/block.py new file mode 100755 index 0000000..5fb08de --- /dev/null +++ b/gui_qt/components/canvas/block.py @@ -0,0 +1,420 @@ +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) + 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 + self.setRotation(new_rotation) + self.core.states["rotation"] = new_rotation + self.create_shapes_and_labels() + for con in self.core.connections(): + 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/gui_qt/components/canvas/colors.py b/gui_qt/components/canvas/colors.py new file mode 100755 index 0000000..a5bb34c --- /dev/null +++ b/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/gui_qt/components/canvas/connection.py b/gui_qt/components/canvas/connection.py new file mode 100755 index 0000000..cb47275 --- /dev/null +++ b/gui_qt/components/canvas/connection.py @@ -0,0 +1,146 @@ +from qtpy.QtGui import QPainterPath, QPainter, QPen +from qtpy.QtWidgets import QGraphicsPathItem +from qtpy.QtCore import QPointF + +from ....core.Connection import Connection as CoreConnection +from . import colors +from ...Constants import ( + CONNECTOR_ARROW_BASE, + CONNECTOR_ARROW_HEIGHT +) + + +class DummyConnection(QGraphicsPathItem): + """ + 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__() + + self.start_point = start_point + self.end_point = end_point + + self._line = QPainterPath() + self._arrowhead = QPainterPath() + self._path = QPainterPath() + self.update(end_point) + + 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._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) + c1 = self.start_point + QPointF(200, 0) + c2 = self.end_point - QPointF(200, 0) + self._line.cubicTo(c1, c2, 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.setWidth(2) + 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): + def __init__(self, core, parent, source, sink): + self.core = core + super(GUIConnection, self).__init__() + + self.source = source + self.sink = sink + + self._line = QPainterPath() + self._arrowhead = QPainterPath() + self._path = QPainterPath() + self.update() + + 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._current_cr = None # for what_is_selected() of curved line + self._line_path = None + self.setFlag(QGraphicsPathItem.ItemIsSelectable) + + 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) + c1 = self.source.gui.ctrl_point + c2 = self.sink.gui.ctrl_point + self._line.cubicTo(c1, c2, self.sink.gui.connection_point) + + self._arrowhead.clear() + self._arrowhead.moveTo(self.sink.gui.connection_point + QPointF(10.0, 0)) + self._arrowhead.lineTo(self.sink.gui.connection_point + QPointF(10.0, 0) + QPointF(-CONNECTOR_ARROW_HEIGHT, - CONNECTOR_ARROW_BASE / 2)) + self._arrowhead.lineTo(self.sink.gui.connection_point + QPointF(10.0, 0) + QPointF(-CONNECTOR_ARROW_HEIGHT, CONNECTOR_ARROW_BASE / 2)) + self._arrowhead.lineTo(self.sink.gui.connection_point + QPointF(10.0, 0)) + + 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.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.setWidth(2) + painter.setPen(pen) + painter.drawPath(self._line) + painter.setBrush(color) + painter.drawPath(self._arrowhead) + + def mouseDoubleClickEvent(self, e): + self.parent.connections.remove(self) + self.scene().removeItem(self) diff --git a/gui_qt/components/canvas/flowgraph.py b/gui_qt/components/canvas/flowgraph.py new file mode 100644 index 0000000..64c6e28 --- /dev/null +++ b/gui_qt/components/canvas/flowgraph.py @@ -0,0 +1,531 @@ +# 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._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) + + 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()) + self.clickPos = event.scenePos() + selected = self.selectedItems() + 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: + new_con = None + if len(selected) == 1: + if selected[0].core.is_port and selected[0] != g_item: + if selected[0].core.is_source and c_item.is_sink: + new_con = self.core.connect(selected[0].core, c_item) + elif selected[0].is_sink and c_item.is_source: + new_con = self.core.connect(c_item, selected[0].core) + if new_con: + log.debug("Created connection (click)") + self.addItem(new_con.gui) + self.newElement.emit(new_con) + self.update() + else: + self.start_port = g_item + if c_item.is_source: + self.dummy_arrow = DummyConnection(self, g_item.connection_point, event.scenePos()) + self.addItem(self.dummy_arrow) + if event.button() == Qt.LeftButton: + self.mousePressed = True + super(FlowgraphScene, self).mousePressEvent(event) + + def mouseMoveEvent(self, event): + 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 + 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.removeItem(self.dummy_arrow) + self.dummy_arrow = None + else: + if self.clickPos != event.scenePos() and self.moving_blocks: + self.itemMoved.emit(event.scenePos() - self.clickPos) + 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/gui_qt/components/canvas/port.py b/gui_qt/components/canvas/port.py new file mode 100644 index 0000000..d3adf5b --- /dev/null +++ b/gui_qt/components/canvas/port.py @@ -0,0 +1,147 @@ +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) + + 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.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) + 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)) + + 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) + + if self._show_label: + painter.setPen(QPen(1)) + painter.setFont(self.font) + if self.core._dir == "sink": + 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/gui_qt/components/console.py b/gui_qt/components/console.py new file mode 100644 index 0000000..4592824 --- /dev/null +++ b/gui_qt/components/console.py @@ -0,0 +1,270 @@ +# 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.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 + + 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/gui_qt/components/dialogs.py b/gui_qt/components/dialogs.py new file mode 100644 index 0000000..47b78de --- /dev/null +++ b/gui_qt/components/dialogs.py @@ -0,0 +1,196 @@ +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) + + +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.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 = QStandardItemModel() + 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)]) + self.treeview.setModel(self.model) + + +class PropsDialog(QDialog): + def __init__(self, parent_block, force_show_id): + super().__init__() + self.setMinimumSize(600, 400) + 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() + ignore_dtype_labels = ["_multiline", "_multiline_python_external"] + + for cat in unique_categories(): + qvb = QGridLayout() + qvb.setAlignment(Qt.AlignTop) + qvb.setVerticalSpacing(5) + qvb.setHorizontalSpacing(20) + i = 0 + for param in self._block.params.values(): + if 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 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()) + ) + else: + dropdown.setEditable(True) + value_label = ( + param.options[param.value] + if param.value in param.options + else param.value + ) + dropdown.setCurrentText(value_label) + 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) + #self.edit_params.append(line_edit) + else: + line_edit = QLineEdit(param.value) + 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 + tab = QWidget() + tab.setLayout(qvb) + self.tabs.addTab(tab, cat) + + # 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) + self.layout.addWidget(self.buttonBox) + + self.setLayout(self.layout) + + def accept(self): + super().accept() + self._block.old_data = self._block.export_data() + for par in self.edit_params: + if isinstance(par, QLineEdit): + par.param.set_value(par.text()) + 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 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/gui_qt/components/example_browser.py b/gui_qt/components/example_browser.py new file mode 100644 index 0000000..94a098d --- /dev/null +++ b/gui_qt/components/example_browser.py @@ -0,0 +1,311 @@ +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 = {} + with Cache(Constants.EXAMPLE_CACHE_FILE, log=False) as cache: + for entry in self.platform.config.example_paths: + if entry == '': + log.error("Empty example path!") + break + 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/gui_qt/components/executor.py b/gui_qt/components/executor.py new file mode 100644 index 0000000..6a1ae4d --- /dev/null +++ b/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] + + # 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/gui_qt/components/flowgraph_view.py b/gui_qt/components/flowgraph_view.py new file mode 100644 index 0000000..b7d930b --- /dev/null +++ b/gui_qt/components/flowgraph_view.py @@ -0,0 +1,188 @@ +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) + + def mouseMoveEvent(self, event): + if self.mousePressed and 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 + 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/gui_qt/components/oot_browser.py b/gui_qt/components/oot_browser.py new file mode 100644 index 0000000..c2b1757 --- /dev/null +++ b/gui_qt/components/oot_browser.py @@ -0,0 +1,108 @@ +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") + + 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/gui_qt/components/preferences.py b/gui_qt/components/preferences.py new file mode 100644 index 0000000..b2382cd --- /dev/null +++ b/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/gui_qt/components/undoable_actions.py b/gui_qt/components/undoable_actions.py new file mode 100644 index 0000000..e0f83f0 --- /dev/null +++ b/gui_qt/components/undoable_actions.py @@ -0,0 +1,233 @@ +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/gui_qt/components/variable_editor.py b/gui_qt/components/variable_editor.py new file mode 100644 index 0000000..7e85f35 --- /dev/null +++ b/gui_qt/components/variable_editor.py @@ -0,0 +1,224 @@ +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 + +# 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): + new_block = Signal([str]) + delete_block = Signal([str]) + + def __init__(self): + super(VariableEditor, self).__init__() + + 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) + + imports = QTreeWidgetItem(self._tree) + imports.setText(0, "Imports") + imports.setIcon(2, QtGui.QIcon.fromTheme("list-add")) + variables = QTreeWidgetItem(self._tree) + variables.setText(0, "Variables") + 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 + + ### 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 set_scene(self, scene: FlowgraphScene): + self.scene = scene + self.update_gui(self.scene.core.blocks) + + def handle_change(self, tl, br): # TODO: Why are there two arguments? + if self.currently_rebuilding: + return + + c_block = self._tree.model().data(tl, role=Qt.UserRole) + 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['import'].set_value(new_text) + else: + c_block.params['value'].set_value(new_text) + self.scene.blockPropsChange.emit(c_block) + + def _rebuild(self): + # TODO: The way we update block params here seems suboptimal + self.currently_rebuilding = True + self._tree.clear() + imports = QTreeWidgetItem(self._tree) + imports.setText(0, "Imports") + imports.setIcon(2, QtGui.QIcon.fromTheme("list-add")) + for block in self._imports: + import_ = QTreeWidgetItem(imports) + 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")) + if block.enabled: + import_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled) + else: + import_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable) + variables = QTreeWidgetItem(self._tree) + variables.setText(0, "Variables") + variables.setIcon(2, QtGui.QIcon.fromTheme("list-add")) + for block in sorted(self._variables, key=lambda v: v.name): + variable_ = QTreeWidgetItem(variables) + variable_.setText(0, block.name) + variable_.setData(0, Qt.UserRole, block) + if block.key == 'variable': + variable_.setText(1, block.params['value'].get_value()) + variable_.setData(1, Qt.UserRole, block) + if block.enabled: + variable_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled) + else: + variable_.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable) + else: + variable_.setText(1, '') + variable_.setFlags(Qt.ItemIsSelectable) + 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.new_block.emit("import") + elif action == VariableEditorAction.ADD_VARIABLE: + self.new_block.emit("variable") + elif action == VariableEditorAction.OPEN_PROPERTIES: + # TODO: Disabled in GRC Gtk. Enable? + pass + elif action == VariableEditorAction.DELETE_BLOCK: + self.delete_block.emit(self._block.name) + elif action == VariableEditorAction.DELETE_CONFIRM: + pass # TODO: Handle this + elif action == VariableEditorAction.ENABLE_BLOCK: + self._block.state = 'enabled' + elif action == VariableEditorAction.DISABLE_BLOCK: + self._block.state = 'disabled' + #Actions.VARIABLE_EDITOR_UPDATE() # TODO: Fix this + + +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/gui_qt/components/wiki_tab.py b/gui_qt/components/wiki_tab.py new file mode 100644 index 0000000..6ba6ace --- /dev/null +++ b/gui_qt/components/wiki_tab.py @@ -0,0 +1,115 @@ +# 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, argv_enabled=False): + super(WikiTab, self).__init__() + + self.qsettings = self.app.qsettings + + self.setObjectName('wiki_tab') + self.setWindowTitle('Wiki') + + self.setFloating(False) + + active = None + if argv_enabled: + active = True + else: + if self.qsettings.value("appearance/display_wiki", False, type=bool) == True: + active = True + else: + active = False + + if active: + try: + from qtpy.QtWebEngineWidgets import QWebEngineView + self.hidden = False + except ImportError: + log.error("PyQt QWebEngine missing!") + self.hide() + self.hidden = True + return + else: + self.hide() + self.hidden = True + 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) + + def setURL(self, url): + if not self.hidden: + 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/gui_qt/components/window.py b/gui_qt/components/window.py new file mode 100644 index 0000000..eb71d9c --- /dev/null +++ b/gui_qt/components/window.py @@ -0,0 +1,1519 @@ +# 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, 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 .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): + 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) + + 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__") + QtGui.QIcon.setThemeName("Papirus-Dark") + + # 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.setCentralWidget(self.tabWidget) + + files_open = list(self.app.qsettings.value('window/files_open', [])) + if files_open: + for file in files_open: + self.open_triggered(file) + else: + self.new_triggered() + + self.clipboard = None + self.undoView = None + + 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() + """ + + def update_variable_editor(self, var_edit): + var_edit.new_block.connect(self.var_edit_new_block) + + @QtCore.Slot(str) + def var_edit_new_block(self, block_key): + self.currentFlowgraphScene.add_block(block_key) + + @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["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(False) + actions["open_hier"].setEnabled(False) + 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""" + self.update_variable_editor(self.app.VariableEditor) + + blocks = self.currentFlowgraphScene.selected_blocks() + conns = self.currentFlowgraphScene.selected_connections() + undoStack = self.currentFlowgraphScene.undoStack + canUndo = undoStack.canUndo() + canRedo = undoStack.canRedo() + 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["create_hier"].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) + self.actions["create_hier"].setEnabled( + True + ) # TODO: Other requirements for enabling this? + + 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") + menus["open_recent"] = open_recent + recent_files = None + if recent_files: + pass + else: + open_recent.setEnabled(False) + # open_recent.addAction(actions["open"]) + # TODO: populate recent files + + # 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): + Open = QtWidgets.QFileDialog.getOpenFileName + filename, filtr = Open( + self, + self.actions["open"].statusTip(), + 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) + 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.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.red) + 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) + + def open_triggered(self, filename=None, save_allowed=True): + log.debug("open") + if not filename: + filename = self.open() + + if 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 + + 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) + except IOError: + log.error("Save failed") + return + + log.info(f"Saved {filename}") + self.tabWidget.tabBar().setTabTextColor(self.tabWidget.currentIndex(), Qt.white) # TODO: Not quite the right hue + 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") + filename, filtr = QtWidgets.QFileDialog.getSaveFileName( + self, + self.actions["save"].statusTip(), + filter="Flow Graph Files (*.grc);;All files (*.*)", + ) + 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(), Qt.white) # TODO: Not quite the right hue + self.currentFlowgraphScene.set_saved(True) + self.tabWidget.setTabText(self.tabWidget.currentIndex(), os.path.basename(filename)) + else: + log.debug("Cancelled Save As action") + self.updateActions() + + def save_copy_triggered(self): + log.debug("Save Copy") + filename, filtr = QtWidgets.QFileDialog.getSaveFileName( + self, + self.actions["save"].statusTip(), + filter="Flow Graph Files (*.grc);;All files (*.*)", + ) + 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 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 + + Save = QtWidgets.QFileDialog.getSaveFileName + file_path, filtr = Save( + self, + self.actions["save"].statusTip(), + 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.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.currentFlowgraphScene.delete_selected() + 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.setFocus() + + 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") + 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") + 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 + 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 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 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 + + # 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() + + 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') + diff --git a/gui_qt/external_editor.py b/gui_qt/external_editor.py new file mode 100644 index 0000000..255779b --- /dev/null +++ b/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() \ No newline at end of file diff --git a/gui_qt/grc.py b/gui_qt/grc.py new file mode 100644 index 0000000..506a4e5 --- /dev/null +++ b/gui_qt/grc.py @@ -0,0 +1,165 @@ +# 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, 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): + # 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() + 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("--wiki" in settings.argv) + stopwatch.lap("wikitab") + self.VariableEditor = components.VariableEditor() + stopwatch.lap("variable_editor") + self.VariableEditor.set_scene(self.MainWindow.currentFlowgraphScene) + 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" + ) + 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/gui_qt/helpers/__init__.py b/gui_qt/helpers/__init__.py new file mode 100644 index 0000000..b0dc219 --- /dev/null +++ b/gui_qt/helpers/__init__.py @@ -0,0 +1,2 @@ +from . import logging +from . import qt diff --git a/gui_qt/helpers/logging.py b/gui_qt/helpers/logging.py new file mode 100644 index 0000000..7958892 --- /dev/null +++ b/gui_qt/helpers/logging.py @@ -0,0 +1,131 @@ +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/gui_qt/helpers/profiling.py b/gui_qt/helpers/profiling.py new file mode 100644 index 0000000..5509c7a --- /dev/null +++ b/gui_qt/helpers/profiling.py @@ -0,0 +1,26 @@ +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/gui_qt/helpers/qt.py b/gui_qt/helpers/qt.py new file mode 100644 index 0000000..e69de29 diff --git a/gui_qt/properties.py b/gui_qt/properties.py new file mode 100644 index 0000000..296beec --- /dev/null +++ b/gui_qt/properties.py @@ -0,0 +1,263 @@ +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 + ''' + 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/gui_qt/resources/available_preferences.yml b/gui_qt/resources/available_preferences.yml new file mode 100644 index 0000000..6aa5810 --- /dev/null +++ b/gui_qt/resources/available_preferences.yml @@ -0,0 +1,178 @@ +# 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: 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: 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: default_qt_gui_theme + name: Default Qt GUI theme + dtype: str + default: "" + - key: display_wiki + name: Display Wiki tab + dtype: bool + default: False + +# 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/gui_qt/resources/cpp_cmd_fg.png b/gui_qt/resources/cpp_cmd_fg.png new file mode 100644 index 0000000..c9756f8 Binary files /dev/null and b/gui_qt/resources/cpp_cmd_fg.png differ diff --git a/gui_qt/resources/cpp_fg.png b/gui_qt/resources/cpp_fg.png new file mode 100644 index 0000000..0edd96c Binary files /dev/null and b/gui_qt/resources/cpp_fg.png differ diff --git a/gui_qt/resources/cpp_qt_fg.png b/gui_qt/resources/cpp_qt_fg.png new file mode 100644 index 0000000..ff7d026 Binary files /dev/null and b/gui_qt/resources/cpp_qt_fg.png differ diff --git a/gui_qt/resources/data/rx_logo.grc b/gui_qt/resources/data/rx_logo.grc new file mode 100755 index 0000000..e420323 --- /dev/null +++ b/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/gui_qt/resources/example_browser.ui b/gui_qt/resources/example_browser.ui new file mode 100644 index 0000000..99d7f97 --- /dev/null +++ b/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/gui_qt/resources/example_browser_widget.ui b/gui_qt/resources/example_browser_widget.ui new file mode 100644 index 0000000..45b2cad --- /dev/null +++ b/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/gui_qt/resources/language/add-translation.txt b/gui_qt/resources/language/add-translation.txt new file mode 100644 index 0000000..b27a523 --- /dev/null +++ b/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/gui_qt/resources/language/en_US/LC_MESSAGES/grc.mo b/gui_qt/resources/language/en_US/LC_MESSAGES/grc.mo new file mode 100644 index 0000000..7a1f07e Binary files /dev/null and b/gui_qt/resources/language/en_US/LC_MESSAGES/grc.mo differ diff --git a/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po b/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po new file mode 100644 index 0000000..12fb5f0 --- /dev/null +++ b/gui_qt/resources/language/en_US/LC_MESSAGES/grc.po @@ -0,0 +1,416 @@ +# 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: 2023-11-26 20:00+0300\n" +"PO-Revision-Date: 2023-11-26 20:03+0300\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.1\n" + +#: grc/gui_qt/components/window.py:76 +msgid "window-title" +msgstr "GNU Radio Companion" + +#: grc/gui_qt/components/window.py:244 +msgid "new" +msgstr "&New" + +#: grc/gui_qt/components/window.py:247 +msgid "new-tooltip" +msgstr "Create a new flow graph" + +#: grc/gui_qt/components/window.py:252 +msgid "open" +msgstr "&Open..." + +#: grc/gui_qt/components/window.py:255 +msgid "open-tooltip" +msgstr "Open an existing flow graph" + +#: grc/gui_qt/components/window.py:259 +msgid "example_browser" +msgstr "&Examples..." + +#: grc/gui_qt/components/window.py:261 +msgid "example_browser-tooltip" +msgstr "Show example browser" + +#: grc/gui_qt/components/window.py:266 +msgid "close" +msgstr "&Close" + +#: grc/gui_qt/components/window.py:269 +msgid "close-tooltip" +msgstr "Close the current flow graph" + +#: grc/gui_qt/components/window.py:274 +msgid "close_all" +msgstr "C&lose All" + +#: grc/gui_qt/components/window.py:276 +msgid "close_all-tooltip" +msgstr "Close all open flow graphs" + +#: grc/gui_qt/components/window.py:280 +msgid "save" +msgstr "&Save" + +#: grc/gui_qt/components/window.py:283 +msgid "save-tooltip" +msgstr "Save the current flow graph" + +#: grc/gui_qt/components/window.py:288 +msgid "save_as" +msgstr "Save &As..." + +#: grc/gui_qt/components/window.py:291 +msgid "save_as-tooltip" +msgstr "Save the current flow graph under a new name" + +#: grc/gui_qt/components/window.py:294 +msgid "save_copy" +msgstr "Save Cop&y" + +#: grc/gui_qt/components/window.py:298 +msgid "screen_capture" +msgstr "Screen ca&pture" + +#: grc/gui_qt/components/window.py:301 +msgid "screen_capture-tooltip" +msgstr "Create a screen capture of the current flow graph" + +#: grc/gui_qt/components/window.py:306 +msgid "exit" +msgstr "E&xit" + +#: grc/gui_qt/components/window.py:309 +msgid "exit-tooltip" +msgstr "Exit GNU Radio Companion" + +#: grc/gui_qt/components/window.py:315 +msgid "undo" +msgstr "Undo" + +#: grc/gui_qt/components/window.py:318 +msgid "undo-tooltip" +msgstr "Undo the last change" + +#: grc/gui_qt/components/window.py:323 +msgid "redo" +msgstr "Redo" + +#: grc/gui_qt/components/window.py:326 +msgid "redo-tooltip" +msgstr "Redo the last change" + +#: grc/gui_qt/components/window.py:333 +msgid "cut" +msgstr "Cu&t" + +#: grc/gui_qt/components/window.py:336 +msgid "cut-tooltip" +msgstr "Cut the current selection's contents to the clipboard" + +#: grc/gui_qt/components/window.py:341 +msgid "copy" +msgstr "&Copy" + +#: grc/gui_qt/components/window.py:344 +msgid "copy-tooltip" +msgstr "Copy the current selection's contents to the clipboard" + +#: grc/gui_qt/components/window.py:349 +msgid "paste" +msgstr "&Paste" + +#: grc/gui_qt/components/window.py:352 +msgid "paste-tooltip" +msgstr "Paste the clipboard's contents into the current selection" + +#: grc/gui_qt/components/window.py:357 +msgid "delete" +msgstr "&Delete" + +#: grc/gui_qt/components/window.py:360 +msgid "delete-tooltip" +msgstr "Delete the selected blocks" + +#: grc/gui_qt/components/window.py:372 +msgid "select_all" +msgstr "Select all" + +#: grc/gui_qt/components/window.py:375 +msgid "select_all-tooltip" +msgstr "Select all items in the canvas" + +#: grc/gui_qt/components/window.py:379 +msgid "Select None" +msgstr "" + +#: grc/gui_qt/components/window.py:379 +#, fuzzy +#| msgid "select_all-tooltip" +msgid "select_none-tooltip" +msgstr "Select all items in the canvas" + +#: grc/gui_qt/components/window.py:384 +msgid "rotate_ccw" +msgstr "Rotate counterclockwise" + +#: grc/gui_qt/components/window.py:387 +msgid "rotate_ccw-tooltip" +msgstr "Rotate the selected block(s) 90 degrees counterclockwise" + +#: grc/gui_qt/components/window.py:392 +msgid "rotate_cw" +msgstr "Rotate clockwise" + +#: grc/gui_qt/components/window.py:395 +msgid "rotate_cw-tooltip" +msgstr "Rotate the selected block(s) 90 degrees clockwise" + +#: grc/gui_qt/components/window.py:401 +msgid "enable" +msgstr "Enable" + +#: grc/gui_qt/components/window.py:402 +msgid "disable" +msgstr "Disable" + +#: grc/gui_qt/components/window.py:403 +msgid "bypass" +msgstr "Bypass" + +#: grc/gui_qt/components/window.py:409 +msgid "vertical_align_top" +msgstr "Vertical align top" + +#: grc/gui_qt/components/window.py:410 +msgid "vertical_align_middle" +msgstr "Vertical align middle" + +#: grc/gui_qt/components/window.py:411 +msgid "vertical_align_bottom" +msgstr "Vertical align bottom" + +#: grc/gui_qt/components/window.py:417 +msgid "horizontal_align_left" +msgstr "Horizontal align left" + +#: grc/gui_qt/components/window.py:418 +msgid "horizontal_align_center" +msgstr "Horizontal align center" + +#: grc/gui_qt/components/window.py:419 +msgid "horizontal_align_right" +msgstr "Horizontal align right" + +#: grc/gui_qt/components/window.py:425 +msgid "create_hier_block" +msgstr "Create hierarchical block" + +#: grc/gui_qt/components/window.py:426 +msgid "open_hier_block" +msgstr "Open hierarchical block" + +#: grc/gui_qt/components/window.py:427 +msgid "toggle_source_bus" +msgstr "Toggle source bus" + +#: grc/gui_qt/components/window.py:428 +msgid "toggle_sink_bus" +msgstr "Toggle sink bus" + +#: grc/gui_qt/components/window.py:437 +msgid "flowgraph-properties" +msgstr "Properties" + +#: grc/gui_qt/components/window.py:439 +msgid "flowgraph-properties-tooltip" +msgstr "Show the properties for a flow graph" + +#: grc/gui_qt/components/window.py:443 +msgid "search-in-examples" +msgstr "" + +#: grc/gui_qt/components/window.py:445 +msgid "search-in-examples-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:453 +msgid "Zoom &in" +msgstr "Zoom &in" + +#: grc/gui_qt/components/window.py:459 +msgid "Zoom &out" +msgstr "Zoom &out" + +#: grc/gui_qt/components/window.py:465 +msgid "O&riginal size" +msgstr "O&riginal size" + +#: grc/gui_qt/components/window.py:470 +msgid "snap_to_grid" +msgstr "Snap to grid" + +#: grc/gui_qt/components/window.py:474 +msgid "toggle_grid" +msgstr "Toggle grid" + +#: grc/gui_qt/components/window.py:474 +msgid "toggle_grid-tooltip" +msgstr "Toggle the canvas grid" + +#: grc/gui_qt/components/window.py:478 +msgid "errors" +msgstr "&Errors" + +#: grc/gui_qt/components/window.py:478 +msgid "errors-tooltip" +msgstr "View errors in the current flow graph" + +#: grc/gui_qt/components/window.py:483 +msgid "find" +msgstr "&Find Block" + +#: grc/gui_qt/components/window.py:486 +msgid "find-tooltip" +msgstr "Search for a block by name (and key)" + +#: grc/gui_qt/components/window.py:491 +msgid "about" +msgstr "&About" + +#: grc/gui_qt/components/window.py:491 grc/gui_qt/components/window.py:498 +msgid "about-tooltip" +msgstr "Show the about window" + +#: grc/gui_qt/components/window.py:496 +msgid "about-qt" +msgstr "About &Qt" + +#: grc/gui_qt/components/window.py:503 +msgid "process-generate" +msgstr "&Generate" + +#: grc/gui_qt/components/window.py:506 +msgid "process-generate-tooltip" +msgstr "Generate a python flow graph" + +#: grc/gui_qt/components/window.py:511 +msgid "process-execute" +msgstr "&Execute" + +#: grc/gui_qt/components/window.py:514 +msgid "process-execute-tooltip" +msgstr "Execute a python flow graph" + +#: grc/gui_qt/components/window.py:519 +msgid "process-kill" +msgstr "&Kill" + +#: grc/gui_qt/components/window.py:522 +msgid "process-kill-tooltip" +msgstr "Kill the current flow graph" + +#: grc/gui_qt/components/window.py:527 +msgid "help" +msgstr "&Help" + +#: grc/gui_qt/components/window.py:530 +msgid "help-tooltip" +msgstr "Show the help window" + +#: grc/gui_qt/components/window.py:535 +#, fuzzy +#| msgid "filter_design_tool" +msgid "&Filter Design Tool" +msgstr "Filter Design Tool" + +#: grc/gui_qt/components/window.py:538 +msgid "Set Default &Qt GUI Theme" +msgstr "" + +#: grc/gui_qt/components/window.py:541 +#, fuzzy +#| msgid "module_browser" +msgid "&OOT Module Browser" +msgstr "OOT Module Browser" + +#: grc/gui_qt/components/window.py:544 +msgid "show_flowgraph_complexity" +msgstr "Show Flowgraph Complexity" + +#: grc/gui_qt/components/window.py:550 +msgid "&Types" +msgstr "" + +#: grc/gui_qt/components/window.py:552 +msgid "&Keys" +msgstr "" + +#: grc/gui_qt/components/window.py:556 +msgid "&Get Involved" +msgstr "" + +#: grc/gui_qt/components/window.py:560 +msgid "preferences" +msgstr "Pre&ferences" + +#: grc/gui_qt/components/window.py:562 +msgid "preferences-tooltip" +msgstr "Show GRC preferences" + +#: grc/gui_qt/components/window.py:566 +msgid "reload" +msgstr "" + +#: grc/gui_qt/components/window.py:566 +#, fuzzy +#| msgid "redo-tooltip" +msgid "reload-tooltip" +msgstr "Redo the last change" + +#: grc/gui_qt/components/window.py:822 +msgid "ready-message" +msgstr "Ready" + +#~ msgid "zoom_in" +#~ msgstr "Zoom in" + +#~ msgid "zoom_out" +#~ msgstr "Zoom out" + +#~ msgid "zoom_reset" +#~ msgstr "Reset zoom level" + +#~ msgid "clear" +#~ msgstr "Clear" + +#~ msgid "clear-tooltip" +#~ msgstr "Clear the console" + +#~ msgid "show-level" +#~ msgstr "Show message 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/gui_qt/resources/language/grc.pot b/gui_qt/resources/language/grc.pot new file mode 100644 index 0000000..2de3b54 --- /dev/null +++ b/gui_qt/resources/language/grc.pot @@ -0,0 +1,378 @@ +# 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. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-11-26 20:00+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: grc/gui_qt/components/window.py:76 +msgid "window-title" +msgstr "" + +#: grc/gui_qt/components/window.py:244 +msgid "new" +msgstr "" + +#: grc/gui_qt/components/window.py:247 +msgid "new-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:252 +msgid "open" +msgstr "" + +#: grc/gui_qt/components/window.py:255 +msgid "open-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:259 +msgid "example_browser" +msgstr "" + +#: grc/gui_qt/components/window.py:261 +msgid "example_browser-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:266 +msgid "close" +msgstr "" + +#: grc/gui_qt/components/window.py:269 +msgid "close-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:274 +msgid "close_all" +msgstr "" + +#: grc/gui_qt/components/window.py:276 +msgid "close_all-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:280 +msgid "save" +msgstr "" + +#: grc/gui_qt/components/window.py:283 +msgid "save-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:288 +msgid "save_as" +msgstr "" + +#: grc/gui_qt/components/window.py:291 +msgid "save_as-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:294 +msgid "save_copy" +msgstr "" + +#: grc/gui_qt/components/window.py:298 +msgid "screen_capture" +msgstr "" + +#: grc/gui_qt/components/window.py:301 +msgid "screen_capture-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:306 +msgid "exit" +msgstr "" + +#: grc/gui_qt/components/window.py:309 +msgid "exit-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:315 +msgid "undo" +msgstr "" + +#: grc/gui_qt/components/window.py:318 +msgid "undo-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:323 +msgid "redo" +msgstr "" + +#: grc/gui_qt/components/window.py:326 +msgid "redo-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:333 +msgid "cut" +msgstr "" + +#: grc/gui_qt/components/window.py:336 +msgid "cut-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:341 +msgid "copy" +msgstr "" + +#: grc/gui_qt/components/window.py:344 +msgid "copy-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:349 +msgid "paste" +msgstr "" + +#: grc/gui_qt/components/window.py:352 +msgid "paste-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:357 +msgid "delete" +msgstr "" + +#: grc/gui_qt/components/window.py:360 +msgid "delete-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:372 +msgid "select_all" +msgstr "" + +#: grc/gui_qt/components/window.py:375 +msgid "select_all-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:379 +msgid "Select None" +msgstr "" + +#: grc/gui_qt/components/window.py:379 +msgid "select_none-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:384 +msgid "rotate_ccw" +msgstr "" + +#: grc/gui_qt/components/window.py:387 +msgid "rotate_ccw-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:392 +msgid "rotate_cw" +msgstr "" + +#: grc/gui_qt/components/window.py:395 +msgid "rotate_cw-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:401 +msgid "enable" +msgstr "" + +#: grc/gui_qt/components/window.py:402 +msgid "disable" +msgstr "" + +#: grc/gui_qt/components/window.py:403 +msgid "bypass" +msgstr "" + +#: grc/gui_qt/components/window.py:409 +msgid "vertical_align_top" +msgstr "" + +#: grc/gui_qt/components/window.py:410 +msgid "vertical_align_middle" +msgstr "" + +#: grc/gui_qt/components/window.py:411 +msgid "vertical_align_bottom" +msgstr "" + +#: grc/gui_qt/components/window.py:417 +msgid "horizontal_align_left" +msgstr "" + +#: grc/gui_qt/components/window.py:418 +msgid "horizontal_align_center" +msgstr "" + +#: grc/gui_qt/components/window.py:419 +msgid "horizontal_align_right" +msgstr "" + +#: grc/gui_qt/components/window.py:425 +msgid "create_hier_block" +msgstr "" + +#: grc/gui_qt/components/window.py:426 +msgid "open_hier_block" +msgstr "" + +#: grc/gui_qt/components/window.py:427 +msgid "toggle_source_bus" +msgstr "" + +#: grc/gui_qt/components/window.py:428 +msgid "toggle_sink_bus" +msgstr "" + +#: grc/gui_qt/components/window.py:437 +msgid "flowgraph-properties" +msgstr "" + +#: grc/gui_qt/components/window.py:439 +msgid "flowgraph-properties-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:443 +msgid "search-in-examples" +msgstr "" + +#: grc/gui_qt/components/window.py:445 +msgid "search-in-examples-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:453 +msgid "Zoom &in" +msgstr "" + +#: grc/gui_qt/components/window.py:459 +msgid "Zoom &out" +msgstr "" + +#: grc/gui_qt/components/window.py:465 +msgid "O&riginal size" +msgstr "" + +#: grc/gui_qt/components/window.py:470 +msgid "snap_to_grid" +msgstr "" + +#: grc/gui_qt/components/window.py:474 +msgid "toggle_grid" +msgstr "" + +#: grc/gui_qt/components/window.py:474 +msgid "toggle_grid-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:478 +msgid "errors" +msgstr "" + +#: grc/gui_qt/components/window.py:478 +msgid "errors-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:483 +msgid "find" +msgstr "" + +#: grc/gui_qt/components/window.py:486 +msgid "find-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:491 +msgid "about" +msgstr "" + +#: grc/gui_qt/components/window.py:491 grc/gui_qt/components/window.py:498 +msgid "about-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:496 +msgid "about-qt" +msgstr "" + +#: grc/gui_qt/components/window.py:503 +msgid "process-generate" +msgstr "" + +#: grc/gui_qt/components/window.py:506 +msgid "process-generate-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:511 +msgid "process-execute" +msgstr "" + +#: grc/gui_qt/components/window.py:514 +msgid "process-execute-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:519 +msgid "process-kill" +msgstr "" + +#: grc/gui_qt/components/window.py:522 +msgid "process-kill-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:527 +msgid "help" +msgstr "" + +#: grc/gui_qt/components/window.py:530 +msgid "help-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:535 +msgid "&Filter Design Tool" +msgstr "" + +#: grc/gui_qt/components/window.py:538 +msgid "Set Default &Qt GUI Theme" +msgstr "" + +#: grc/gui_qt/components/window.py:541 +msgid "&OOT Module Browser" +msgstr "" + +#: grc/gui_qt/components/window.py:544 +msgid "show_flowgraph_complexity" +msgstr "" + +#: grc/gui_qt/components/window.py:550 +msgid "&Types" +msgstr "" + +#: grc/gui_qt/components/window.py:552 +msgid "&Keys" +msgstr "" + +#: grc/gui_qt/components/window.py:556 +msgid "&Get Involved" +msgstr "" + +#: grc/gui_qt/components/window.py:560 +msgid "preferences" +msgstr "" + +#: grc/gui_qt/components/window.py:562 +msgid "preferences-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:566 +msgid "reload" +msgstr "" + +#: grc/gui_qt/components/window.py:566 +msgid "reload-tooltip" +msgstr "" + +#: grc/gui_qt/components/window.py:822 +msgid "ready-message" +msgstr "" diff --git a/gui_qt/resources/logo/gnuradio_logo_icon-square.png b/gui_qt/resources/logo/gnuradio_logo_icon-square.png new file mode 100644 index 0000000..b1a6841 Binary files /dev/null and b/gui_qt/resources/logo/gnuradio_logo_icon-square.png differ diff --git a/gui_qt/resources/manifests/gr-example_haakov.md b/gui_qt/resources/manifests/gr-example_haakov.md new file mode 100644 index 0000000..26de077 --- /dev/null +++ b/gui_qt/resources/manifests/gr-example_haakov.md @@ -0,0 +1,21 @@ +title: Example OOT Module +brief: GNU Radio components to do various things +tags: + - sdr +author: + - Håkon Vågsether +copyright_owner: + - Håkon Vågsether +license: GPLv3 +gr_supported_version: + - v3.9 + - v3.10 + +repo: https://github.com/haakov/gnuradio +website: https://www.example.org +#icon: # 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/gui_qt/resources/oot_browser.ui b/gui_qt/resources/oot_browser.ui new file mode 100644 index 0000000..3a831e6 --- /dev/null +++ b/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/gui_qt/resources/py_cmd_fg.png b/gui_qt/resources/py_cmd_fg.png new file mode 100644 index 0000000..6771aea Binary files /dev/null and b/gui_qt/resources/py_cmd_fg.png differ diff --git a/gui_qt/resources/py_fg.png b/gui_qt/resources/py_fg.png new file mode 100644 index 0000000..b23ce6e Binary files /dev/null and b/gui_qt/resources/py_fg.png differ diff --git a/gui_qt/resources/py_qt_fg.png b/gui_qt/resources/py_qt_fg.png new file mode 100644 index 0000000..13a8398 Binary files /dev/null and b/gui_qt/resources/py_qt_fg.png differ diff --git a/main.py b/main.py index 764e063..de674c2 100755 --- a/main.py +++ b/main.py @@ -1,23 +1,24 @@ -# Copyright 2009-2016 Free Software Foundation, Inc. +#!/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 -import gi -gi.require_version('Gtk', '3.0') -gi.require_version('PangoCairo', '1.0') -from gi.repository import Gtk - VERSION_AND_DISCLAIMER_TEMPLATE = """\ -GNU Radio Companion %s - -This program is part of GNU Radio +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. """ @@ -30,56 +31,57 @@ LOG_LEVELS = { 'critical': logging.CRITICAL, } - -def main(): +### Load GNU Radio +# Do this globally so it is available for both run_gtk() and run_qt() +try: from gnuradio import gr - parser = argparse.ArgumentParser( - description=VERSION_AND_DISCLAIMER_TEMPLATE % gr.version()) - parser.add_argument('flow_graphs', nargs='*') - parser.add_argument( - '--log', choices=['debug', 'info', 'warning', 'error', 'critical'], default='warning') - args = parser.parse_args() +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) - # Enable logging - # Note: All other modules need to use the same '.' convention. - # This should automatically be handled by logging.getLogger(__name__), but - # the prefix can change depending on how grc is launched (either by calling - # gnuradio-companion once installed or python -m grc). The prefix for main.py - # should be the same as other modules, so use that dynamically. - # Strip '.main' from the module name to get the prefix - logger_prefix = __name__[0:__name__.rindex('.')] - log = logging.getLogger(logger_prefix) + # 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!") - # NOTE: This sets the log level to what was requested for the logger on the - # command line, but this may not be the correct approach if multiple handlers - # are intended to be used. The logger level shown here indicates all the log - # messages that are captured and the handler levels indicate messages each - # handler will output. A better approach may be resetting this to logging.DEBUG - # to catch everything and making sure the handlers have the correct levels set. - # This would be useful for a future GUI logging window that can filter messages - # independently of the console output. In this case, this should be DEBUG. - log.setLevel(LOG_LEVELS[args.log]) + # Throw the new exception + raise Exception("Cannot find GNU Radio!") from None - # Console formatting - console = logging.StreamHandler() - console.setLevel(LOG_LEVELS[args.log]) - #msg_format = '[%(asctime)s - %(levelname)8s] --- %(message)s (%(filename)s:%(lineno)s)' - msg_format = '[%(levelname)s] %(message)s (%(filename)s:%(lineno)s)' - date_format = '%I:%M' - formatter = logging.Formatter(msg_format, datefmt=date_format) +### 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) - #formatter = utils.log.ConsoleFormatter() - console.setFormatter(formatter) - log.addHandler(console) - py_version = sys.version.split()[0] - log.debug("Starting GNU Radio Companion ({})".format(py_version)) +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(), @@ -94,3 +96,167 @@ def main(): 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) + sys.exit(app.run()) + + +def main(): + grc_version_from_config = "" + grc_qt_config_file = os.path.expanduser('~/.gnuradio/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') + parser.add_argument('--wiki', action='store_true') + # 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.expanduser('~') + "/.gnuradio/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/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..9a593bd --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --ignore=test_qtbot.py diff --git a/tests/test_qtbot.py b/tests/test_qtbot.py new file mode 100644 index 0000000..b4407b5 --- /dev/null +++ b/tests/test_qtbot.py @@ -0,0 +1,891 @@ +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) + keystroke(qtbot, app, QtCore.Qt.Key_Enter) + + +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") + qtbot.wait(100) + + 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 titled changed" + 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 titled changed" + + +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 + + 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 + + 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) + 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) + 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, "getSaveFileName", lambda *args, **kargs: (fg_path, "") + ) + + 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, "getSaveFileName", lambda *args, **kargs: (fg_path, "") + ) + + 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, "getSaveFileName", lambda *args, **kargs: (fg_path, "") + ) + 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 +@pytest.mark.xfail() +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: (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: (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: (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, "getSaveFileName", lambda *args, **kargs: (fg_path, "") + ) + + 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)