Skip to content

visualdynamics.gui.text_import

text_import

The dialog that reads a delimited text file: the layout found, set where it is wrong, on a preview of the channels as they will be read.

A text export says nothing about itself that a reader can trust without looking: which column is time, at what rate, in which unit each channel is. So importing one asks, every time. The dialog opens on what io.delimited.text_layout found: the delimiter and the decimal point, the time column (or none, and a sample rate in its place), and a table of the channels, each with a box to keep it, its name and its unit as the header gave them. Anything can be changed, and the preview under it is read again from the file's head (PREVIEW_ROWS rows) by the same read_delimited call the import makes, drawn by plot.text_import.build_import_preview: each channel in its row, its missing samples crossed, a dead channel named. The notes that read gives are listed under the preview, and a refusal is shown in their place with Import disabled, since the import would refuse the same way. A file larger than PREVIEW_INLINE_BYTES is read for its preview on the window's worker (MainWindow._run_long), so the dialog keeps painting while the head of a wide file is parsed.

Import then reads the whole file with exactly those arguments, holding only what differs from what was found, so the journal replays the import as it was made: project.import_file(path, sample_rate=5120.0).

Classes:

Name Description
TextImportDialog

How a delimited text file is read, chosen on a preview of its

Functions:

Name Description
ask_text_import

Show the dialog and return the import options chosen, or None when

Classes

TextImportDialog

TextImportDialog(path: str | PathLike, colors: Mapping[str, str], parent: QWidget | None = None, runner: Callable[..., Any] | None = None)

Bases: QDialog

How a delimited text file is read, chosen on a preview of its channels.

path is the file and colors the theme's colors. The dialog opens on io.delimited.text_layout's reading of the file. runner runs a slow read off the GUI thread, called as the window's _run_long is (a status line, then the work, which takes progress=); without one every preview is read in place.

options() is the answer in read_delimited's own keywords, holding only what differs from what was found: an untouched dialog answers {}, and the journal line is the plain import.

Methods:

Name Description
time_column

The time column chosen, by index, or None.

options

The import's keyword arguments, holding only what differs from

Source code in src/visualdynamics/gui/text_import.py
def __init__(self, path: str | os.PathLike, colors: Mapping[str, str],
             parent: QWidget | None = None,
             runner: Callable[..., Any] | None = None) -> None:
    super().__init__(parent)
    import pyqtgraph as pg

    self.path: str = str(path)
    self.colors: dict[str, str] = dict(colors)
    self.runner: Callable[..., Any] | None = runner
    #: what the file was found to be, which `options` is measured from
    self.found: delimited.TextLayout = delimited.text_layout(self.path)
    #: the layout as the delimiter and decimal now chosen read it
    self.layout: delimited.TextLayout = self.found
    #: the last preview read, or None when it was refused
    self.history: Any = None
    #: what the last preview read noted, or the refusal
    self.notes: list[str] = []
    self.refusal: str | None = None
    self._building = False

    base = os.path.basename(self.path)
    self.setWindowTitle(f'Import {base}')
    self.header: QLabel = QLabel()
    self.header.setWordWrap(True)

    self.delimiter_box: QComboBox = QComboBox()
    for label, value in DELIMITER_CHOICES:
        self.delimiter_box.addItem(label, value)
    self.decimal_box: QComboBox = QComboBox()
    self.decimal_box.addItem('Point (0.25)', '.')
    self.decimal_box.addItem('Comma (0,25)', ',')
    self.time_box: QComboBox = QComboBox()
    self.time_box.setToolTip('The column that holds each row\'s time, or '
                             'none when the rows are evenly sampled at '
                             'the sample rate')
    self.rate_box: QDoubleSpinBox = QDoubleSpinBox()
    self.rate_box.setRange(0.0, 1e9)
    self.rate_box.setDecimals(3)
    self.rate_box.setSuffix(' /s')
    self.rate_box.setSpecialValueText('—')
    self.rate_box.setKeyboardTracking(False)
    self.rate_box.setToolTip('Samples per second. With a time column it '
                             'must agree with the column\'s step; '
                             'without one it is the rate the rows were '
                             'sampled at')

    self.table: QTableWidget = QTableWidget(0, 3)
    self.table.setHorizontalHeaderLabels(['Column', 'Name', 'Unit'])
    self.table.horizontalHeader().setStretchLastSection(True)
    self.table.verticalHeader().hide()
    self.table.setToolTip('Uncheck a column to leave it out. A name that '
                          'is a DOF (101X+) becomes the channel\'s DOF; '
                          'a unit is declared and converted to SI')

    self.plot: Any = pg.GraphicsLayoutWidget()
    self.plot.setBackground(background_brush(self.colors))
    self.plot.setMinimumSize(560, 360)
    self.message: QLabel = QLabel()
    self.message.setWordWrap(True)
    self.message.setTextInteractionFlags(
        Qt.TextInteractionFlag.TextSelectableByMouse)

    buttons = QDialogButtonBox()
    self.import_button: Any = buttons.addButton(
        'Import', QDialogButtonBox.ButtonRole.AcceptRole)
    buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
    buttons.accepted.connect(self.accept)
    buttons.rejected.connect(self.reject)

    form = QFormLayout()
    form.addRow('Delimiter', self.delimiter_box)
    form.addRow('Decimal point', self.decimal_box)
    form.addRow('Time column', self.time_box)
    form.addRow('Sample rate', self.rate_box)
    side = QVBoxLayout()
    side.addLayout(form)
    side.addWidget(QLabel('Channels'))
    side.addWidget(self.table, 1)
    middle = QHBoxLayout()
    middle.addWidget(self.plot, 1)
    middle.addLayout(side)
    layout = QVBoxLayout(self)
    layout.addWidget(self.header)
    layout.addLayout(middle, 1)
    layout.addWidget(self.message)
    layout.addWidget(buttons)

    self._fill(self.found)
    if self.found.sample_rate is not None:
        self.rate_box.setValue(self.found.sample_rate)
    self.delimiter_box.currentIndexChanged.connect(lambda _i: self._relayout())
    self.decimal_box.currentIndexChanged.connect(lambda _i: self._relayout())
    self.time_box.currentIndexChanged.connect(lambda _i: self._time_chosen())
    self.rate_box.valueChanged.connect(lambda _v: self._restate())
    self.table.itemChanged.connect(lambda _item: self._restate())
    self._restate()
