From 60643388463bffb8ce535ca69b919cceb92cae1e Mon Sep 17 00:00:00 2001 From: scottcorbin Date: Sun, 8 Nov 2026 18:14:33 -0800 Subject: [PATCH] Mappers dialog: describe each mapper type, show per-mapper details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a mapper opened a bare dropdown of type names, so a first-time user picking between "Tripwire" and "Eve Scout" had nothing to go on — in particular that one needs an account on a Tripwire server and the other needs nothing at all. - Add opens a small picker listing each type with a one-line description of what it is and what it needs. Descriptions wrap rather than clip, since how much fits on a line depends on the platform font. - The table gains a Details column summarizing each config (Tripwire: the account user, or "no account set"; Eve Scout: "public"). It never shows a password or token; a test pins that. - The table reads as a config list rather than a spreadsheet: no painted grid, alternating row colors, and no row-number gutter. MAPPER_TYPE_DESCRIPTIONS sits beside MAPPER_TYPE_LABELS, so a new mapper type adds one line to each. Co-Authored-By: Claude Opus 5.5 --- src/shortcircuit/app.py | 92 +++++++++++++++---- src/shortcircuit/model/test_mappers_dialog.py | 61 ++++++++++++ 2 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 src/shortcircuit/model/test_mappers_dialog.py diff --git a/src/shortcircuit/app.py b/src/shortcircuit/app.py index 7fa37ca..7ef550b 100644 --- a/src/shortcircuit/app.py +++ b/src/shortcircuit/app.py @@ -46,6 +46,56 @@ class StateMapper(TypedDict): TYPE_EVESCOUT: "Eve Scout", } +MAPPER_TYPE_DESCRIPTIONS = { + TYPE_TRIPWIRE: "Corp/alliance scanning map — needs an account on a Tripwire server", + TYPE_EVESCOUT: "Public Thera and Turnur connections — no account needed", +} + + +class AddMapperTypeDialog(QtWidgets.QDialog): + """ + Type picker for the Add flow: each mapper kind with a one-line + description of what it is and what it needs, instead of a bare + dropdown of type names. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Add mapper") + self.setMinimumWidth(440) + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("What kind of mapper do you want to add?")) + + self.list_types = QtWidgets.QListWidget() + self.list_types.setSpacing(2) + # Descriptions wrap rather than clip behind a horizontal scrollbar; + # how much fits on a line depends on the platform's font. + self.list_types.setWordWrap(True) + self.list_types.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + for type_key, label in MAPPER_TYPE_LABELS.items(): + item = QtWidgets.QListWidgetItem() + item.setData(QtCore.Qt.ItemDataRole.UserRole, type_key) + description = MAPPER_TYPE_DESCRIPTIONS.get(type_key, "") + item.setText("{}\n {}".format(label, description) if description else label) + self.list_types.addItem(item) + self.list_types.setCurrentRow(0) + self.list_types.itemDoubleClicked.connect(lambda _item: self.accept()) + layout.addWidget(self.list_types) + + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def selected_type(self) -> Optional[str]: + item = self.list_types.currentItem() + if item is None: + return None + return item.data(QtCore.Qt.ItemDataRole.UserRole) + class TripwireDialog(QtWidgets.QDialog, Ui_TripwireDialog): """ @@ -88,7 +138,8 @@ class MappersDialog(QtWidgets.QDialog, Ui_MappersDialog): COL_ENABLED = 0 COL_NAME = 1 COL_TYPE = 2 - COL_URL = 3 + COL_DETAILS = 3 + COL_URL = 4 def __init__( self, @@ -101,14 +152,20 @@ def __init__( self.configs: List[MapperConfig] = [MapperConfig(**c.to_dict()) for c in configs] self.proxy = proxy or "" - self.tableWidget_mappers.setColumnCount(4) + # A config list, not a spreadsheet: no painted grid, zebra rows for + # scanning across, and no row numbers (they mean nothing here). + self.tableWidget_mappers.setShowGrid(False) + self.tableWidget_mappers.setAlternatingRowColors(True) + self.tableWidget_mappers.verticalHeader().setVisible(False) + self.tableWidget_mappers.setColumnCount(5) self.tableWidget_mappers.setHorizontalHeaderLabels([ - "Enabled", "Name", "Type", "URL", + "Enabled", "Name", "Type", "Details", "URL", ]) header = self.tableWidget_mappers.horizontalHeader() header.setSectionResizeMode(self.COL_ENABLED, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(self.COL_NAME, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(self.COL_TYPE, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(self.COL_DETAILS, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(self.COL_URL, QtWidgets.QHeaderView.ResizeMode.Stretch) self.pushButton_add.clicked.connect(self._on_add) @@ -145,30 +202,33 @@ def _refresh_table(self): type_item.setFlags(type_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) self.tableWidget_mappers.setItem(row, self.COL_TYPE, type_item) + details_item = QtWidgets.QTableWidgetItem(self._cfg_details(cfg)) + details_item.setFlags(details_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + self.tableWidget_mappers.setItem(row, self.COL_DETAILS, details_item) + url_item = QtWidgets.QTableWidgetItem(cfg.url) url_item.setFlags(url_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) self.tableWidget_mappers.setItem(row, self.COL_URL, url_item) + @staticmethod + def _cfg_details(cfg: MapperConfig) -> str: + """Human-readable per-type summary; never the secret itself.""" + if cfg.type == TYPE_TRIPWIRE: + return "user: {}".format(cfg.user) if cfg.user else "no account set" + if cfg.type == TYPE_EVESCOUT: + return "public" + return "" + def _set_enabled(self, row: int, checked: bool): if 0 <= row < len(self.configs): self.configs[row].enabled = checked @QtCore.Slot() def _on_add(self): - type_label, ok = QtWidgets.QInputDialog.getItem( - self, - "Add mapper", - "Type:", - list(MAPPER_TYPE_LABELS.values()), - 0, - False, - ) - if not ok: + picker = AddMapperTypeDialog(self) + if not picker.exec(): return - type_key = next( - (k for k, v in MAPPER_TYPE_LABELS.items() if v == type_label), - None, - ) + type_key = picker.selected_type() if type_key is None: return diff --git a/src/shortcircuit/model/test_mappers_dialog.py b/src/shortcircuit/model/test_mappers_dialog.py new file mode 100644 index 0000000..2d397ca --- /dev/null +++ b/src/shortcircuit/model/test_mappers_dialog.py @@ -0,0 +1,61 @@ +# test_mappers_dialog.py +"""The Mappers dialog's type picker and table.""" + +import os + +# The suite is headless: select the Qt platform plugin before Qt is imported. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6 import QtWidgets + +from shortcircuit.model.mapper_config import TYPE_EVESCOUT, TYPE_TRIPWIRE, MapperConfig + + +@pytest.fixture(scope="session") +def qapp(): + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + yield app + + +def test_picker_lists_every_type_with_a_description(qapp): + from shortcircuit.app import MAPPER_TYPE_DESCRIPTIONS, MAPPER_TYPE_LABELS, AddMapperTypeDialog + + picker = AddMapperTypeDialog() + rows = [picker.list_types.item(i) for i in range(picker.list_types.count())] + assert len(rows) == len(MAPPER_TYPE_LABELS) + for type_key in MAPPER_TYPE_LABELS: + assert MAPPER_TYPE_DESCRIPTIONS.get(type_key), "every type needs a description" + assert picker.selected_type() == list(MAPPER_TYPE_LABELS)[0] + picker.list_types.setCurrentRow(1) + assert picker.selected_type() == list(MAPPER_TYPE_LABELS)[1] + + +def test_details_column_never_shows_secrets(qapp): + from shortcircuit.app import MappersDialog + + tripwire = MapperConfig(type=TYPE_TRIPWIRE, name="Corp", url="https://tw", user="pilot", password="hunter2") + no_account = MapperConfig(type=TYPE_TRIPWIRE, name="Empty", url="https://tw") + scout = MapperConfig(type=TYPE_EVESCOUT, name="Eve Scout") + assert MappersDialog._cfg_details(tripwire) == "user: pilot" + assert MappersDialog._cfg_details(no_account) == "no account set" + assert MappersDialog._cfg_details(scout) == "public" + for cfg in (tripwire, no_account, scout): + assert "hunter2" not in MappersDialog._cfg_details(cfg) + + +def test_table_has_details_column_and_no_row_numbers(qapp): + from shortcircuit.app import MappersDialog + + configs = [ + MapperConfig(type=TYPE_TRIPWIRE, name="Corp", url="https://tw", user="pilot", password="x"), + MapperConfig(type=TYPE_EVESCOUT, name="Eve Scout", url="https://scout"), + ] + dialog = MappersDialog(configs, proxy="") + table = dialog.tableWidget_mappers + headers = [table.horizontalHeaderItem(c).text() for c in range(table.columnCount())] + assert headers == ["Enabled", "Name", "Type", "Details", "URL"] + assert table.item(0, MappersDialog.COL_DETAILS).text() == "user: pilot" + assert table.item(1, MappersDialog.COL_URL).text() == "https://scout" + assert not table.showGrid() + assert not table.verticalHeader().isVisible()