Source code for wonambi.widgets.utils
"""Various functions used for the GUI.
"""
from ast import literal_eval
from logging import getLogger
from math import ceil, floor
from numpy import arange, NaN
from os.path import dirname, join, realpath
from PyQt5.QtCore import QRectF, QSettings, Qt
from PyQt5.QtGui import (QBrush,
QPen,
QColor,
QPainterPath,
QPainter,
)
from PyQt5.QtSvg import QSvgGenerator
from PyQt5.QtWidgets import (QCheckBox,
QComboBox,
QCommonStyle,
QFileDialog,
QGraphicsItem,
QGraphicsRectItem,
QGraphicsSimpleTextItem,
QLineEdit,
QMessageBox,
QPushButton,
QRadioButton,
QSpinBox,
)
lg = getLogger(__name__)
LINE_WIDTH = 0 # COSMETIC LINE
LINE_COLOR = 'black'
# TODO: this in ConfigNotes
STAGE_NAME = ['NREM1', 'NREM2', 'NREM3', 'REM', 'Wake', 'Movement',
'Undefined', 'Unknown', 'Artefact']
MAX_LENGTH = 20
stdicon = QCommonStyle.standardIcon
icon_path = join(dirname(realpath(__file__)), 'icons')
oxy_path = join(icon_path, 'oxygen')
ICON = {'application': join(icon_path, 'wonambi.png'),
'open_rec': join(oxy_path, 'document-open.png'),
'page_prev': join(oxy_path, 'go-previous-view.png'),
'page_next': join(oxy_path, 'go-next-view.png'),
'step_prev': join(oxy_path, 'go-previous.png'),
'step_next': join(oxy_path, 'go-next.png'),
'chronometer': join(oxy_path, 'chronometer.png'),
'up': join(oxy_path, 'go-up.png'),
'down': join(oxy_path, 'go-down.png'),
'zoomin': join(oxy_path, 'zoom-in.png'),
'zoomout': join(oxy_path, 'zoom-out.png'),
'zoomnext': join(oxy_path, 'zoom-next.png'),
'zoomprev': join(oxy_path, 'zoom-previous.png'),
'ydist_more': join(oxy_path, 'format-line-spacing-triple.png'),
'ydist_less': join(oxy_path, 'format-line-spacing-normal.png'),
'selchan': join(oxy_path, 'mail-mark-task.png'),
'widget': join(oxy_path, 'window-duplicate.png'),
'settings': join(oxy_path, 'configure.png'),
'quit': join(oxy_path, 'window-close.png'),
'bookmark': join(oxy_path, 'bookmarks-organize.png'),
'event': join(oxy_path, 'edit-table-cell-merge.png'),
'new_eventtype': join(oxy_path, 'edit-table-insert-column-right.png'),
'del_eventtype': join(oxy_path, 'edit-table-delete-column.png'),
'help-about': join(oxy_path, 'help-about.png')
}
settings = QSettings("wonambi", "wonambi")
[docs]class Path(QPainterPath):
"""Paint a line in the simplest possible way.
Parameters
----------
x : ndarray or list
x-coordinates
y : ndarray or list
y-coordinates
"""
def __init__(self, x, y):
super().__init__()
self.moveTo(x[0], y[0])
for i_x, i_y in zip(x, y):
self.lineTo(i_x, i_y)
[docs]class RectMarker(QGraphicsRectItem):
"""Class to draw a rectangular, coloured item.
Parameters
----------
x : float
x position in scene
y : gloat
y position in scene
width : float
length in seconds
height : float
height in scene units
color : str or QColor, optional
color of the rectangle
"""
def __init__(self, x, y, width, height, zvalue, color='blue'):
super().__init__()
self.color = color
self.setZValue(zvalue)
buffer = 1
self.marker = QRectF(x, y, width, height)
self.b_rect = QRectF(x - buffer / 2, y + buffer / 2, width + buffer,
height + buffer)
self.params = x, y, width, height, zvalue, color
[docs] def paint(self, painter, option, widget):
color = QColor(self.color)
painter.setBrush(QBrush(color))
p = QPen()
p.setWidth(0)
p.setColor(color)
painter.setPen(p)
painter.drawRect(self.marker)
super().paint(painter, option, widget)
[docs]class TextItem_with_BG(QGraphicsSimpleTextItem):
"""Class to draw text with dark background (easier to read).
Parameters
----------
bg_color : str or QColor, optional
color to use as background
"""
def __init__(self, bg_color='black'):
super().__init__()
self.bg_color = bg_color
self.setFlag(QGraphicsItem.ItemIgnoresTransformations)
self.setBrush(QBrush(Qt.white))
[docs] def paint(self, painter, option, widget):
bg_color = QColor(self.bg_color)
painter.setBrush(QBrush(bg_color))
painter.drawRect(self.boundingRect())
super().paint(painter, option, widget)
[docs]class FormBool(QCheckBox):
"""Subclass QCheckBox to have a more consistent API across widgets.
Parameters
----------
checkbox_label : str
label next to checkbox
"""
def __init__(self, checkbox_label):
super().__init__(checkbox_label)
[docs] def get_value(self, default=False):
"""Get the value of the QCheckBox, as boolean.
Parameters
----------
default : bool
not used
Returns
-------
bool
state of the checkbox
"""
return self.checkState() == Qt.Checked
[docs] def set_value(self, value):
"""Set value of the checkbox.
Parameters
----------
value : bool
value for the checkbox
"""
if value:
self.setCheckState(Qt.Checked)
else:
self.setCheckState(Qt.Unchecked)
[docs] def connect(self, funct):
"""Call funct when user ticks the box.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.stateChanged.connect(funct)
[docs]class FormRadio(QRadioButton):
"""Subclass QRadioButton to have a more consistent API across widgets.
Parameters
----------
checkbox_label : str
label next to checkbox
"""
def __init__(self, checkbox_label):
super().__init__(checkbox_label)
[docs] def get_value(self, default=False):
"""Get the value of the QCheckBox, as boolean.
Parameters
----------
default : bool
not used
Returns
-------
bool
state of the checkbox
"""
return self.isChecked == Qt.Checked
[docs] def set_value(self, value):
"""Set value of the checkbox.
Parameters
----------
value : bool
value for the checkbox
"""
if value:
self.setChecked(Qt.Checked)
else:
self.setChecked(Qt.Unchecked)
[docs] def connect(self, funct):
"""Call funct when user ticks the box.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.toggled.connect(funct)
[docs]class FormFloat(QLineEdit):
"""Subclass QLineEdit for float to have a more consistent API across
widgets.
Parameters
----------
significant_digits : int
number of significant digits
"""
def __init__(self, default=None, maxw=None, significant_digits=3):
super().__init__('')
self.significant_digits = significant_digits
if default is not None:
self.set_value(default)
if maxw is not None:
self.setMaximumWidth(maxw)
[docs] def get_value(self, default=0):
"""Get float from widget.
Parameters
----------
default : float
default value for the parameter in case it fails
Returns
-------
float
the value in text or default
"""
text = self.text()
if text == 'N/A':
return NaN
try:
text = float(text)
except ValueError:
lg.debug('Cannot convert "' + str(text) + '" to float.' +
'Using default ' + str(default))
text = default
self.set_value(text)
return text
[docs] def set_value(self, value):
"""Set value of the float.
Parameters
----------
value : float
value for the line edit
"""
if value == '' or value is None or value == 'N/A':
text = ''
else:
text = ('{:.' + str(self.significant_digits) + 'f}').format(value)
self.setText(text)
[docs] def connect(self, funct):
"""Call funct when the text was changed.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.textEdited.connect(funct)
[docs]class FormInt(QLineEdit):
"""Subclass QLineEdit for int to have a more consistent API across widgets.
"""
def __init__(self, default=None):
super().__init__('')
if default is not None:
self.set_value(default)
[docs] def get_value(self, default=0):
"""Get int from widget.
Parameters
----------
default : int
default value for the parameter in case it fails
Returns
-------
int
the value in text or default
"""
text = self.text()
try:
text = int(float(text)) # to convert values like 30.0
except ValueError:
lg.debug('Cannot convert "' + str(text) + '" to int. ' +
'Using default ' + str(default))
text = default
self.set_value(text)
return text
[docs] def set_value(self, value):
"""Set value of the int.
Parameters
----------
value : int
value for the line edit
"""
self.setText(str(value))
[docs] def connect(self, funct):
"""Call funct when the text was changed.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.textEdited.connect(funct)
[docs]class FormList(QLineEdit):
"""Subclass QLineEdit for lists to have a more consistent API across
widgets.
"""
def __init__(self):
super().__init__('')
[docs] def get_value(self, default=None):
"""Get int from widget.
Parameters
----------
default : list
list with widgets
Returns
-------
list
list that might contain int or str or float etc
"""
if default is None:
default = []
try:
text = literal_eval(self.text())
if not isinstance(text, list):
pass
# raise ValueError
except ValueError:
lg.debug('Cannot convert "' + str(text) + '" to list. ' +
'Using default ' + str(default))
text = default
self.set_value(text)
return text
[docs] def set_value(self, value):
"""Set value of the list.
Parameters
----------
value : list
value for the line edit
"""
self.setText(str(value))
[docs] def connect(self, funct):
"""Call funct when the text was changed.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.textEdited.connect(funct)
[docs]class FormStr(QLineEdit):
"""Subclass QLineEdit for str to have a more consistent API across widgets.
"""
def __init__(self):
super().__init__('')
[docs] def get_value(self, default=''):
"""Get int from widget.
Parameters
----------
default : str
not used
Returns
-------
str
the value in text
"""
return self.text()
[docs] def set_value(self, value):
"""Set value of the string.
Parameters
----------
value : str
value for the line edit
"""
self.setText(value)
[docs] def connect(self, funct):
"""Call funct when the text was changed.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.textEdited.connect(funct)
[docs]class FormDir(QPushButton):
"""Subclass QPushButton for str to have a more consistent API across widgets.
Notes
-----
It calls to open the directory three times, but I don't understand why
"""
def __init__(self):
super().__init__('')
[docs] def get_value(self, default=''):
"""Get int from widget.
Parameters
----------
default : str
not used
Returns
-------
str
the value in text
"""
return self.text()
[docs] def set_value(self, value):
"""Set value of the string.
Parameters
----------
value : str
value for the line edit
"""
self.setText(value)
[docs] def connect(self, funct):
"""Call funct when the text was changed.
Parameters
----------
funct : function
function that broadcasts a change.
Notes
-----
There is something wrong here. When you run this function, it calls
for opening a directory three or four times. This is obviously wrong
but I don't understand why this happens three times. Traceback did not
help.
"""
def get_directory():
rec = QFileDialog.getExistingDirectory(self,
'Path to Recording'
' Directory')
if rec == '':
return
self.setText(rec)
funct()
self.clicked.connect(get_directory)
[docs]class FormMenu(QComboBox):
"""Subclass QComboBox for dropdown menus to have a more consistent API
across widgets.
Parameters
----------
input_list: list of str
items to include in the dropdown menu / combobox
"""
def __init__(self, input_list):
super().__init__()
if input_list is not None:
for i in input_list:
self.addItem(i)
[docs] def get_value(self, default=None):
"""Get selection from widget.
Parameters
----------
default : str
str for use by widget
Returns
-------
str
selected item from the combobox
"""
if default is None:
default = ''
try:
text = self.currentText()
except ValueError:
lg.debug('Cannot convert "' + str(text) + '" to list. ' +
'Using default ' + str(default))
text = default
self.set_value(text)
return text
[docs] def set_value(self, value):
"""Set value of the list.
Parameters
----------
value : str
value for the combobox
"""
self.setCurrentText(str(value))
[docs] def connect(self, funct):
"""Call funct when the selection was changed.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.currentIndexChanged.connect(funct)
[docs]class FormSpin(QSpinBox):
"""Subclass QSpinBox for int to have a more consistent API across widgets.
"""
def __init__(self, default=None, min_val=None, max_val=None, step=None):
super().__init__()
if default is not None:
self.set_value(default)
if min_val is not None:
self.setMinimum(min_val)
if max_val is not None:
self.setMaximum(max_val)
if step is not None:
self.setSingleStep(step)
[docs] def get_value(self, default=0):
"""Get int from widget.
Parameters
----------
default : int
default value for the parameter in case it fails
Returns
-------
int
the value in text or default
"""
text = self.value()
try:
text = int(float(text))
except ValueError:
lg.debug('Cannot convert "' + str(text) + '" to int. ' +
'Using default ' + str(default))
text = default
self.set_value(text)
return text
[docs] def set_value(self, value):
"""Set value of the int.
Parameters
----------
value : int
value for the line edit
"""
self.setValue(int(value))
[docs] def connect(self, funct):
"""Call funct when the text was changed.
Parameters
----------
funct : function
function that broadcasts a change.
"""
self.valueChanged.connect(funct)
[docs]def keep_recent_datasets(max_dataset_history, info=None):
"""Keep track of the most recent recordings.
Parameters
----------
max_dataset_history : int
maximum number of datasets to remember
info : str, optional TODO
path to file
Returns
-------
list of str
paths to most recent datasets (only if you don't specify
new_dataset)
"""
history = settings.value('recent_recordings', [])
if isinstance(history, str):
history = [history]
if info is not None and info.filename is not None:
new_dataset = info.filename
if new_dataset in history:
lg.debug(new_dataset + ' already present, will be replaced')
history.remove(new_dataset)
if len(history) > max_dataset_history:
lg.debug('Removing last dataset ' + history[-1])
history.pop()
lg.debug('Adding ' + new_dataset + ' to list of recent datasets')
history.insert(0, new_dataset)
settings.setValue('recent_recordings', history)
return None
else:
return history
[docs]def choose_file_or_dir():
"""Create a simple message box to see if the user wants to open dir or file
Returns
-------
str
'dir' or 'file' or 'abort'
"""
question = QMessageBox(QMessageBox.Information, 'Open Dataset',
'Do you want to open a file or a directory?')
dir_button = question.addButton('Directory', QMessageBox.YesRole)
file_button = question.addButton('File', QMessageBox.NoRole)
question.addButton(QMessageBox.Cancel)
question.exec_()
response = question.clickedButton()
if response == dir_button:
return 'dir'
elif response == file_button:
return 'file'
else:
return 'abort'
[docs]def select_session(sessions):
"""Select one session out of a list of sessions.
Parameters
----------
sessions : list
list of integers
Returns
-------
None or int
index of the session being chosen
"""
question = QMessageBox(QMessageBox.Question, 'Open Dataset',
'Select one session')
buttons = []
for sess in sessions:
buttons.append(
question.addButton('Session ' + str(sess), QMessageBox.ActionRole))
button_cancel = question.addButton(QMessageBox.Cancel)
question.exec_()
response = question.clickedButton()
if response == button_cancel:
return
else:
return buttons.index(response)
[docs]def short_strings(s, max_length=MAX_LENGTH):
if len(s) > max_length:
max_length -= 3 # dots
start = ceil(max_length / 2)
end = -floor(max_length / 2)
s = s[:start] + '...' + s[end:]
return s
[docs]def convert_name_to_color(s):
"""Convert any string to an RGB color.
Parameters
----------
s : str
string to convert
selection : bool, optional
if an event is being selected, it's lighter
Returns
-------
instance of QColor
one of the possible color
Notes
-----
It takes any string and converts it to RGB color. The same string always
returns the same color. The numbers are a bit arbitrary but not completely.
h is the baseline color (keep it high to have brighter colors). Make sure
that the max module + h is less than 256 (RGB limit).
The number you multiply ord for is necessary to differentiate the letters
(otherwise 'r' and 's' are too close to each other).
"""
h = 100
v = [5 * ord(x) for x in s]
sum_mod = lambda x: sum(x) % 100
color = QColor(sum_mod(v[::3]) + h, sum_mod(v[1::3]) + h,
sum_mod(v[2::3]) + h)
return color
[docs]def freq_from_str(freq_str):
"""Obtain frequency ranges from input string, either as list or dynamic
notation.
Parameters
----------
freq_str : str
String with frequency ranges, either as a list:
e.g. [[1-3], [3-5], [5-8]];
or with a dynamic definition: (start, stop, width, step).
Returns
-------
list of tuple of float or None
Every tuple of float represents a frequency band. If input is invalid,
returns None.
"""
freq = []
as_list = freq_str[1:-1].replace(' ', '').split(',')
try:
if freq_str[0] == '[' and freq_str[-1] == ']':
for i in as_list:
one_band = i[1:-1].split('-')
one_band = float(one_band[0]), float(one_band[1])
freq.append(one_band)
elif freq_str[0] == '(' and freq_str[-1] == ')':
if len(as_list) == 4:
start = float(as_list[0])
stop = float(as_list[1])
halfwidth = float(as_list[2]) / 2
step = float(as_list[3])
centres = arange(start, stop, step)
for i in centres:
freq.append((i - halfwidth, i + halfwidth))
else:
return None
else:
return None
except:
return None
return freq
[docs]def export_graphics(MAIN, checked=False, test=None):
from .modal_widgets import SVGDialog # avoid circolar import
if MAIN.info.filename is not None:
filename = join(dirname(MAIN.info.filename), '*.svg')
else:
filename = None
svg_dialog = SVGDialog(filename)
if test is None:
if not svg_dialog.exec():
return
else:
svg_dialog.idx_file.setText(test)
if svg_dialog.idx_list.currentText() == 'Traces':
widget = MAIN.traces
elif svg_dialog.idx_list.currentText() == 'Overview':
widget = MAIN.overview
svg_file = svg_dialog.idx_file.text()
if not svg_file.endswith('.svg'):
svg_file += '.svg'
export_graphics_to_svg(widget, svg_file)
[docs]def export_graphics_to_svg(widget, filename):
"""Export graphics to svg
Parameters
----------
widget : instance of QGraphicsView
traces or overview
filename : str
path to save svg
"""
generator = QSvgGenerator()
generator.setFileName(filename)
generator.setSize(widget.size())
generator.setViewBox(widget.rect())
painter = QPainter()
painter.begin(generator)
widget.render(painter)
painter.end()