Methods:
time_column
time_column() -> int | None

The time column chosen, by index, or None.

Source code in src/visualdynamics/gui/text_import.py
def time_column(self) -> int | None:
    """The time column chosen, by index, or None."""
    return self.time_box.currentData()
options
options() -> dict[str, Any]

The import's keyword arguments, holding only what differs from what was found: 'delimiter' and 'decimal', 'time_column' (a name, or False for none), 'sample_rate', 'columns' when some are left out, 'names' when one was changed and 'units' by column name where one differs from the header's. A column whose name another column shares is given by its index, which the reader would otherwise refuse as naming two.

Source code in src/visualdynamics/gui/text_import.py
def options(self) -> dict[str, Any]:
    """The import's keyword arguments, holding only what differs from
    what was found: 'delimiter' and 'decimal', 'time_column' (a name,
    or False for none), 'sample_rate', 'columns' when some are left
    out, 'names' when one was changed and 'units' by column name
    where one differs from the header's. A column whose name another
    column shares is given by its index, which the reader would
    otherwise refuse as naming two."""
    out: dict[str, Any] = {}
    layout = self.layout

    def key(k: int) -> str | int:
        name = layout.columns[k]
        return name if layout.columns.count(name) == 1 else k

    if layout.delimiter != self.found.delimiter:
        out['delimiter'] = layout.delimiter
    if layout.decimal != self.found.decimal:
        out['decimal'] = layout.decimal
    # measured from the layout as now read: the import reads it the
    # same way from the delimiter and decimal stated
    time = self.time_column()
    if time != layout.time_column:
        out['time_column'] = False if time is None else key(time)
    rate = float(self.rate_box.value())
    if rate > 0 and rate != (layout.sample_rate or 0.0):
        out['sample_rate'] = rate
    rows = self._rows()
    kept = [(k, name, unit) for k, keep, name, unit in rows if keep]
    if len(kept) < len(rows):
        out['columns'] = [key(k) for k, _n, _u in kept]
    if any(name != layout.columns[k] for k, name, _u in kept):
        out['names'] = [name or layout.columns[k] for k, name, _u in kept]
    units = {key(k): unit for k, _n, unit in kept
             if unit and unit != (layout.units[k] or '')}
    if units:
        out['units'] = units
    return out

Functions:

ask_text_import

ask_text_import(parent: QWidget | None, path: str | PathLike, colors: Mapping[str, str], runner: Callable[..., Any] | None = None) -> dict[str, Any] | None

Show the dialog and return the import options chosen, or None when it is canceled and the file is skipped. runner is as the dialog takes it.

Source code in src/visualdynamics/gui/text_import.py
def ask_text_import(parent: QWidget | None, path: str | os.PathLike,
                    colors: Mapping[str, str],
                    runner: Callable[..., Any] | None = None
                    ) -> dict[str, Any] | None:
    """Show the dialog and return the import options chosen, or None when
    it is canceled and the file is skipped. `runner` is as the dialog
    takes it."""
    dialog = TextImportDialog(path, colors, parent, runner)
    if dialog.exec() == QDialog.DialogCode.Accepted:
        return dialog.options()
    return None