sovabids.tui

Terminal User Interface for sovabids.

Classes

FSPickerScreen

Base filesystem browser modal: an editable location bar + an Up button + a

DirPickerScreen

Modal that lets user browse and confirm a directory.

FilePickerScreen

Modal that lets user browse and confirm a file (e.g. a rules YAML).

SetupPane

FilesListScreen

Modal showing all matched files with extraction details on click.

ChannelNamesScreen

Modal showing channel names and types from a file.

_OptionalNumber

Blank is allowed (the field is optional); a non-blank value must be a positive,

RulesPane

MappingsPane

ConvertPane

SovabidsApp

sovabids TUI — EEG to BIDS conversion wizard.

Functions

_tree_root(→ str)

Return a valid directory to root a DirectoryTree at.

_flatten_dict(→ dict)

Flatten nested dict with dot-notation keys.

_plf_problem(→ str)

Return why value is not a usable power-line frequency, or "" if fine.

_open_path_in_viewer(→ bool)

Open a file with the OS default handler, cross-platform. Returns True if a

main(→ None)

Module Contents

sovabids.tui._tree_root(start: str) → str

Return a valid directory to root a DirectoryTree at.

A DirectoryTree root must be a directory, so normalize the seed value: a file opens at its parent directory (fixes reopening a picker whose input already holds a file path), and an empty / nonexistent path falls back to the nearest existing ancestor, then the home directory.

class sovabids.tui.FSPickerScreen(start: str = '.')

Bases: textual.screen.ModalScreen[str]

Base filesystem browser modal: an editable location bar + an Up button + a DirectoryTree, so the user can navigate anywhere (not just below the seed folder). Subclasses decide whether OK returns a directory or a file.

DEFAULT_CSS = Multiline-String
Show Value
"""
    FSPickerScreen {
        align: center middle;
    }
    FSPickerScreen > Vertical {
        width: 80%;
        height: 80%;
        border: thick $primary;
        background: $surface;
        padding: 1;
    }
    FSPickerScreen DirectoryTree {
        height: 1fr;
    }
    FSPickerScreen Label {
        height: 1;
        margin-bottom: 1;
    }
    FSPickerScreen .loc-row {
        height: 3;
    }
    FSPickerScreen .loc-row Input {
        width: 1fr;
    }
    FSPickerScreen .newdir-row {
        height: 3;
    }
    FSPickerScreen .newdir-row Input {
        width: 1fr;
    }
    FSPickerScreen .btn-row {
        height: 3;
        align: right middle;
    }
    FSPickerScreen Button {
        margin-left: 1;
    }
    """
_prompt: ClassVar[str] = 'Select a path:'
_DOUBLE_CLICK_SECONDS: ClassVar[float] = 0.5
_selected = ''
_root
_last_dir = ''
_last_dir_t = 0.0
compose() → textual.app.ComposeResult
_extra_controls() → list

Widgets to place between the location bar and the tree. Base: none.

_reroot(path: str) → None

Point the tree (and the location bar) at path’s nearest directory.

on_input_submitted(event: textual.widgets.Input.Submitted) → None
on_directory_tree_directory_selected(event: textual.widgets.DirectoryTree.DirectorySelected) → None
_on_dir_selected(path: str) → None

Single-selection hook. Base does nothing (a file picker ignores directories).

on_button_pressed(event: textual.widgets.Button.Pressed) → None
_make_folder() → None

Create a folder named after the new-folder-name box inside the folder currently shown, then step into it and pre-select it. Only the directory picker renders that box, so file mode never reaches here.

abstractmethod _confirm() → None
on_key(event) → None
class sovabids.tui.DirPickerScreen(start: str = '.')

Bases: FSPickerScreen

Modal that lets user browse and confirm a directory.

_prompt = 'Pick a directory: click to select, double-click to enter; OK confirms.'
_extra_controls() → list

Widgets to place between the location bar and the tree. Base: none.

_on_dir_selected(path: str) → None

