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(
+ '| {name} |
'
+ "".format(color=color, name=name)
+ for name, color in colors
+ )
+ message += "
"
+ 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)