Single-selection hook. Base does nothing (a file picker ignores directories).

_confirm() → None
class sovabids.tui.FilePickerScreen(start: str = '.')

Bases: FSPickerScreen

Modal that lets user browse and confirm a file (e.g. a rules YAML).

Unlike DirPickerScreen, only a file selection sets the confirmable value; clicking a folder just navigates the tree and never becomes the result. That is what keeps a stray navigation click before OK from returning a directory — the exact failure in issue #89. (It inherits the base directory handler, which only navigates into folders and never sets the confirmable value here.)

_prompt = 'Pick a file: double-click folders to enter, click a file, then OK.'
on_directory_tree_file_selected(event: textual.widgets.DirectoryTree.FileSelected) → None
_confirm() → None
class sovabids.tui.SetupPane

Bases: textual.widgets.Static

DEFAULT_CSS = Multiline-String
Show Value
"""
    SetupPane { padding: 1 2; }
    SetupPane Label { margin-top: 1; }
    SetupPane .path-row { height: 3; }
    SetupPane Input { width: 1fr; }
    SetupPane Button { width: 14; margin-left: 1; }
    SetupPane .hint { color: $text-muted; margin-bottom: 1; }
    """
compose() → textual.app.ComposeResult
on_button_pressed(event: textual.widgets.Button.Pressed) → None
get_values() → dict
class sovabids.tui.FilesListScreen(files: list[str], pattern: str = '', mode: str = 'placeholder', fields: list[str] | None = None)

Bases: textual.screen.ModalScreen

Modal showing all matched files with extraction details on click.

DEFAULT_CSS = Multiline-String
Show Value
"""
    FilesListScreen { align: center middle; }
    FilesListScreen > Vertical {
        width: 92%; height: 90%;
        border: thick $primary; background: $surface; padding: 1;
    }
    FilesListScreen DataTable { height: 1fr; }
    FilesListScreen #extraction-panel {
        height: auto;
        max-height: 40%;
        border: solid $primary;
        padding: 0 1;
        color: $success;
        overflow-y: auto;
    }
    FilesListScreen #extraction-panel.muted { color: $text-muted; }
    FilesListScreen #extraction-panel.error { color: $error; }
    FilesListScreen Horizontal { height: 3; align: right middle; }
    """
_files
_pattern = ''
_mode = 'placeholder'
_fields = []
compose() → textual.app.ComposeResult
on_mount() → None
on_data_table_row_highlighted(event: textual.widgets.DataTable.RowHighlighted) → None
_show_extraction(filepath: str) → None
on_button_pressed(event: textual.widgets.Button.Pressed) → None
on_key(event) → None
class sovabids.tui.ChannelNamesScreen(filepath: str)

Bases: textual.screen.ModalScreen

Modal showing channel names and types from a file.

DEFAULT_CSS = Multiline-String
Show Value
"""
    ChannelNamesScreen { align: center middle; }
    ChannelNamesScreen > Vertical {
        width: 70%; height: 85%;
        border: thick $primary; background: $surface; padding: 1;
    }
    ChannelNamesScreen DataTable { height: 1fr; }
    ChannelNamesScreen #ch-status { color: $text-muted; height: 1; }
    ChannelNamesScreen Horizontal { height: 3; align: right middle; }
    """
_filepath
compose() → textual.app.ComposeResult
on_mount() → None
_load_worker() → None
_set_ch_error(msg: str) → None
_populate(ch_names: list, ch_types: list) → None
on_button_pressed(event: textual.widgets.Button.Pressed) → None
on_key(event) → None
sovabids.tui._flatten_dict(d: dict, prefix: str = '') → dict

Flatten nested dict with dot-notation keys.

sovabids.tui._plf_problem(value: str) → str

Return why value is not a usable power-line frequency, or “” if fine. Blank is fine (optional); otherwise it must parse and be a positive, finite number (rejects nan/inf/negative, which float() would otherwise accept).

sovabids.tui._open_path_in_viewer(path: str) → bool

Open a file with the OS default handler, cross-platform. Returns True if a viewer/browser was launched.

Only waits briefly for the opener to detect an immediate failure (e.g. xdg-open with no handler exits non-zero right away); a still-running opener is treated as a successful hand-off and left alone — never killed — because some openers stay attached to the viewer for its whole lifetime. Falls back to the browser with a valid file:// URI (as_uri handles Windows drives/spaces).

class sovabids.tui._OptionalNumber

Bases: textual.validation.Validator

Blank is allowed (the field is optional); a non-blank value must be a positive, finite number. Flags an unusable power-line frequency instead of dropping it.

validate(value: str) → textual.validation.ValidationResult
class sovabids.tui.RulesPane

Bases: textual.widgets.Static

DEFAULT_CSS = Multiline-String
Show Value
"""
    RulesPane { padding: 1 2; }
    RulesPane Label { margin-top: 1; }
    RulesPane .hint { color: $text-muted; }
    RulesPane .pattern-examples {
        color: $text-muted;
        margin-bottom: 1;
        padding: 0 2;
        border-left: solid $primary;
    }
    RulesPane .rulesfile-row { height: 3; }
    RulesPane .rulesfile-row Input { width: 1fr; }
    RulesPane .rulesfile-row Button { width: 14; margin-left: 1; }
    RulesPane #rules-lock-note {
        display: none;
        height: auto;
        color: $warning;
        border: round $warning;
        padding: 0 1;
        margin-bottom: 1;
    }
    RulesPane #rules-lock-note.locked { display: block; }
    RulesPane #rules-builder { height: auto; }
    RulesPane #ext-count { color: $text-muted; height: 1; margin-bottom: 1; }
    RulesPane #sample-paths {
        display: none;
        height: auto;
        margin-bottom: 1;
        padding: 0 2;
        border-left: solid $success;
    }
    RulesPane #preview-row { height: auto; margin-top: 1; }
    RulesPane #preview-label {
        padding: 1;
        border: solid $primary;
        color: $success;
        width: 1fr;
        height: auto;
        min-height: 3;
    }
    RulesPane #preview-label.error { color: $error; }
    RulesPane #preview-label.muted { color: $text-muted; }
    RulesPane #show-files { width: 18; margin-left: 1; }
    RulesPane Select { margin-bottom: 1; }
    RulesPane Input { margin-bottom: 1; }
    RulesPane .section-head { margin-top: 1; }
    RulesPane RadioSet { margin-bottom: 1; }
    RulesPane #regex-fields-row { display: none; height: auto; }
    RulesPane #regex-fields-row.visible { display: block; }
    RulesPane #regex-examples { display: none; }
    RulesPane #regex-examples.visible { display: block; }
    RulesPane #io-example-row { display: none; height: auto; }
    RulesPane #io-examples { display: none; }
    RulesPane #io-src-row { height: 3; }
    RulesPane #io-src-row Input { width: 1fr; }
    RulesPane #io-src-row Button { width: 18; margin-left: 1; }
    RulesPane #pattern-section { height: auto; }
    RulesPane .inspect-row { height: 3; margin-bottom: 1; }
    RulesPane .inspect-row Button { margin-right: 1; }
    """
_SAMPLE_SHOW: ClassVar[int] = 2
_SAMPLE_COLLECT_CAP: ClassVar[int] = 200
_preview_timer = None
_ext_count_timer = None
_preview_gen = 0
_matched_files: list[str] = []
_psd_in_flight = False
compose() → textual.app.ComposeResult
_compose_builder() → textual.app.ComposeResult
on_input_changed(event: textual.widgets.Input.Changed) → None
_set_builder_locked(locked: bool) → None
get_rules_file() → str
plf_error() → str

Return an error message if the power-line frequency is set but not a usable (positive, finite) number, else “”. Lets an action block instead of silently dropping it (#102).

on_select_changed(event: textual.widgets.Select.Changed) → None
on_radio_set_changed(event: textual.widgets.RadioSet.Changed) → None
on_button_pressed(event: textual.widgets.Button.Pressed) → None
_get_mode_and_fields() → tuple[str, list[str]]
_get_io_example() → tuple[str, str]
_schedule_ext_count() → None
_run_ext_count() → None
_ext_count_worker(source: str, ext: str) → None
_set_ext_count(text: str) → None
_psd_png_path() → str

A single, reused temp file for the PSD preview so repeated clicks overwrite one PNG instead of leaking a new one each time. The directory is removed in on_unmount.

on_unmount() → None
_psd_worker(filepath: str, out_path: str) → None
_psd_done() → None
_update_psd_btn() → None
_schedule_preview() → None
_run_preview() → None
_preview_worker(gen: int, source: str, pattern: str, ext: str, mode: str, fields: list[str], io_src: str = '', io_tgt: str = '') → None
_apply_preview(gen: int, msg: str, css: str, matched: list) → None
_update_show_files_btn(files: list[str]) → None
_set_preview(text: str, css_class: str) → None
_update_samples(samples: list, source: str) → None

Show a couple of real source-file paths so the user can read off the structure and build the pattern from it. Hidden when nothing is found.

_get_source() → str
get_rules() → dict
class sovabids.tui.MappingsPane

Bases: textual.widgets.Static

DEFAULT_CSS = Multiline-String
Show Value
"""
    MappingsPane { padding: 1 2; }
    MappingsPane #mappings-status { margin-bottom: 1; color: $text-muted; }
    MappingsPane DataTable { height: 1fr; }
    MappingsPane Horizontal { height: 3; margin-bottom: 1; }
    MappingsPane Button { margin-right: 1; }
    """
compose() → textual.app.ComposeResult
on_mount() → None
on_button_pressed(event: textual.widgets.Button.Pressed) → None
_generate() → None
_gen_worker(source: str, bids: str, rules: dict, token: int) → None
_apply_mappings(mapping_data: dict, token: int) → None
_gen_error(msg: str, token: int) → None
_populate_table(individuals: list) → None
_save_mappings_yaml() → None
_set_status(msg: str, error: bool = False) → None
class sovabids.tui.ConvertPane

Bases: textual.widgets.Static

DEFAULT_CSS = Multiline-String
Show Value
"""
    ConvertPane { padding: 1 2; }
    ConvertPane Horizontal { height: 3; margin-bottom: 1; }
    ConvertPane Button { margin-right: 1; }
    ConvertPane RichLog { height: 1fr; border: solid $primary; }
    """
compose() → textual.app.ComposeResult
on_button_pressed(event: textual.widgets.Button.Pressed) → None
_start_convert() → None
_convert_worker(mapping_data: dict, log: textual.widgets.RichLog) → None
_log(msg: str) → None
class sovabids.tui.SovabidsApp

Bases: textual.app.App

sovabids TUI — EEG to BIDS conversion wizard.

TITLE = 'sovabids'
SUB_TITLE = 'EEG → BIDS conversion'
CSS = Multiline-String
Show Value
"""
    TabbedContent { height: 1fr; }
    TabPane { height: 1fr; }
    """
BINDINGS = [('q', 'quit', 'Quit'), ('ctrl+s', 'save_rules', 'Save rules')]
_mapping_data: dict | None = None
_pending_input_id: str = ''
_gen_token: int = 0
compose() → textual.app.ComposeResult
_on_path_picked(path: str) → None
on_tabbed_content_tab_activated(event: textual.widgets.TabbedContent.TabActivated) → None
on_input_changed(event: textual.widgets.Input.Changed) → None
on_select_changed(event: textual.widgets.Select.Changed) → None
on_radio_set_changed(event: textual.widgets.RadioSet.Changed) → None
_invalidate_mappings_if_edit(control) → None
_set_mappings_valid(valid: bool) → None
action_save_rules() → None
sovabids.tui.main() → None