From 7d7b27e92cf7f4a85eb1abb25189048e1bb48319 Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 17:40:07 -0700 Subject: [PATCH 01/10] feat(ui): intel-aware preview borders with decay and pulse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WindowPreviewWidget now tints its frame based on IntelReport threat level (clear/info/warning/danger/critical), with a 30s linear alpha decay after the last alert and a one-shot 600ms pulse on upgrade into danger or critical. WindowManager.apply_threat_state fans the state out to every preview frame; MainWindowV21._on_intel_alert routes VISUAL_BORDER alerts through it. Per-character system tracking is a follow-up — for now all frames share the same state, which still uses intel data EVE-O Preview cannot access. Existing flash_border / border_flash_requested wiring is preserved (legacy path now actually has a widget implementation behind the hasattr guard at main_window_v21.py:650). 25 new tests, 2204 total green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 168 ++++++++++- src/argus_overview/ui/main_window_v21.py | 9 + tests/test_main_tab.py | 364 +++++++++++++++++++++++ 3 files changed, 538 insertions(+), 3 deletions(-) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 40f7735..70a05fb 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -48,10 +48,31 @@ ) from argus_overview.core.discovery import scan_eve_windows +from argus_overview.intel.parser import ThreatLevel from argus_overview.ui.action_registry import PrimaryHome from argus_overview.ui.menu_builder import ContextMenuBuilder, ToolbarBuilder from argus_overview.utils.screen import ScreenGeometry, get_screen_geometry +# Threat-tint config — tuned for a glanceable but non-distracting frame +THREAT_BORDER_COLORS: dict[ThreatLevel, tuple[int, int, int]] = { + ThreatLevel.CLEAR: (0, 200, 100), + ThreatLevel.INFO: (0, 180, 230), + ThreatLevel.WARNING: (255, 170, 0), + ThreatLevel.DANGER: (255, 90, 30), + ThreatLevel.CRITICAL: (255, 40, 40), +} +THREAT_LEVEL_RANK: dict[ThreatLevel, int] = { + ThreatLevel.CLEAR: 0, + ThreatLevel.INFO: 1, + ThreatLevel.WARNING: 2, + ThreatLevel.DANGER: 3, + ThreatLevel.CRITICAL: 4, +} +THREAT_DECAY_TICK_MS = 100 # decay tick rate +THREAT_DECAY_DURATION_MS = 30_000 # full fade time after last alert +THREAT_PULSE_TICK_MS = 33 # ~30 fps pulse +THREAT_PULSE_DURATION_MS = 600 # one pulse cycle + # Module-level constant: avoids re-creating the dict on every pil_to_qimage call _FORMAT_MAP = { "RGB": (3, QImage.Format.Format_RGB888), @@ -606,6 +627,27 @@ def __init__( self._show_session_timer = False self._load_settings() + # Intel threat state (PR1: intel-aware preview borders) + self._threat_level: ThreatLevel | None = None + self._threat_system: str | None = None + # Decay alpha drives the inset border; ranges 0.0 (off) to 1.0 (full) + self._threat_alpha: float = 0.0 + self._threat_decay_steps: int = max(1, THREAT_DECAY_DURATION_MS // THREAT_DECAY_TICK_MS) + self._threat_decay_timer = QTimer(self) + self._threat_decay_timer.timeout.connect(self._tick_threat_decay) + + # Pulse animation (oneshot on transition into danger/critical) + self._pulse_phase: float = 0.0 # 1.0 -> 0.0 over PULSE_DURATION + self._pulse_steps: int = max(1, THREAT_PULSE_DURATION_MS // THREAT_PULSE_TICK_MS) + self._pulse_timer = QTimer(self) + self._pulse_timer.timeout.connect(self._tick_pulse) + + # Legacy flash_border state (retains existing border_flash_requested wiring) + self._flash_color: QColor | None = None + self._flash_timer = QTimer(self) + self._flash_timer.setSingleShot(True) + self._flash_timer.timeout.connect(self._clear_flash) + # Setup UI self.setMinimumSize(200, 150) self.setMaximumSize(600, 450) @@ -803,14 +845,117 @@ def leaveEvent(self, event): super().leaveEvent(event) + def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> None: + """ + Update the intel threat state for this preview frame. + + Args: + level: Threat level. None or CLEAR clears the border. + system: System name the threat refers to (kept for tooltip + dock). + """ + prev_level = self._threat_level + + if level is None or level == ThreatLevel.CLEAR: + self._threat_level = None + self._threat_system = None + self._threat_alpha = 0.0 + self._threat_decay_timer.stop() + self.update() + return + + self._threat_level = level + self._threat_system = system + self._threat_alpha = 1.0 + + # Pulse on upgrade into danger/critical + upgraded = prev_level is None or THREAT_LEVEL_RANK.get( + prev_level, 0 + ) < THREAT_LEVEL_RANK.get(level, 0) + if upgraded and level in (ThreatLevel.DANGER, ThreatLevel.CRITICAL): + self._start_pulse() + + # Restart decay timer + self._threat_decay_timer.start(THREAT_DECAY_TICK_MS) + self.update() + + def _tick_threat_decay(self) -> None: + """Linear decay of the threat-border alpha.""" + if self._threat_alpha <= 0.0: + self._threat_decay_timer.stop() + return + self._threat_alpha = max(0.0, self._threat_alpha - 1.0 / self._threat_decay_steps) + if self._threat_alpha <= 0.0: + self._threat_level = None + self._threat_system = None + self._threat_decay_timer.stop() + self.update() + + def _start_pulse(self) -> None: + """Trigger a single pulse cycle on upgrade to danger/critical.""" + self._pulse_phase = 1.0 + self._pulse_timer.start(THREAT_PULSE_TICK_MS) + + def _tick_pulse(self) -> None: + """Decrement pulse phase toward 0; stop when done.""" + self._pulse_phase = max(0.0, self._pulse_phase - 1.0 / self._pulse_steps) + if self._pulse_phase <= 0.0: + self._pulse_timer.stop() + self.update() + + def flash_border(self, color: str, duration_ms: int) -> None: + """ + Legacy color/duration border flash hook used by border_flash_requested. + + Kept independent of set_threat_state so callers without IntelReport + context still get visual feedback. + """ + self._flash_color = QColor(color) + self._flash_timer.start(max(0, int(duration_ms))) + self.update() + + def _clear_flash(self) -> None: + self._flash_color = None + self.update() + def paintEvent(self, event): - """Custom paint for activity indicator""" + """Custom paint: threat border, focus dot, lock icon, legacy flash.""" super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - # Draw activity indicator (v2.2) + # 1. Threat-tint border (PR1) — drawn first so dot/lock paint over it + if self._threat_level is not None and self._threat_alpha > 0.0: + r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) + base_alpha = int(220 * self._threat_alpha) + # Pulse adds an extra alpha kick for the first ~600ms after upgrade + pulse_boost = int(35 * self._pulse_phase) + alpha = max(0, min(255, base_alpha + pulse_boost)) + border_color = QColor(r, g, b, alpha) + pen_width = 3 + int(2 * self._pulse_phase) # 3px → 5px during pulse + pen = QPen(border_color) + pen.setWidth(pen_width) + painter.setPen(pen) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + inset = pen_width // 2 + 1 + painter.drawRoundedRect( + inset, + inset, + self.width() - 2 * inset, + self.height() - 2 * inset, + 4, + 4, + ) + + # 2. Legacy flash overlay (compat with border_flash_requested) + if self._flash_color is not None: + pen = QPen(self._flash_color) + pen.setWidth(3) + painter.setPen(pen) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 4, 4) + + # 3. Activity indicator (v2.2) if self._show_activity_indicator: activity = self.get_activity_state() if activity == "focused": @@ -825,7 +970,7 @@ def paintEvent(self, event): painter.setPen(QPen(Qt.PenStyle.NoPen)) painter.drawEllipse(self.width() - 14, 6, 8, 8) - # Draw lock icon if positions are locked + # 4. Lock icon if positions are locked if self._positions_locked: painter.setPen(QPen(QColor(200, 200, 200, 180))) painter.drawText(6, 14, "🔒") @@ -1036,6 +1181,23 @@ def remove_window(self, window_id: str): self.logger.info(f"Removed window {window_id} from preview") + def apply_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: + """ + Fan out an intel threat state to every preview frame. + + Until per-character system tracking lands, every frame shares the + same state. Returns the count of frames updated for tests + logs. + """ + count = 0 + for frame in list(self.preview_frames.values()): + try: + frame.set_threat_state(level, system) + count += 1 + except RuntimeError: + # Widget already deleted by Qt — skip + continue + return count + def _capture_cycle(self): """ Capture cycle - called by timer diff --git a/src/argus_overview/ui/main_window_v21.py b/src/argus_overview/ui/main_window_v21.py index 7451d39..a84b894 100644 --- a/src/argus_overview/ui/main_window_v21.py +++ b/src/argus_overview/ui/main_window_v21.py @@ -653,11 +653,20 @@ def _flash_preview_borders(self, color: str, duration_ms: int): @Slot(object, object) def _on_intel_alert(self, report, alert_type): """Handle intel alert from intel tab.""" + from argus_overview.intel.alerts import AlertType from argus_overview.intel.parser import IntelReport if not isinstance(report, IntelReport): return + # Fan out threat state to preview frames once per report + # (filter on VISUAL_BORDER so we only trigger on a single AlertType + # emission per report, not on every type the dispatcher fires). + if alert_type == AlertType.VISUAL_BORDER and hasattr(self, "main_tab"): + window_manager = getattr(self.main_tab, "window_manager", None) + if window_manager is not None and hasattr(window_manager, "apply_threat_state"): + window_manager.apply_threat_state(report.threat_level, report.system) + # Show tray notification for critical alerts if report.threat_level.value == "critical": if hasattr(self, "system_tray"): diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index fa3c32e..34340fd 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -9040,3 +9040,367 @@ def test_on_window_activated_catches_value_error(self): tab._on_window_activated("0x123") tab.logger.error.assert_called_once() + + +# ============================================================================= +# Threat-Tint Border Tests (PR1: intel-aware preview borders) +# ============================================================================= + + +def _make_widget_for_threat_tests(): + """Bypass __init__ and seed only the threat-related state.""" + from argus_overview.ui.main_tab import ( + THREAT_DECAY_DURATION_MS, + THREAT_DECAY_TICK_MS, + THREAT_PULSE_DURATION_MS, + THREAT_PULSE_TICK_MS, + WindowPreviewWidget, + ) + + widget = WindowPreviewWidget.__new__(WindowPreviewWidget) + widget._threat_level = None + widget._threat_system = None + widget._threat_alpha = 0.0 + widget._threat_decay_steps = max(1, THREAT_DECAY_DURATION_MS // THREAT_DECAY_TICK_MS) + widget._threat_decay_timer = MagicMock() + widget._pulse_phase = 0.0 + widget._pulse_steps = max(1, THREAT_PULSE_DURATION_MS // THREAT_PULSE_TICK_MS) + widget._pulse_timer = MagicMock() + widget._flash_color = None + widget._flash_timer = MagicMock() + widget.update = MagicMock() + return widget + + +class TestWindowPreviewWidgetThreatState: + """Tests for set_threat_state, decay, pulse, and flash_border.""" + + def test_set_threat_state_stores_level_and_system(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP") + + assert widget._threat_level == ThreatLevel.WARNING + assert widget._threat_system == "HED-GP" + assert widget._threat_alpha == 1.0 + widget._threat_decay_timer.start.assert_called_once() + widget.update.assert_called() + + def test_set_threat_state_clear_resets(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.DANGER + widget._threat_system = "HED-GP" + widget._threat_alpha = 0.7 + + widget.set_threat_state(ThreatLevel.CLEAR) + + assert widget._threat_level is None + assert widget._threat_system is None + assert widget._threat_alpha == 0.0 + widget._threat_decay_timer.stop.assert_called_once() + + def test_set_threat_state_none_resets(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.DANGER + widget._threat_alpha = 0.5 + + widget.set_threat_state(None) + + assert widget._threat_level is None + assert widget._threat_alpha == 0.0 + + def test_pulse_triggers_on_upgrade_to_danger(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP") + widget._pulse_timer.start.reset_mock() + + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert widget._pulse_phase == 1.0 + widget._pulse_timer.start.assert_called_once() + + def test_pulse_triggers_on_first_critical(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.CRITICAL, "Jita") + + assert widget._pulse_phase == 1.0 + widget._pulse_timer.start.assert_called_once() + + def test_pulse_does_not_trigger_on_warning(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.WARNING, "Jita") + + assert widget._pulse_phase == 0.0 + widget._pulse_timer.start.assert_not_called() + + def test_pulse_does_not_re_trigger_on_same_level(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + widget._pulse_timer.start.reset_mock() + widget._pulse_phase = 0.0 # pulse already finished + + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + widget._pulse_timer.start.assert_not_called() + + def test_decay_tick_reduces_alpha(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.WARNING + widget._threat_alpha = 1.0 + + widget._tick_threat_decay() + + assert widget._threat_alpha < 1.0 + assert widget._threat_alpha > 0.0 + widget.update.assert_called() + + def test_decay_clears_state_at_zero(self): + from argus_overview.intel.parser import ThreatLevel + + widget = _make_widget_for_threat_tests() + widget._threat_level = ThreatLevel.WARNING + widget._threat_system = "HED-GP" + widget._threat_decay_steps = 2 # tighten so 2 ticks fully drains + widget._threat_alpha = 1.0 / widget._threat_decay_steps # near zero + + widget._tick_threat_decay() + + assert widget._threat_alpha == 0.0 + assert widget._threat_level is None + assert widget._threat_system is None + widget._threat_decay_timer.stop.assert_called() + + def test_decay_tick_at_zero_stops_timer(self): + widget = _make_widget_for_threat_tests() + widget._threat_alpha = 0.0 + + widget._tick_threat_decay() + + widget._threat_decay_timer.stop.assert_called_once() + + def test_pulse_tick_decrements_phase(self): + widget = _make_widget_for_threat_tests() + widget._pulse_phase = 1.0 + + widget._tick_pulse() + + assert widget._pulse_phase < 1.0 + widget.update.assert_called() + + def test_pulse_tick_at_zero_stops_timer(self): + widget = _make_widget_for_threat_tests() + widget._pulse_steps = 2 + widget._pulse_phase = 1.0 / widget._pulse_steps # near zero + + widget._tick_pulse() + + assert widget._pulse_phase == 0.0 + widget._pulse_timer.stop.assert_called_once() + + def test_flash_border_sets_color_and_starts_timer(self): + from PySide6.QtGui import QColor + + widget = _make_widget_for_threat_tests() + widget.flash_border("#FF0000", 2000) + + assert isinstance(widget._flash_color, QColor) + widget._flash_timer.start.assert_called_once_with(2000) + widget.update.assert_called() + + def test_flash_border_with_negative_duration_clamped_to_zero(self): + widget = _make_widget_for_threat_tests() + widget.flash_border("#FF0000", -100) + + widget._flash_timer.start.assert_called_once_with(0) + + def test_clear_flash_resets_color(self): + from PySide6.QtGui import QColor + + widget = _make_widget_for_threat_tests() + widget._flash_color = QColor("#FF0000") + + widget._clear_flash() + + assert widget._flash_color is None + widget.update.assert_called() + + +class TestWindowManagerApplyThreatState: + """Tests for WindowManager.apply_threat_state fan-out.""" + + def test_apply_threat_state_fans_out_to_all_frames(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + f1, f2, f3 = MagicMock(), MagicMock(), MagicMock() + manager.preview_frames = {"w1": f1, "w2": f2, "w3": f3} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 3 + f1.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") + f2.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") + f3.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") + + def test_apply_threat_state_empty_frames(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "Jita") + + assert count == 0 + + def test_apply_threat_state_skips_deleted_widgets(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + ok = MagicMock() + dead = MagicMock() + dead.set_threat_state.side_effect = RuntimeError("widget deleted") + manager.preview_frames = {"alive": ok, "dead": dead} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "Jita") + + assert count == 1 + ok.set_threat_state.assert_called_once() + + def test_apply_threat_state_clear_propagates(self): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.main_tab import WindowManager + + with patch.object(WindowManager, "__init__", return_value=None): + manager = WindowManager.__new__(WindowManager) + f1 = MagicMock() + manager.preview_frames = {"w1": f1} + + manager.apply_threat_state(ThreatLevel.CLEAR, None) + + f1.set_threat_state.assert_called_once_with(ThreatLevel.CLEAR, None) + + +class TestWindowPreviewWidgetPaintWithThreat: + """Smoke tests that paintEvent runs without crashing across threat states.""" + + def _build_real_widget(self): + from argus_overview.ui.main_tab import WindowPreviewWidget + + capture_system = MagicMock() + widget = WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=capture_system, + ) + widget.resize(200, 150) + return widget + + def test_paint_event_no_threat_does_not_crash(self): + widget = self._build_real_widget() + try: + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_event_with_threat_does_not_crash(self): + from argus_overview.intel.parser import ThreatLevel + + widget = self._build_real_widget() + try: + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_event_with_flash_does_not_crash(self): + widget = self._build_real_widget() + try: + widget.flash_border("#FF0000", 1000) + widget.repaint() + finally: + widget.deleteLater() + + +class TestMainWindowV21IntelAlertThreatFanout: + """Wiring test: _on_intel_alert routes VISUAL_BORDER alerts to fan-out.""" + + def test_visual_border_alert_calls_apply_threat_state(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.system_tray = MagicMock() # avoid critical-tray branch firing extra calls + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.DANGER, + hostile_count=3, + ship_types=["sabre"], + player_names=[], + raw_message="hostiles HED-GP +3", + ) + + window._on_intel_alert(report, AlertType.VISUAL_BORDER) + + window.main_tab.window_manager.apply_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP" + ) + + def test_non_border_alert_does_not_fan_out(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.WARNING, + hostile_count=1, + ship_types=[], + player_names=[], + raw_message="neut HED-GP", + ) + + window._on_intel_alert(report, AlertType.AUDIO) + + window.main_tab.window_manager.apply_threat_state.assert_not_called() + + def test_non_intel_report_ignored(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + + window._on_intel_alert("not a report", AlertType.VISUAL_BORDER) + + window.main_tab.window_manager.apply_threat_state.assert_not_called() From 3eb695e568c7f48e436e27043b72a3fd39164e05 Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 18:43:47 -0700 Subject: [PATCH 02/10] feat(ui): character status dock with per-chip threat state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a horizontal strip of CharacterChip widgets above the preview grid: each chip shows a colored-initials avatar (deterministic accent per character), the character name, current system, and a threat-tint dot fed from the same intel pipeline as the preview borders (PR1). Clicking a chip activates the matching window via the existing focus path. StatusDock owns chip lifecycle (add/remove/clear/sync) and fans threat state to all chips. MainTab._sync_status_dock mirrors window_manager state on every add/remove site. MainWindowV21._on_intel_alert pushes VISUAL_BORDER alerts to the dock alongside the existing preview-frame fan-out, so border + chip dot stay in lock-step. Visibility gated on thumbnails.show_status_dock (default true). Per-character system tracking still uses the shared parser current_system for now — the chip already accepts a system arg so per-char tracking can land later without API changes. 35 new tests in tests/test_status_dock.py + 1 updated existing setup-ui test for the +1 addWidget call. Suite: 2239 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 26 ++ src/argus_overview/ui/main_window_v21.py | 5 +- src/argus_overview/ui/status_dock.py | 323 ++++++++++++++++++ tests/test_main_tab.py | 39 ++- tests/test_status_dock.py | 407 +++++++++++++++++++++++ 5 files changed, 782 insertions(+), 18 deletions(-) create mode 100644 src/argus_overview/ui/status_dock.py create mode 100644 tests/test_status_dock.py diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 70a05fb..59929d2 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -1351,6 +1351,18 @@ def _setup_ui(self): layout_controls = self._create_layout_controls() layout.addWidget(layout_controls) + # PR2: Character status dock — chip strip with per-character system + # + threat dot. Clicking a chip focuses the matching window. + # Parent is set when the dock is added to the layout below. + from argus_overview.ui.status_dock import StatusDock + + self.status_dock = StatusDock() + self.status_dock.chip_clicked.connect(self._on_window_activated) + sm = getattr(self, "settings_manager", None) + show_dock = sm.get("thumbnails.show_status_dock", True) if sm else True + self.status_dock.setVisible(show_dock) + layout.addWidget(self.status_dock) + # Scroll area for preview frames scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -1369,6 +1381,16 @@ def _setup_ui(self): status_bar = self._create_status_bar() layout.addWidget(status_bar) + def _sync_status_dock(self) -> None: + """Mirror window_manager.preview_frames into the status dock.""" + if not hasattr(self, "status_dock") or self.status_dock is None: + return + desired: dict[str, str] = { + wid: getattr(frame, "character_name", wid) + for wid, frame in self.window_manager.preview_frames.items() + } + self.status_dock.sync_from_window_ids(desired) + def _create_toolbar(self) -> QWidget: """Create toolbar using ActionRegistry (v2.3)""" toolbar = QWidget() @@ -1754,6 +1776,7 @@ def one_click_import(self): self.status_label.setText("No new EVE windows found") self._update_status() + self._sync_status_dock() def _toggle_lock(self): """Toggle thumbnail position lock""" @@ -1849,6 +1872,7 @@ def _add_window_to_preview(self, window_id: str, window_title: str) -> bool: self._on_window_removed, Qt.ConnectionType.UniqueConnection ) self.preview_layout.addWidget(frame) + self._sync_status_dock() return True return False @@ -1957,6 +1981,7 @@ def _on_window_removed(self, window_id: str): frame.session_timer.stop() self.window_manager.remove_window(window_id) self._update_status() + self._sync_status_dock() def _remove_all_windows(self): """Remove all windows from preview""" @@ -1977,6 +2002,7 @@ def _remove_all_windows(self): self.window_manager.remove_window(window_id) self._update_status() + self._sync_status_dock() def minimize_inactive_windows(self): """Toggle auto-minimize mode - when enabled, cycling minimizes previous window""" diff --git a/src/argus_overview/ui/main_window_v21.py b/src/argus_overview/ui/main_window_v21.py index a84b894..fe67c5d 100644 --- a/src/argus_overview/ui/main_window_v21.py +++ b/src/argus_overview/ui/main_window_v21.py @@ -659,13 +659,16 @@ def _on_intel_alert(self, report, alert_type): if not isinstance(report, IntelReport): return - # Fan out threat state to preview frames once per report + # Fan out threat state to preview frames + status dock once per report # (filter on VISUAL_BORDER so we only trigger on a single AlertType # emission per report, not on every type the dispatcher fires). if alert_type == AlertType.VISUAL_BORDER and hasattr(self, "main_tab"): window_manager = getattr(self.main_tab, "window_manager", None) if window_manager is not None and hasattr(window_manager, "apply_threat_state"): window_manager.apply_threat_state(report.threat_level, report.system) + status_dock = getattr(self.main_tab, "status_dock", None) + if status_dock is not None and hasattr(status_dock, "set_threat_state"): + status_dock.set_threat_state(report.threat_level, report.system) # Show tray notification for critical alerts if report.threat_level.value == "critical": diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py new file mode 100644 index 0000000..26b4fd5 --- /dev/null +++ b/src/argus_overview/ui/status_dock.py @@ -0,0 +1,323 @@ +""" +Character Status Dock — horizontal strip of character chips. + +Each chip surfaces per-character state that EVE-O Preview cannot: +character avatar (initials in an accent color), system name, and a +threat-tint dot driven by the same intel pipeline that tints preview +borders. Click a chip to focus the matching window. + +PR2 of the intel-aware UI uplift. Pairs with WindowPreviewWidget's +threat-tint border (PR1) so glanceable threat state is visible whether +you're looking at a thumbnail grid or the dock. +""" + +from __future__ import annotations + +import logging + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainter, QPen +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from argus_overview.intel.parser import ThreatLevel +from argus_overview.ui.main_tab import THREAT_BORDER_COLORS + +# Stable accent palette — same hue per character across sessions +CHIP_ACCENT_COLORS: list[tuple[int, int, int]] = [ + (255, 100, 100), + (100, 255, 100), + (100, 150, 255), + (255, 200, 80), + (220, 120, 220), + (100, 220, 220), + (255, 165, 60), + (170, 130, 255), +] + + +def accent_for(name: str) -> QColor: + """Deterministic accent color for a character name.""" + r, g, b = CHIP_ACCENT_COLORS[abs(hash(name)) % len(CHIP_ACCENT_COLORS)] + return QColor(r, g, b) + + +def _initials(name: str) -> str: + parts = [p for p in name.replace("_", " ").split() if p] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][:2].upper() + return (parts[0][0] + parts[-1][0]).upper() + + +class CharacterChip(QFrame): + """ + A single chip in the StatusDock. + + Layout (left to right): + [ avatar 28x28 ] [ name ] [ system pill ] [ threat dot ] + """ + + clicked = Signal(str) # window_id + + AVATAR_SIZE = 28 + DOT_SIZE = 10 + + def __init__( + self, + window_id: str, + character_name: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.window_id = window_id + self.character_name = character_name + self._accent: QColor = accent_for(character_name) + self._system: str | None = None + self._threat_level: ThreatLevel | None = None + self._threat_alpha: float = 0.0 + + self.setFixedHeight(40) + self.setMinimumWidth(160) + self.setMaximumWidth(260) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self._apply_base_style() + + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 4, 8, 4) + layout.setSpacing(8) + + # Avatar — a fixed-size frame painted with initials in the accent color + self._avatar = QLabel(_initials(character_name)) + self._avatar.setFixedSize(self.AVATAR_SIZE, self.AVATAR_SIZE) + self._avatar.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._update_avatar_style() + layout.addWidget(self._avatar) + + # Name label (primary, bold) + self._name_label = QLabel(character_name) + self._name_label.setStyleSheet("font-weight: bold; font-size: 10pt;") + self._name_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + layout.addWidget(self._name_label) + + # System label (secondary) + self._system_label = QLabel("—") + self._system_label.setStyleSheet("color: #aaa; font-size: 9pt;") + self._system_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(self._system_label) + + # Threat dot is painted in paintEvent (no widget needed — keeps chip compact) + + self.setToolTip(self._tooltip_text()) + + # ----- styling helpers -------------------------------------------------- + def _apply_base_style(self) -> None: + self.setStyleSheet( + """ + CharacterChip { + background-color: #2b2b2b; + border: 1px solid #444; + border-radius: 6px; + } + CharacterChip:hover { + background-color: #353535; + border-color: #6a6a6a; + } + """ + ) + + def _update_avatar_style(self) -> None: + a = self._accent + darker = a.darker(160) + # luminance-based contrast for the initials text + luminance = (a.red() * 299 + a.green() * 587 + a.blue() * 114) / 1000 + text_color = "#0f0f0f" if luminance > 140 else "#f5f5f5" + self._avatar.setStyleSheet( + f""" + background-color: {a.name()}; + border: 1px solid {darker.name()}; + border-radius: {self.AVATAR_SIZE // 2}px; + color: {text_color}; + font-weight: bold; + font-size: 10pt; + """ + ) + + def _tooltip_text(self) -> str: + parts = [self.character_name] + if self._system: + parts.append(f"System: {self._system}") + if self._threat_level is not None: + parts.append(f"Threat: {self._threat_level.value}") + parts.append("Click to focus window") + return "\n".join(parts) + + # ----- public API ------------------------------------------------------- + def set_system(self, system: str | None) -> None: + self._system = system + self._system_label.setText(system or "—") + self.setToolTip(self._tooltip_text()) + + def set_threat_state( + self, level: ThreatLevel | None, system: str | None = None, alpha: float = 1.0 + ) -> None: + if level is None or level == ThreatLevel.CLEAR: + self._threat_level = None + self._threat_alpha = 0.0 + else: + self._threat_level = level + self._threat_alpha = max(0.0, min(1.0, alpha)) + if system is not None: + self.set_system(system) + else: + self.setToolTip(self._tooltip_text()) + self.update() + + # ----- events ----------------------------------------------------------- + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.window_id) + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + super().paintEvent(event) + if self._threat_level is None or self._threat_alpha <= 0.0: + return + + painter = QPainter(self) + try: + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) + alpha = max(0, min(255, int(230 * self._threat_alpha))) + color = QColor(r, g, b, alpha) + painter.setPen(QPen(color.darker(140), 1)) + painter.setBrush(QBrush(color)) + # Draw threat dot vertically centered, left of the right edge + x = self.width() - self.DOT_SIZE - 8 + y = (self.height() - self.DOT_SIZE) // 2 + painter.drawEllipse(x, y, self.DOT_SIZE, self.DOT_SIZE) + finally: + painter.end() + + +class StatusDock(QWidget): + """ + Horizontal strip of CharacterChip widgets. + + Mounts above the preview grid in MainTab. Designed to mirror the set + of active preview windows: one chip per window_id. Chips emit + chip_clicked when activated; the dock re-emits to the parent. + """ + + chip_clicked = Signal(str) # window_id + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self._chips: dict[str, CharacterChip] = {} + + self.setFixedHeight(56) + outer = QVBoxLayout(self) + outer.setContentsMargins(4, 2, 4, 2) + outer.setSpacing(0) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + outer.addWidget(self._scroll) + + self._strip = QWidget() + self._strip_layout = QHBoxLayout(self._strip) + self._strip_layout.setContentsMargins(4, 2, 4, 2) + self._strip_layout.setSpacing(6) + self._strip_layout.addStretch() # push chips left, fill empty space right + self._scroll.setWidget(self._strip) + + self.setStyleSheet( + """ + StatusDock { + background-color: #1f1f1f; + border-bottom: 1px solid #3a3a3a; + } + """ + ) + + # ----- public API ------------------------------------------------------- + def chip_count(self) -> int: + return len(self._chips) + + def has_chip(self, window_id: str) -> bool: + return window_id in self._chips + + def add_chip(self, window_id: str, character_name: str) -> CharacterChip | None: + if window_id in self._chips: + return None + chip = CharacterChip(window_id, character_name, parent=self._strip) + chip.clicked.connect(self.chip_clicked.emit) + # Insert before the trailing stretch (last item) + insert_at = max(0, self._strip_layout.count() - 1) + self._strip_layout.insertWidget(insert_at, chip) + self._chips[window_id] = chip + return chip + + def remove_chip(self, window_id: str) -> bool: + chip = self._chips.pop(window_id, None) + if chip is None: + return False + self._strip_layout.removeWidget(chip) + chip.deleteLater() + return True + + def clear(self) -> None: + for window_id in list(self._chips.keys()): + self.remove_chip(window_id) + + def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: + """Fan out a single threat state to every chip. Returns count updated.""" + count = 0 + for chip in list(self._chips.values()): + try: + chip.set_threat_state(level, system) + count += 1 + except RuntimeError: + continue + return count + + def set_chip_system(self, window_id: str, system: str | None) -> bool: + chip = self._chips.get(window_id) + if chip is None: + return False + chip.set_system(system) + return True + + def sync_from_window_ids(self, desired: dict[str, str]) -> tuple[list[str], list[str]]: + """ + Bulk diff: ensure chips match `desired` mapping of window_id -> name. + + Returns (added_ids, removed_ids). + """ + existing = set(self._chips.keys()) + target = set(desired.keys()) + added = [] + removed = [] + for window_id in existing - target: + if self.remove_chip(window_id): + removed.append(window_id) + for window_id in target - existing: + name = desired[window_id] + if self.add_chip(window_id, name) is not None: + added.append(window_id) + return added, removed diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index 34340fd..3e75c34 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -8446,6 +8446,7 @@ def test_setup_ui_creates_layout_and_widgets(self): mock_container = MagicMock() mock_flow_layout = MagicMock() + mock_status_dock = MagicMock() with patch("argus_overview.ui.main_tab.QVBoxLayout") as mock_vbox: with patch("argus_overview.ui.main_tab.QScrollArea", return_value=mock_scroll): with patch("argus_overview.ui.main_tab.QWidget", return_value=mock_container): @@ -8453,29 +8454,33 @@ def test_setup_ui_creates_layout_and_widgets(self): "argus_overview.ui.main_tab.FlowLayout", return_value=mock_flow_layout, ): - mock_layout = MagicMock() - mock_vbox.return_value = mock_layout + with patch( + "argus_overview.ui.status_dock.StatusDock", + return_value=mock_status_dock, + ): + mock_layout = MagicMock() + mock_vbox.return_value = mock_layout - tab._setup_ui() + tab._setup_ui() - # Layout created and set - tab.setLayout.assert_called_once_with(mock_layout) + # Layout created and set + tab.setLayout.assert_called_once_with(mock_layout) - # Toolbar, layout controls, status bar created - tab._create_toolbar.assert_called_once() - tab._create_layout_controls.assert_called_once() - tab._create_status_bar.assert_called_once() + # Toolbar, layout controls, status bar created + tab._create_toolbar.assert_called_once() + tab._create_layout_controls.assert_called_once() + tab._create_status_bar.assert_called_once() - # Scroll area configured - mock_scroll.setWidgetResizable.assert_called_once_with(True) - mock_scroll.setWidget.assert_called_once_with(mock_container) + # Scroll area configured + mock_scroll.setWidgetResizable.assert_called_once_with(True) + mock_scroll.setWidget.assert_called_once_with(mock_container) - # Preview container and layout stored - assert tab.preview_container is mock_container - assert tab.preview_layout is mock_flow_layout + # Preview container and layout stored + assert tab.preview_container is mock_container + assert tab.preview_layout is mock_flow_layout - # All widgets added to layout - assert mock_layout.addWidget.call_count == 4 + # 5 widgets added to layout (PR2 added status dock) + assert mock_layout.addWidget.call_count == 5 # ============================================================================= diff --git a/tests/test_status_dock.py b/tests/test_status_dock.py new file mode 100644 index 0000000..bfd5464 --- /dev/null +++ b/tests/test_status_dock.py @@ -0,0 +1,407 @@ +"""Tests for the character status dock (PR2).""" + +from unittest.mock import MagicMock + +from argus_overview.intel.parser import ThreatLevel +from argus_overview.ui.status_dock import ( + CharacterChip, + StatusDock, + _initials, + accent_for, +) + +# ============================================================================= +# Helpers +# ============================================================================= + + +class TestInitials: + def test_single_word(self): + assert _initials("Solo") == "SO" + + def test_two_words(self): + assert _initials("Foo Bar") == "FB" + + def test_underscores(self): + assert _initials("foo_bar") == "FB" + + def test_empty(self): + assert _initials("") == "?" + + def test_three_words_uses_first_and_last(self): + assert _initials("Alice Beta Gamma") == "AG" + + +class TestAccentFor: + def test_deterministic(self): + assert accent_for("Pilot1").rgb() == accent_for("Pilot1").rgb() + + def test_different_names_likely_differ(self): + # Not guaranteed but extremely probable across the small palette + names = ["A", "B", "C", "D", "E", "F", "G", "H"] + seen = {accent_for(n).rgb() for n in names} + # At least 3 different accents in 8 names is a generous lower bound + assert len(seen) >= 3 + + +# ============================================================================= +# CharacterChip +# ============================================================================= + + +class TestCharacterChip: + def test_init_sets_basic_state(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + assert chip.window_id == "0xABC" + assert chip.character_name == "TestPilot" + assert chip._system is None + assert chip._threat_level is None + finally: + chip.deleteLater() + + def test_set_system_updates_label(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + assert chip._system == "HED-GP" + assert chip._system_label.text() == "HED-GP" + finally: + chip.deleteLater() + + def test_set_system_none_uses_dash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + chip.set_system(None) + assert chip._system_label.text() == "—" + finally: + chip.deleteLater() + + def test_set_threat_state_stores_level(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP") + assert chip._threat_level == ThreatLevel.WARNING + assert chip._threat_alpha == 1.0 + assert chip._system == "HED-GP" + finally: + chip.deleteLater() + + def test_set_threat_state_clear_resets(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.DANGER, "Jita") + chip.set_threat_state(ThreatLevel.CLEAR) + assert chip._threat_level is None + assert chip._threat_alpha == 0.0 + finally: + chip.deleteLater() + + def test_set_threat_state_none_resets(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.DANGER, "Jita") + chip.set_threat_state(None) + assert chip._threat_level is None + finally: + chip.deleteLater() + + def test_set_threat_state_alpha_clamped(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=2.5) + assert chip._threat_alpha == 1.0 + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=-0.5) + assert chip._threat_alpha == 0.0 + finally: + chip.deleteLater() + + def test_click_emits_window_id(self, qapp): + from PySide6.QtCore import QPoint, Qt + from PySide6.QtGui import QMouseEvent + + chip = CharacterChip("0xABC", "TestPilot") + try: + received: list[str] = [] + chip.clicked.connect(received.append) + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + QPoint(5, 5), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + chip.mousePressEvent(event) + assert received == ["0xABC"] + finally: + chip.deleteLater() + + def test_right_click_does_not_emit(self, qapp): + from PySide6.QtCore import QPoint, Qt + from PySide6.QtGui import QMouseEvent + + chip = CharacterChip("0xABC", "TestPilot") + try: + received: list[str] = [] + chip.clicked.connect(received.append) + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + QPoint(5, 5), + Qt.MouseButton.RightButton, + Qt.MouseButton.RightButton, + Qt.KeyboardModifier.NoModifier, + ) + chip.mousePressEvent(event) + assert received == [] + finally: + chip.deleteLater() + + def test_paint_event_no_threat_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.repaint() + finally: + chip.deleteLater() + + def test_paint_event_with_threat_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.set_threat_state(ThreatLevel.CRITICAL, "Jita") + chip.repaint() + finally: + chip.deleteLater() + + def test_tooltip_includes_system_and_threat(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP") + tip = chip.toolTip() + assert "TestPilot" in tip + assert "HED-GP" in tip + assert "warning" in tip + finally: + chip.deleteLater() + + +# ============================================================================= +# StatusDock +# ============================================================================= + + +class TestStatusDock: + def test_init_empty(self, qapp): + dock = StatusDock() + try: + assert dock.chip_count() == 0 + finally: + dock.deleteLater() + + def test_add_chip(self, qapp): + dock = StatusDock() + try: + chip = dock.add_chip("0x1", "Alice") + assert chip is not None + assert dock.chip_count() == 1 + assert dock.has_chip("0x1") + finally: + dock.deleteLater() + + def test_add_chip_duplicate_returns_none(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dup = dock.add_chip("0x1", "Alice") + assert dup is None + assert dock.chip_count() == 1 + finally: + dock.deleteLater() + + def test_remove_chip(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + assert dock.remove_chip("0x1") is True + assert dock.chip_count() == 1 + assert not dock.has_chip("0x1") + assert dock.has_chip("0x2") + finally: + dock.deleteLater() + + def test_remove_chip_missing_returns_false(self, qapp): + dock = StatusDock() + try: + assert dock.remove_chip("nope") is False + finally: + dock.deleteLater() + + def test_clear_removes_all(self, qapp): + dock = StatusDock() + try: + for i in range(5): + dock.add_chip(f"0x{i}", f"Pilot{i}") + dock.clear() + assert dock.chip_count() == 0 + finally: + dock.deleteLater() + + def test_set_threat_state_fans_to_all_chips(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + assert count == 2 + for chip in dock._chips.values(): + assert chip._threat_level == ThreatLevel.DANGER + assert chip._system == "HED-GP" + finally: + dock.deleteLater() + + def test_set_threat_state_clear(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.set_threat_state(ThreatLevel.DANGER, "Jita") + dock.set_threat_state(ThreatLevel.CLEAR) + for chip in dock._chips.values(): + assert chip._threat_level is None + finally: + dock.deleteLater() + + def test_set_chip_system_updates_one_chip(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + assert dock.set_chip_system("0x1", "HED-GP") is True + assert dock._chips["0x1"]._system == "HED-GP" + assert dock._chips["0x2"]._system is None + finally: + dock.deleteLater() + + def test_set_chip_system_unknown_returns_false(self, qapp): + dock = StatusDock() + try: + assert dock.set_chip_system("nope", "Jita") is False + finally: + dock.deleteLater() + + def test_chip_clicked_signal_propagates(self, qapp): + dock = StatusDock() + try: + received: list[str] = [] + dock.chip_clicked.connect(received.append) + chip = dock.add_chip("0xABC", "Alice") + chip.clicked.emit("0xABC") + assert received == ["0xABC"] + finally: + dock.deleteLater() + + def test_sync_from_window_ids_adds_and_removes(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + added, removed = dock.sync_from_window_ids({"0x2": "Bob", "0x3": "Carol"}) + assert added == ["0x3"] + assert removed == ["0x1"] + assert dock.has_chip("0x2") and dock.has_chip("0x3") + assert not dock.has_chip("0x1") + finally: + dock.deleteLater() + + def test_sync_from_window_ids_empty_clears(self, qapp): + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + added, removed = dock.sync_from_window_ids({}) + assert added == [] + assert removed == ["0x1"] + assert dock.chip_count() == 0 + finally: + dock.deleteLater() + + +# ============================================================================= +# MainWindowV21 wiring — dock receives threat fan-out +# ============================================================================= + + +class TestMainWindowV21StatusDockFanout: + def test_visual_border_alert_calls_dock_set_threat_state(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.status_dock = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.DANGER, + hostile_count=2, + ship_types=[], + player_names=[], + raw_message="hostiles HED-GP", + ) + + window._on_intel_alert(report, AlertType.VISUAL_BORDER) + + window.main_tab.status_dock.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP" + ) + + def test_audio_alert_does_not_touch_dock(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.status_dock = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.WARNING, + hostile_count=1, + ship_types=[], + player_names=[], + raw_message="neut", + ) + + window._on_intel_alert(report, AlertType.AUDIO) + + window.main_tab.status_dock.set_threat_state.assert_not_called() + + def test_dock_optional_does_not_crash_when_missing(self): + from argus_overview.intel.alerts import AlertType + from argus_overview.intel.parser import IntelReport, ThreatLevel + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock(spec=["window_manager"]) + window.main_tab.window_manager = MagicMock() + window.system_tray = MagicMock() + + report = IntelReport( + system="HED-GP", + threat_level=ThreatLevel.DANGER, + hostile_count=2, + ship_types=[], + player_names=[], + raw_message="hostiles", + ) + + # Should not raise even though main_tab has no status_dock attr + window._on_intel_alert(report, AlertType.VISUAL_BORDER) + window.main_tab.window_manager.apply_threat_state.assert_called_once() From 3d2781a2583695cbf541cc40340baafbd88a951c Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 21:46:16 -0700 Subject: [PATCH 03/10] feat(ui): preview focus mode (spotlight + dim) Double-click any preview thumbnail to spotlight it: that frame grows beyond the normal 600x450 cap and gets full opacity, while every other frame drops to 25% opacity to fade into the background. Double-click the focused thumb again, or press Escape, to exit. Removing the focused window from the preview also drops focus and restores the rest. WindowPreviewWidget gains: - focus_requested = Signal(str) emitted on left double-click - set_spotlight(mode) state machine (None / 'focused' / 'dimmed') that drives min/max size and the existing opacity_effect - mouseDoubleClickEvent that consumes the second-click activation MainTab gains: - _focus_window_id state + enter_focus_mode / exit_focus_mode / is_focus_mode_active / _on_focus_requested / _apply_focus_state - keyPressEvent handles Escape (only consumes the key when active) - _on_window_removed clears focus and re-applies state when the spotlight target is removed mid-focus Wired focus_requested at both add-window sites (one_click_import + _add_window_to_preview) so any newly-imported frame participates. 17 new tests + 4 existing _on_window_removed tests preserved by making the new code path use getattr/hasattr guards (the bypassed-init test helpers don't set _focus_window_id or status_dock). Suite: 2256 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 122 +++++++++++++- tests/test_main_tab.py | 257 ++++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+), 2 deletions(-) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 59929d2..c440f97 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -589,6 +589,7 @@ class WindowPreviewWidget(QWidget): window_activated = Signal(str) # window_id window_removed = Signal(str) # window_id label_changed = Signal(str, str) # window_id, new_label + focus_requested = Signal(str) # window_id — PR3 spotlight toggle def __init__( self, @@ -686,11 +687,19 @@ def __init__( if self._show_session_timer: self.session_timer.start(60000) # Update every minute - # Opacity effect for hover + # Opacity effect for hover (and PR3 spotlight dim state) self.opacity_effect = QGraphicsOpacityEffect(self) self.opacity_effect.setOpacity(1.0) self.setGraphicsEffect(self.opacity_effect) + # PR3 — focus/spotlight mode state. + # mode is one of: None (normal), "focused" (this widget is the + # spotlight target), "dimmed" (another widget has spotlight). + self._spotlight_mode: str | None = None + # Cached size constraints so we can restore them when leaving focus. + self._normal_min_size: QSize = self.minimumSize() + self._normal_max_size: QSize = self.maximumSize() + def _load_settings(self): """Load settings from settings_manager""" if self.settings_manager: @@ -902,6 +911,36 @@ def _tick_pulse(self) -> None: self._pulse_timer.stop() self.update() + def set_spotlight(self, mode: str | None) -> None: + """ + Apply or clear focus/spotlight presentation. + + Args: + mode: 'focused' to upscale + full opacity (this widget is the + spotlight target), 'dimmed' to fade and desaturate (another + widget owns the spotlight), None to return to normal. + """ + if mode not in (None, "focused", "dimmed"): + raise ValueError(f"Invalid spotlight mode: {mode!r}") + self._spotlight_mode = mode + + if mode == "focused": + # Allow growth beyond the normal max so the focused widget + # actually uses the freed space when others are minimized/hidden. + self.setMinimumSize(QSize(360, 270)) + self.setMaximumSize(QSize(16777215, 16777215)) # QWIDGETSIZE_MAX + self.opacity_effect.setOpacity(1.0) + elif mode == "dimmed": + self.setMinimumSize(self._normal_min_size) + self.setMaximumSize(self._normal_max_size) + self.opacity_effect.setOpacity(0.25) + else: # None — restore baseline + self.setMinimumSize(self._normal_min_size) + self.setMaximumSize(self._normal_max_size) + # Hover state may want partial opacity; reset to full on exit. + self.opacity_effect.setOpacity(self._opacity_on_hover if self._is_hovered else 1.0) + self.update() + def flash_border(self, color: str, duration_ms: int) -> None: """ Legacy color/duration border flash hook used by border_flash_requested. @@ -1018,6 +1057,18 @@ def mouseReleaseEvent(self, event): self.logger.info(f"Activating window: {self.window_id}") self._drag_start_pos = None + def mouseDoubleClickEvent(self, event): + """PR3: double-click toggles spotlight focus mode for this window.""" + if event.button() == Qt.MouseButton.LeftButton: + self.focus_requested.emit(self.window_id) + event.accept() + # Cancel the pending single-click activation that the + # preceding press recorded; otherwise the window still + # activates beneath the focus toggle. + self._drag_start_pos = None + return + super().mouseDoubleClickEvent(event) + def contextMenuEvent(self, event): """Handle right-click context menu (v2.3 - uses ActionRegistry)""" # Build context menu from ActionRegistry @@ -1292,6 +1343,9 @@ def __init__(self, capture_system, character_manager, settings_manager=None, par self.character_manager = character_manager self.settings_manager = settings_manager + # PR3: focus mode state — None = normal, str = window_id holding spotlight + self._focus_window_id: str | None = None + # v2.2 State self._thumbnails_visible = True self._positions_locked = False @@ -1756,6 +1810,9 @@ def one_click_import(self): frame.window_removed.connect( self._on_window_removed, Qt.ConnectionType.UniqueConnection ) + frame.focus_requested.connect( + self._on_focus_requested, Qt.ConnectionType.UniqueConnection + ) # Add to layout self.preview_layout.addWidget(frame) @@ -1871,6 +1928,9 @@ def _add_window_to_preview(self, window_id: str, window_title: str) -> bool: frame.window_removed.connect( self._on_window_removed, Qt.ConnectionType.UniqueConnection ) + frame.focus_requested.connect( + self._on_focus_requested, Qt.ConnectionType.UniqueConnection + ) self.preview_layout.addWidget(frame) self._sync_status_dock() return True @@ -1930,6 +1990,46 @@ def show_add_window_dialog(self): self.logger.info(f"Added {added} windows to preview") self._update_status() + # ----- PR3 focus mode controller -------------------------------------- + def _on_focus_requested(self, window_id: str) -> None: + """Toggle spotlight focus for the given window.""" + if self._focus_window_id == window_id: + self.exit_focus_mode() + else: + self.enter_focus_mode(window_id) + + def enter_focus_mode(self, window_id: str) -> None: + """Spotlight one window: scale up, dim others. Idempotent.""" + if window_id not in self.window_manager.preview_frames: + self.logger.debug(f"Cannot enter focus mode — unknown window {window_id}") + return + self._focus_window_id = window_id + self._apply_focus_state() + + def exit_focus_mode(self) -> None: + """Restore normal grid presentation.""" + if self._focus_window_id is None: + return + self._focus_window_id = None + self._apply_focus_state() + + def is_focus_mode_active(self) -> bool: + return self._focus_window_id is not None + + def _apply_focus_state(self) -> None: + """Push current focus mode to all preview frames.""" + focus_id = self._focus_window_id + for wid, frame in list(self.window_manager.preview_frames.items()): + try: + if focus_id is None: + frame.set_spotlight(None) + elif wid == focus_id: + frame.set_spotlight("focused") + else: + frame.set_spotlight("dimmed") + except RuntimeError: + continue + def _on_window_activated(self, window_id: str): """Handle window activation with optional auto-minimize of previous window""" from argus_overview.utils.window_utils import run_x11_subprocess @@ -1977,11 +2077,23 @@ def _on_window_removed(self, window_id: str): frame.window_removed.disconnect(self._on_window_removed) except (RuntimeError, TypeError): pass + try: + frame.focus_requested.disconnect(self._on_focus_requested) + except (RuntimeError, TypeError): + pass # Stop per-frame timers frame.session_timer.stop() + # If we just removed the spotlight target, drop focus state. + # getattr keeps this safe when called via test helpers that bypass __init__. + if getattr(self, "_focus_window_id", None) == window_id: + self._focus_window_id = None self.window_manager.remove_window(window_id) self._update_status() - self._sync_status_dock() + if hasattr(self, "status_dock"): + self._sync_status_dock() + if hasattr(self, "_focus_window_id"): + # Re-apply (covers both: focus cleared, or other tile removed mid-focus) + self._apply_focus_state() def _remove_all_windows(self): """Remove all windows from preview""" @@ -2141,6 +2253,12 @@ def keyPressEvent(self, event: QKeyEvent): """Handle keyboard shortcuts for window navigation""" key = event.key() + # PR3: Escape exits focus mode (only consume the key if active) + if key == Qt.Key.Key_Escape and self.is_focus_mode_active(): + self.exit_focus_mode() + event.accept() + return + # Number keys 1-9 to activate window by index if Qt.Key.Key_1 <= key <= Qt.Key.Key_9: index = key - Qt.Key.Key_1 # 0-8 diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index 3e75c34..f96a4e3 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -9409,3 +9409,260 @@ def test_non_intel_report_ignored(self): window._on_intel_alert("not a report", AlertType.VISUAL_BORDER) window.main_tab.window_manager.apply_threat_state.assert_not_called() + + +# ============================================================================= +# Spotlight / Focus Mode Tests (PR3) +# ============================================================================= + + +class TestWindowPreviewWidgetSpotlight: + """Tests for set_spotlight() state transitions.""" + + def _make(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + widget = WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + return widget + + def test_set_spotlight_focused_grows(self, qapp): + widget = self._make(qapp) + try: + widget.set_spotlight("focused") + assert widget._spotlight_mode == "focused" + # Focused widget gets a larger min size than the default 200x150 + assert widget.minimumWidth() >= 360 + assert widget.opacity_effect.opacity() == 1.0 + finally: + widget.deleteLater() + + def test_set_spotlight_dimmed_drops_opacity(self, qapp): + widget = self._make(qapp) + try: + widget.set_spotlight("dimmed") + assert widget._spotlight_mode == "dimmed" + assert widget.opacity_effect.opacity() == 0.25 + finally: + widget.deleteLater() + + def test_set_spotlight_none_restores(self, qapp): + widget = self._make(qapp) + try: + normal_min = widget._normal_min_size + widget.set_spotlight("focused") + widget.set_spotlight(None) + assert widget._spotlight_mode is None + assert widget.minimumSize() == normal_min + assert widget.opacity_effect.opacity() == 1.0 + finally: + widget.deleteLater() + + def test_set_spotlight_invalid_raises(self, qapp): + import pytest + + widget = self._make(qapp) + try: + with pytest.raises(ValueError): + widget.set_spotlight("bogus") + finally: + widget.deleteLater() + + def test_double_click_emits_focus_requested(self, qapp): + from PySide6.QtCore import QPoint, Qt + from PySide6.QtGui import QMouseEvent + + widget = self._make(qapp) + try: + received: list[str] = [] + widget.focus_requested.connect(received.append) + event = QMouseEvent( + QMouseEvent.Type.MouseButtonDblClick, + QPoint(50, 50), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + widget.mouseDoubleClickEvent(event) + assert received == ["0xABC"] + finally: + widget.deleteLater() + + +class TestMainTabFocusMode: + """Tests for MainTab focus mode controller.""" + + def _make_tab(self): + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = None + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {} + return tab + + def test_enter_focus_mode_unknown_window_noop(self): + tab = self._make_tab() + tab.enter_focus_mode("missing") + assert tab._focus_window_id is None + + def test_enter_focus_mode_sets_state_and_fans_out(self): + tab = self._make_tab() + f1, f2 = MagicMock(), MagicMock() + tab.window_manager.preview_frames = {"w1": f1, "w2": f2} + + tab.enter_focus_mode("w1") + + assert tab._focus_window_id == "w1" + f1.set_spotlight.assert_called_once_with("focused") + f2.set_spotlight.assert_called_once_with("dimmed") + + def test_exit_focus_mode_clears_all(self): + tab = self._make_tab() + f1, f2 = MagicMock(), MagicMock() + tab.window_manager.preview_frames = {"w1": f1, "w2": f2} + tab._focus_window_id = "w1" + + tab.exit_focus_mode() + + assert tab._focus_window_id is None + f1.set_spotlight.assert_called_once_with(None) + f2.set_spotlight.assert_called_once_with(None) + + def test_exit_focus_mode_when_inactive_noop(self): + tab = self._make_tab() + f1 = MagicMock() + tab.window_manager.preview_frames = {"w1": f1} + + tab.exit_focus_mode() + + f1.set_spotlight.assert_not_called() + + def test_on_focus_requested_toggles_on(self): + tab = self._make_tab() + f1 = MagicMock() + tab.window_manager.preview_frames = {"w1": f1} + + tab._on_focus_requested("w1") + + assert tab._focus_window_id == "w1" + + def test_on_focus_requested_toggles_off_when_same_window(self): + tab = self._make_tab() + f1 = MagicMock() + tab.window_manager.preview_frames = {"w1": f1} + tab._focus_window_id = "w1" + + tab._on_focus_requested("w1") + + assert tab._focus_window_id is None + + def test_on_focus_requested_swaps_to_different_window(self): + tab = self._make_tab() + f1, f2 = MagicMock(), MagicMock() + tab.window_manager.preview_frames = {"w1": f1, "w2": f2} + tab._focus_window_id = "w1" + + tab._on_focus_requested("w2") + + assert tab._focus_window_id == "w2" + # f1 should be dimmed now (used to be focused), f2 focused + f1.set_spotlight.assert_called_with("dimmed") + f2.set_spotlight.assert_called_with("focused") + + def test_apply_focus_state_skips_deleted_widgets(self): + tab = self._make_tab() + ok = MagicMock() + dead = MagicMock() + dead.set_spotlight.side_effect = RuntimeError("widget deleted") + tab.window_manager.preview_frames = {"alive": ok, "dead": dead} + tab._focus_window_id = "alive" + + tab._apply_focus_state() + + ok.set_spotlight.assert_called_with("focused") + + def test_is_focus_mode_active(self): + tab = self._make_tab() + assert tab.is_focus_mode_active() is False + tab._focus_window_id = "w1" + assert tab.is_focus_mode_active() is True + + def test_remove_focused_window_clears_focus(self): + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = "w1" + tab.status_dock = MagicMock() + tab._update_status = MagicMock() + + # Build a frame mock with the disconnect surface and session_timer + frame = MagicMock() + wm = MagicMock() + wm.preview_frames = {"w1": frame} + tab.window_manager = wm + + tab._on_window_removed("w1") + + assert tab._focus_window_id is None + wm.remove_window.assert_called_once_with("w1") + # Disconnect calls happened + frame.window_activated.disconnect.assert_called() + frame.focus_requested.disconnect.assert_called() + + +class TestMainTabFocusEscapeKey: + """Tests that Escape exits focus mode without swallowing other keys.""" + + def test_escape_when_focused_exits(self): + from PySide6.QtCore import Qt + from PySide6.QtGui import QKeyEvent + + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = "w1" + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {"w1": MagicMock()} + + event = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Escape, + Qt.KeyboardModifier.NoModifier, + ) + tab.keyPressEvent(event) + + assert tab._focus_window_id is None + + def test_escape_when_not_focused_passes_through(self): + from PySide6.QtCore import Qt + from PySide6.QtGui import QKeyEvent + + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab._focus_window_id = None + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {} + + event = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Escape, + Qt.KeyboardModifier.NoModifier, + ) + # Should not raise even though super().keyPressEvent reaches QWidget + # bypassed init — bypass keyPressEvent dispatch by mocking out super + # via a real QWidget call would crash, so we just assert the focus + # path didn't fire. + try: + tab.keyPressEvent(event) + except RuntimeError: + pass # Expected: super().keyPressEvent hits bypassed-init widget + assert tab._focus_window_id is None From 935bfdf162cec32fe47db61c16969e3f20e7fc61 Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 22:28:13 -0700 Subject: [PATCH 04/10] feat(intel): per-character system tracking from Local logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVE writes a separate Local channel chat log per character session. Each file has a Listener: header that names the character, and "Channel changed to Local : " lines whenever that character jumps. We parse both to maintain a per-character current-system map. Adds intel/character_location.py — CharacterLocationTracker(QObject): - Polls Local_*.txt files (UTF-16-LE) in the EVE chat log directory - Reads the Listener: header once per file - Tails for "Channel changed to Local : X" lines - Emits character_system_changed = Signal(str, str) on real changes - get_system / get_all_locations for cached lookup - Idempotent start/stop, file rotation handling, garbage-line rejection Wires into the existing UI: - StatusDock.set_character_system(char_name, system) updates every chip for that character (multibox-aware: same character in multiple windows all sync) - WindowManager._character_systems map persists across window add/remove cycles, exposed via set_character_system / get_character_system - MainWindowV21 constructs the tracker, connects character_system_changed to forward to dock + manager, and stops it on close - MainTab._sync_status_dock seeds chip systems from the cached map so newly-imported windows populate without waiting for the next change Behavior is gated on intel.track_character_locations (default true). The tracker idles if it can't find an EVE log directory. This PR is intentionally Phase A: the tracker drives chip system labels only. Smart per-character threat fan-out (only tinting frames whose character is in the affected system) is a follow-up — the storage map exists, the fan-out semantics decision is deferred. 33 new tests in tests/test_character_location.py + 1 existing test patched (init test now also patches _init_location_tracker). Suite: 2289 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../intel/character_location.py | 283 +++++++++++ src/argus_overview/ui/main_tab.py | 28 ++ src/argus_overview/ui/main_window_v21.py | 36 ++ src/argus_overview/ui/status_dock.py | 18 + tests/test_character_location.py | 475 ++++++++++++++++++ tests/test_main_window_v21.py | 1 + 6 files changed, 841 insertions(+) create mode 100644 src/argus_overview/intel/character_location.py create mode 100644 tests/test_character_location.py diff --git a/src/argus_overview/intel/character_location.py b/src/argus_overview/intel/character_location.py new file mode 100644 index 0000000..8cd6ea4 --- /dev/null +++ b/src/argus_overview/intel/character_location.py @@ -0,0 +1,283 @@ +""" +Character Location Tracker — per-character current-system tracking from EVE logs. + +EVE Online writes a separate Local channel chat log per character session. +Each Local log file has: + + 1. A header block declaring the listener (the character) and channel: + + --------------------------------------------------------------- + Channel ID: Local + Channel Name: Local + Listener: MyCharName + Session Started: 2024.01.15 14:30:00 + --------------------------------------------------------------- + + 2. Lines of the form + [ 2024.01.15 14:30:05 ] EVE System > Channel changed to Local : Jita + whenever the character jumps into a new system. + +This tracker polls today's Local_*.txt files, parses the listener header +once per file, then tails each file for "Channel changed to Local" lines +and maintains a {character_name -> current_system} map. It emits +character_system_changed when a character's system updates. + +Files are UTF-16-LE (matches ChatLogWatcher). + +PR4 of the intel-aware UI uplift. Pairs with the chip strip + preview +borders so threat fan-out and chip system labels can become per-character. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtCore import QObject, QTimer, Signal + +# Header line: " Listener: MyCharName" +# Whitespace before label, around colon, and before/after name is variable. +_LISTENER_RE = re.compile(r"^\s*Listener:\s*(?P.+?)\s*$") + +# Channel-changed line as it appears inside a Local log: +# [ 2024.01.15 14:30:05 ] EVE System > Channel changed to Local : Jita +_CHANNEL_CHANGED_RE = re.compile( + r"\[\s*\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2}\s*\]" + r"\s*EVE\s+System\s*>\s*Channel changed to Local\s*:\s*(?P.+?)\s*$" +) + +# Local logs are written one-per-character. Filename pattern: +# Local_YYYYMMDD_HHMMSS_.txt (current EVE clients, charid optional) +_LOCAL_FILE_GLOB = "Local_*.txt" + +# How many bytes from the start of the file to scan for the Listener header. +# The header is small (~300 bytes incl. UTF-16 BOM); 4KB is generous. +_HEADER_SCAN_BYTES = 4096 + + +def find_eve_chat_log_directory() -> Path | None: + """Locate the EVE Online chat log directory. + + Mirrors ChatLogWatcher.find_log_directory but lives here so the tracker + can be used without instantiating a watcher. + """ + candidates = [ + Path.home() / ".eve" / "logs" / "Chatlogs", + Path.home() + / ".local" + / "share" + / "Steam" + / "steamapps" + / "compatdata" + / "8500" + / "pfx" + / "drive_c" + / "users" + / "steamuser" + / "Documents" + / "EVE" + / "logs" + / "Chatlogs", + Path.home() + / ".steam" + / "steam" + / "steamapps" + / "compatdata" + / "8500" + / "pfx" + / "drive_c" + / "users" + / "steamuser" + / "Documents" + / "EVE" + / "logs" + / "Chatlogs", + Path.home() / "Documents" / "EVE" / "logs" / "Chatlogs", + ] + for path in candidates: + if path.exists() and path.is_dir(): + return path + return None + + +@dataclass +class _FileState: + """Tail-cursor state for one Local log file.""" + + listener: str | None + position: int # next read offset + + +class CharacterLocationTracker(QObject): + """Per-character current-system tracker. + + Polls Local_*.txt files in the EVE chat log directory, parses the + Listener header once per file, then tails each file for + "Channel changed to Local : " lines. + + Signals: + character_system_changed(str, str): (character_name, system) + Emitted when a character moves to a new system. Also fires + once per character on initial detection. + """ + + character_system_changed = Signal(str, str) # char_name, system + + def __init__( + self, + log_directory: Path | None = None, + poll_interval_ms: int = 2000, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self._explicit_log_dir = log_directory + self._log_directory: Path | None = log_directory + self.poll_interval_ms = max(250, int(poll_interval_ms)) + + # Per-file cursor state, keyed by absolute path. + self._file_state: dict[Path, _FileState] = {} + # Authoritative location map: character_name -> current_system + self._locations: dict[str, str] = {} + + self._poll_timer = QTimer(self) + self._poll_timer.timeout.connect(self._poll) + self._running = False + + # ---- Public API -------------------------------------------------------- + def get_system(self, character_name: str) -> str | None: + """Return the last known system for character_name, or None.""" + return self._locations.get(character_name) + + def get_all_locations(self) -> dict[str, str]: + """Snapshot of all known {character_name: system} pairs.""" + return dict(self._locations) + + def start(self) -> None: + """Begin polling for Local log changes.""" + if self._running: + return + if self._log_directory is None: + self._log_directory = find_eve_chat_log_directory() + if self._log_directory is None: + self.logger.warning( + "CharacterLocationTracker: no EVE chat log directory found; tracker will idle." + ) + return + self._running = True + self._poll_timer.start(self.poll_interval_ms) + # Run once immediately so chips populate without a 2s wait + self._poll() + + def stop(self) -> None: + """Stop polling. Safe to call multiple times.""" + if not self._running: + return + self._running = False + self._poll_timer.stop() + + def is_running(self) -> bool: + return self._running + + # ---- Internals --------------------------------------------------------- + def _poll(self) -> None: + """Scan today's Local log files and emit any new system changes.""" + if self._log_directory is None or not self._log_directory.exists(): + return + try: + files = list(self._log_directory.glob(_LOCAL_FILE_GLOB)) + except OSError as e: + self.logger.warning(f"Failed to list log directory: {e}") + return + + for path in files: + try: + self._process_file(path) + except (OSError, UnicodeDecodeError) as e: + self.logger.debug(f"Skipping {path.name}: {e}") + continue + + def _process_file(self, path: Path) -> None: + """Read header (once) and tail the file for new channel-changed events.""" + state = self._file_state.get(path) + if state is None: + listener = self._read_listener(path) + # Start at end-of-file so we don't replay history on first open. + try: + position = path.stat().st_size + except OSError: + return + state = _FileState(listener=listener, position=position) + self._file_state[path] = state + # Optional: also seed initial system from header-adjacent lines? + # Skipped — we wait for the first explicit "Channel changed" line + # so we never claim a stale system. + + if state.listener is None: + return # Cannot map systems to a character without a listener. + + try: + file_size = path.stat().st_size + except OSError: + return + + if file_size < state.position: + # File rotated/truncated. + state.position = 0 + + if file_size == state.position: + return # No new content. + + try: + with open(path, "rb") as fb: + fb.seek(state.position) + raw = fb.read() + state.position = fb.tell() + except OSError as e: + self.logger.debug(f"Read failed for {path.name}: {e}") + return + + try: + text = raw.decode("utf-16-le", errors="ignore") + except UnicodeDecodeError: + return + + for line in text.splitlines(): + match = _CHANNEL_CHANGED_RE.search(line) + if not match: + continue + system = match.group("system").strip() + self._update_location(state.listener, system) + + def _read_listener(self, path: Path) -> str | None: + """Parse the Listener: header from the start of a Local log file.""" + try: + with open(path, "rb") as fb: + raw = fb.read(_HEADER_SCAN_BYTES) + except OSError: + return None + if not raw: + return None + try: + text = raw.decode("utf-16-le", errors="ignore") + except UnicodeDecodeError: + return None + for line in text.splitlines(): + m = _LISTENER_RE.match(line) + if m: + listener = m.group("name").strip() + if listener: + return listener + return None + + def _update_location(self, character_name: str, system: str) -> None: + prev = self._locations.get(character_name) + if prev == system: + return + self._locations[character_name] = system + self.logger.info( + f"CharacterLocationTracker: {character_name} -> {system} (was {prev or 'unknown'})" + ) + self.character_system_changed.emit(character_name, system) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index c440f97..86bfde4 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -1152,6 +1152,10 @@ def __init__(self, character_manager, capture_system, settings_manager=None): # State self.preview_frames: dict[str, WindowPreviewWidget] = {} + # PR4: per-character current system, fed by CharacterLocationTracker. + # Survives window add/remove cycles. Used for chip system labels and + # (in a follow-up PR) smart per-character threat fan-out. + self._character_systems: dict[str, str] = {} self.pending_requests: dict[ str, tuple[str, float] ] = {} # request_id -> (window_id, timestamp) @@ -1232,6 +1236,23 @@ def remove_window(self, window_id: str): self.logger.info(f"Removed window {window_id} from preview") + def set_character_system(self, character_name: str, system: str | None) -> None: + """ + Record the current system for a character. + + Stored on a per-character map (separate from preview_frames) so the + information survives across window add/remove cycles. PR4 uses this + only for storage + future smart fan-out; existing fan-out semantics + (one shared threat state) are unchanged. + """ + if system is None: + self._character_systems.pop(character_name, None) + else: + self._character_systems[character_name] = system + + def get_character_system(self, character_name: str) -> str | None: + return self._character_systems.get(character_name) + def apply_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: """ Fan out an intel threat state to every preview frame. @@ -1445,6 +1466,13 @@ def _sync_status_dock(self) -> None: } self.status_dock.sync_from_window_ids(desired) + # PR4: seed chip system labels from cached per-character state so + # newly-added chips populate without waiting for the next location + # change event. + char_systems = getattr(self.window_manager, "_character_systems", {}) + for char_name, system in char_systems.items(): + self.status_dock.set_character_system(char_name, system) + def _create_toolbar(self) -> QWidget: """Create toolbar using ActionRegistry (v2.3)""" toolbar = QWidget() diff --git a/src/argus_overview/ui/main_window_v21.py b/src/argus_overview/ui/main_window_v21.py index fe67c5d..774b484 100644 --- a/src/argus_overview/ui/main_window_v21.py +++ b/src/argus_overview/ui/main_window_v21.py @@ -161,8 +161,35 @@ def __init__(self): self.auto_discovery.character_gone.connect(self._on_character_gone) self.auto_discovery.start() + # PR4: per-character location tracker (Local channel logs) + self._init_location_tracker() + self.logger.info("Main window v2.2 initialized successfully") + def _init_location_tracker(self) -> None: + """Start the per-character location tracker if enabled.""" + from argus_overview.intel.character_location import CharacterLocationTracker + + enabled = self.settings_manager.get("intel.track_character_locations", True) + if not enabled: + self.location_tracker = None + return + self.location_tracker = CharacterLocationTracker(parent=self) + self.location_tracker.character_system_changed.connect(self._on_character_system_changed) + self.location_tracker.start() + + @Slot(str, str) + def _on_character_system_changed(self, character_name: str, system: str) -> None: + """Forward per-character system updates to the dock + window manager.""" + if not hasattr(self, "main_tab"): + return + wm = getattr(self.main_tab, "window_manager", None) + if wm is not None and hasattr(wm, "set_character_system"): + wm.set_character_system(character_name, system) + dock = getattr(self.main_tab, "status_dock", None) + if dock is not None and hasattr(dock, "set_character_system"): + dock.set_character_system(character_name, system) + def _create_system_tray(self): """Create system tray icon (v2.4 - uses ActionRegistry)""" self.system_tray = SystemTray(self) @@ -1016,6 +1043,15 @@ def closeEvent(self, event: QCloseEvent): if hasattr(self, "auto_discovery"): self.auto_discovery.stop() + if getattr(self, "location_tracker", None) is not None: + try: + self.location_tracker.character_system_changed.disconnect( + self._on_character_system_changed + ) + except (RuntimeError, TypeError): + pass + self.location_tracker.stop() + if hasattr(self, "capture_system"): self.capture_system.stop() diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py index 26b4fd5..4787a1e 100644 --- a/src/argus_overview/ui/status_dock.py +++ b/src/argus_overview/ui/status_dock.py @@ -303,6 +303,24 @@ def set_chip_system(self, window_id: str, system: str | None) -> bool: chip.set_system(system) return True + def set_character_system(self, character_name: str, system: str | None) -> int: + """ + Update every chip for a given character. Returns count updated. + + Multi-boxers can run the same character in multiple windows, so + chips are matched by character_name, not window_id. Source of the + update is typically CharacterLocationTracker. + """ + count = 0 + for chip in list(self._chips.values()): + try: + if chip.character_name == character_name: + chip.set_system(system) + count += 1 + except RuntimeError: + continue + return count + def sync_from_window_ids(self, desired: dict[str, str]) -> tuple[list[str], list[str]]: """ Bulk diff: ensure chips match `desired` mapping of window_id -> name. diff --git a/tests/test_character_location.py b/tests/test_character_location.py new file mode 100644 index 0000000..3428710 --- /dev/null +++ b/tests/test_character_location.py @@ -0,0 +1,475 @@ +"""Tests for CharacterLocationTracker (PR4: per-character system tracking).""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from argus_overview.intel.character_location import ( + CharacterLocationTracker, + find_eve_chat_log_directory, +) + +# Header block as EVE writes it, plus an initial Channel-changed line. +_HEADER_TEMPLATE = ( + "" # BOM stripped by utf-16-le decoding errors=ignore is fine + "---------------------------------------------------------------\r\n" + " Channel ID: Local\r\n" + " Channel Name: Local\r\n" + " Listener: {listener}\r\n" + " Session Started: 2024.01.15 14:30:00\r\n" + "---------------------------------------------------------------\r\n" +) + + +def _write_local_log( + directory: Path, + listener: str, + lines: list[str] | None = None, + suffix: str = "20240115_143000", +) -> Path: + """Write a UTF-16-LE Local log file with header + provided lines.""" + content = _HEADER_TEMPLATE.format(listener=listener) + if lines: + content += "\r\n".join(lines) + "\r\n" + path = directory / f"Local_{suffix}.txt" + # Use 'utf-16' (with BOM) to match real EVE files; tracker uses utf-16-le + # with errors='ignore' which handles either. + path.write_bytes(content.encode("utf-16-le")) + return path + + +def _append_local_log(path: Path, lines: list[str]) -> None: + """Append UTF-16-LE-encoded lines to an existing log file.""" + addition = ("\r\n".join(lines) + "\r\n").encode("utf-16-le") + with open(path, "ab") as f: + f.write(addition) + + +# ============================================================================= +# Helpers +# ============================================================================= + + +class TestFindEveChatLogDirectory: + def test_returns_first_existing(self, tmp_path): + # Manually patch Path.home to a directory layout where a candidate exists. + fake_home = tmp_path / "home" + chatlogs = fake_home / "Documents" / "EVE" / "logs" / "Chatlogs" + chatlogs.mkdir(parents=True) + with patch("argus_overview.intel.character_location.Path.home", return_value=fake_home): + result = find_eve_chat_log_directory() + assert result == chatlogs + + def test_returns_none_when_no_candidates_exist(self, tmp_path): + fake_home = tmp_path / "no-eve" + fake_home.mkdir() + with patch("argus_overview.intel.character_location.Path.home", return_value=fake_home): + result = find_eve_chat_log_directory() + assert result is None + + +# ============================================================================= +# Tracker — initial state +# ============================================================================= + + +class TestCharacterLocationTrackerInit: + def test_initial_state_empty(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + assert tracker.get_all_locations() == {} + assert tracker.get_system("Anyone") is None + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + def test_poll_interval_clamped(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path, poll_interval_ms=10) + try: + assert tracker.poll_interval_ms == 250 + finally: + tracker.deleteLater() + + +# ============================================================================= +# Tracker — header + line parsing +# ============================================================================= + + +class TestCharacterLocationTrackerParsing: + def test_listener_extracted_from_header(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "TestPilot") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + listener = tracker._read_listener(path) + assert listener == "TestPilot" + finally: + tracker.deleteLater() + + def test_listener_returns_none_for_missing_header(self, qapp, tmp_path): + path = tmp_path / "Local_20240115_140000.txt" + path.write_bytes("just chat data\r\n".encode("utf-16-le")) + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + assert tracker._read_listener(path) is None + finally: + tracker.deleteLater() + + def test_listener_with_special_characters(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Pilot O'Connor-Smith") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + assert tracker._read_listener(path) == "Pilot O'Connor-Smith" + finally: + tracker.deleteLater() + + def test_channel_changed_emits_signal(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + # First scan establishes the file at end-of-file: no events. + tracker._poll() + assert received == [] + + _append_local_log( + path, + ["[ 2024.01.15 14:31:00 ] EVE System > Channel changed to Local : Jita"], + ) + tracker._poll() + assert received == [("Alice", "Jita")] + finally: + tracker.deleteLater() + + def test_channel_changed_dedupes_same_system(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log( + path, + [ + "[ 2024.01.15 14:31:00 ] EVE System > Channel changed to Local : Jita", + "[ 2024.01.15 14:32:00 ] EVE System > Channel changed to Local : Jita", + ], + ) + tracker._poll() + assert received == [("Alice", "Jita")] + finally: + tracker.deleteLater() + + def test_channel_changed_emits_on_real_change(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log( + path, + ["[ 2024.01.15 14:31:00 ] EVE System > Channel changed to Local : Jita"], + ) + tracker._poll() + _append_local_log( + path, + ["[ 2024.01.15 14:33:00 ] EVE System > Channel changed to Local : Amarr"], + ) + tracker._poll() + assert received == [("Alice", "Jita"), ("Alice", "Amarr")] + assert tracker.get_system("Alice") == "Amarr" + finally: + tracker.deleteLater() + + def test_unrelated_lines_ignored(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log( + path, + [ + "[ 2024.01.15 14:31:00 ] Bob > o7", + "[ 2024.01.15 14:31:05 ] Carol > anyone home?", + ], + ) + tracker._poll() + assert received == [] + finally: + tracker.deleteLater() + + def test_handles_file_truncation(self, qapp, tmp_path): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker._poll() # Start at end-of-file + # Truncate the file (simulates rotation/recreation) + path.write_bytes(_HEADER_TEMPLATE.format(listener="Alice").encode("utf-16-le")) + # File is now smaller — tracker should reset cursor + tracker._poll() + _append_local_log( + path, + ["[ 2024.01.15 15:00:00 ] EVE System > Channel changed to Local : Dodixie"], + ) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + tracker._poll() + assert received == [("Alice", "Dodixie")] + finally: + tracker.deleteLater() + + +# ============================================================================= +# Tracker — multi-character +# ============================================================================= + + +class TestCharacterLocationTrackerMultiChar: + def test_two_characters_tracked_independently(self, qapp, tmp_path): + a = _write_local_log(tmp_path, "Alice", suffix="20240115_140000") + b = _write_local_log(tmp_path, "Bob", suffix="20240115_140100") + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker._poll() # establish cursors + _append_local_log( + a, + ["[ 2024.01.15 14:30:00 ] EVE System > Channel changed to Local : Jita"], + ) + _append_local_log( + b, + ["[ 2024.01.15 14:30:01 ] EVE System > Channel changed to Local : Amarr"], + ) + tracker._poll() + assert tracker.get_system("Alice") == "Jita" + assert tracker.get_system("Bob") == "Amarr" + assert tracker.get_all_locations() == {"Alice": "Jita", "Bob": "Amarr"} + finally: + tracker.deleteLater() + + def test_files_without_listener_skipped(self, qapp, tmp_path): + # File without a Listener line — tracker should not crash and should + # not emit anything for it. + bad = tmp_path / "Local_20240115_140200.txt" + bad.write_bytes("garbage data\r\n".encode("utf-16-le")) + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker._poll() + tracker._poll() + assert tracker.get_all_locations() == {} + finally: + tracker.deleteLater() + + +# ============================================================================= +# Tracker — lifecycle +# ============================================================================= + + +class TestCharacterLocationTrackerLifecycle: + def test_start_no_directory_idles(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=None) + try: + with patch( + "argus_overview.intel.character_location.find_eve_chat_log_directory", + return_value=None, + ): + tracker.start() + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + def test_start_with_directory_runs(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path, poll_interval_ms=500) + try: + tracker.start() + assert tracker.is_running() is True + tracker.stop() + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + def test_start_idempotent(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker.start() + tracker.start() # should not raise or restart + assert tracker.is_running() is True + tracker.stop() + finally: + tracker.deleteLater() + + def test_stop_idempotent(self, qapp, tmp_path): + tracker = CharacterLocationTracker(log_directory=tmp_path) + try: + tracker.stop() # never started + assert tracker.is_running() is False + finally: + tracker.deleteLater() + + +# ============================================================================= +# StatusDock.set_character_system +# ============================================================================= + + +class TestStatusDockSetCharacterSystem: + def test_updates_only_chips_for_named_character(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.add_chip("0x3", "Alice") # multibox: Alice in two windows + + count = dock.set_character_system("Alice", "Jita") + + assert count == 2 + assert dock._chips["0x1"]._system == "Jita" + assert dock._chips["0x3"]._system == "Jita" + assert dock._chips["0x2"]._system is None + finally: + dock.deleteLater() + + def test_unknown_character_returns_zero(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + count = dock.set_character_system("Nobody", "Jita") + assert count == 0 + finally: + dock.deleteLater() + + +# ============================================================================= +# WindowManager.set_character_system +# ============================================================================= + + +class TestWindowManagerSetCharacterSystem: + def _make_manager(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = {} + return manager + + def test_set_then_get(self): + manager = self._make_manager() + manager.set_character_system("Alice", "Jita") + assert manager.get_character_system("Alice") == "Jita" + + def test_overwrite(self): + manager = self._make_manager() + manager.set_character_system("Alice", "Jita") + manager.set_character_system("Alice", "Amarr") + assert manager.get_character_system("Alice") == "Amarr" + + def test_set_none_clears(self): + manager = self._make_manager() + manager.set_character_system("Alice", "Jita") + manager.set_character_system("Alice", None) + assert manager.get_character_system("Alice") is None + + def test_unknown_character_returns_none(self): + manager = self._make_manager() + assert manager.get_character_system("Nobody") is None + + +# ============================================================================= +# MainWindowV21 wiring +# ============================================================================= + + +class TestMainWindowV21CharacterLocationWiring: + def test_on_character_system_changed_forwards_to_dock_and_manager(self): + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock() + window.main_tab.window_manager = MagicMock() + window.main_tab.status_dock = MagicMock() + + window._on_character_system_changed("Alice", "Jita") + + window.main_tab.window_manager.set_character_system.assert_called_once_with("Alice", "Jita") + window.main_tab.status_dock.set_character_system.assert_called_once_with("Alice", "Jita") + + def test_on_character_system_changed_no_main_tab_safe(self): + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + # No main_tab set — should not raise + window._on_character_system_changed("Alice", "Jita") + + def test_on_character_system_changed_dock_optional(self): + from argus_overview.ui.main_window_v21 import MainWindowV21 + + window = MainWindowV21.__new__(MainWindowV21) + window.main_tab = MagicMock(spec=["window_manager"]) + window.main_tab.window_manager = MagicMock() + + window._on_character_system_changed("Alice", "Jita") + + window.main_tab.window_manager.set_character_system.assert_called_once() + + +# ============================================================================= +# MainTab._sync_status_dock seeding +# ============================================================================= + + +class TestMainTabSyncStatusDockSeedsSystem: + def test_sync_seeds_known_systems_into_chips(self): + from argus_overview.ui.main_tab import MainTab + + tab = MainTab.__new__(MainTab) + tab.logger = MagicMock() + tab.window_manager = MagicMock() + tab.window_manager.preview_frames = {} + tab.window_manager._character_systems = {"Alice": "Jita", "Bob": "Amarr"} + tab.status_dock = MagicMock() + + tab._sync_status_dock() + + # Verify both characters were pushed into the dock for seeding + calls = tab.status_dock.set_character_system.call_args_list + seeded = {(c.args[0], c.args[1]) for c in calls} + assert seeded == {("Alice", "Jita"), ("Bob", "Amarr")} + + +# ============================================================================= +# Additional safety: malformed lines +# ============================================================================= + + +@pytest.mark.parametrize( + "line", + [ + "", + "garbage", + "[ bad timestamp ] EVE System > Channel changed to Local : Jita", + "[ 2024.01.15 14:31:00 ] EVE System > Some other message", + "[ 2024.01.15 14:31:00 ] Player > Channel changed to Local : Jita", + ], +) +def test_channel_changed_regex_rejects_garbage(qapp, tmp_path, line): + path = _write_local_log(tmp_path, "Alice") + tracker = CharacterLocationTracker(log_directory=tmp_path) + received: list[tuple[str, str]] = [] + tracker.character_system_changed.connect(lambda c, s: received.append((c, s))) + try: + tracker._poll() + _append_local_log(path, [line]) + tracker._poll() + assert received == [] + finally: + tracker.deleteLater() diff --git a/tests/test_main_window_v21.py b/tests/test_main_window_v21.py index f155058..b311903 100644 --- a/tests/test_main_window_v21.py +++ b/tests/test_main_window_v21.py @@ -2335,6 +2335,7 @@ def test_init_creates_core_modules(self): stack.enter_context(patch.object(MainWindowV21, "_connect_signals")) stack.enter_context(patch.object(MainWindowV21, "_create_system_tray")) stack.enter_context(patch.object(MainWindowV21, "_register_hotkeys")) + stack.enter_context(patch.object(MainWindowV21, "_init_location_tracker")) stack.enter_context(patch(f"{mod}.QTabWidget")) stack.enter_context(patch(f"{mod}.QVBoxLayout")) stack.enter_context(patch(f"{mod}.QWidget")) From 2e25226c92104745f557d7acb0fcdf5ef24ab770 Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 22:57:15 -0700 Subject: [PATCH 05/10] feat(ui): smart per-character threat fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threat alerts now tint only the frames + chips for characters who are actually in (or who could be in) the affected system. EVE-O Preview can't do this — it has no awareness of which client is in which system. Filter rules (applied in both WindowManager.apply_threat_state and StatusDock.set_threat_state, symmetrically): 1. CLEAR / None level: flush every frame regardless of system. Explicit clears must always win over the filter. 2. system is None / empty: fan to all (legacy behavior — preserves correctness when intel can't attribute the report to a system). 3. Otherwise: only frames whose character's known system matches the alert system. Frames whose character has no known system fall through and still tint (graceful upgrade — until every active character is being tracked, intel must not silently skip frames). Changes: - WindowPreviewWidget._character_system + set_character_system / get_character_system. Set by WindowManager when location changes. - WindowManager.set_character_system now pushes the system to every matching active frame, not just the internal map. - WindowManager.apply_threat_state filters via _character_systems lookup by frame.character_name (deliberately not via frame attr, so bypassed- init test helpers fall through cleanly). - StatusDock.set_threat_state filters via chip._system. CharacterChip already stored its system from PR4, so no chip changes were needed. This is the payoff PR for the four that came before. The composite UX: "hostiles HED-GP +3" fires → only the frame for the character in HED-GP pulses red → the chip for that character lights up red → user double-clicks to spotlight (PR3) → others stay dim and quiet. 17 new tests. Existing PR1/PR4 fan-out tests still pass because mock managers default _character_systems to {}, triggering the legacy fan-to-all path. Suite: 2306 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 75 +++++++- src/argus_overview/ui/status_dock.py | 18 +- tests/test_main_tab.py | 277 +++++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 10 deletions(-) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 86bfde4..b63e022 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -628,6 +628,11 @@ def __init__( self._show_session_timer = False self._load_settings() + # PR4/PR5: known current system for this frame's character. + # Pushed by WindowManager.set_character_system; consumed by + # WindowManager.apply_threat_state for smart fan-out. + self._character_system: str | None = None + # Intel threat state (PR1: intel-aware preview borders) self._threat_level: ThreatLevel | None = None self._threat_system: str | None = None @@ -854,6 +859,13 @@ def leaveEvent(self, event): super().leaveEvent(event) + def set_character_system(self, system: str | None) -> None: + """Record the current system for this frame's character (PR5).""" + self._character_system = system + + def get_character_system(self) -> str | None: + return self._character_system + def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> None: """ Update the intel threat state for this preview frame. @@ -1240,33 +1252,78 @@ def set_character_system(self, character_name: str, system: str | None) -> None: """ Record the current system for a character. - Stored on a per-character map (separate from preview_frames) so the - information survives across window add/remove cycles. PR4 uses this - only for storage + future smart fan-out; existing fan-out semantics - (one shared threat state) are unchanged. + Stored on a per-character map that survives across window add/remove + cycles, AND pushed to every active frame for that character so the + smart fan-out (apply_threat_state) can filter by frame state alone. """ if system is None: self._character_systems.pop(character_name, None) else: self._character_systems[character_name] = system + # Push to active frames whose character matches. + for frame in list(self.preview_frames.values()): + try: + if getattr(frame, "character_name", None) == character_name: + frame.set_character_system(system) + except RuntimeError: + continue + def get_character_system(self, character_name: str) -> str | None: return self._character_systems.get(character_name) def apply_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: """ - Fan out an intel threat state to every preview frame. - - Until per-character system tracking lands, every frame shares the - same state. Returns the count of frames updated for tests + logs. + Fan an intel threat state out to preview frames, filtered by system. + + Filter rules (PR5 smart fan-out): + 1. CLEAR or None level: flush every frame regardless of system. + Explicit clears must always win. + 2. system is None / unknown: fall back to fanning all frames + (legacy behavior — preserves correctness when intel can't + attribute the report to a system). + 3. Otherwise: only frames whose character's known system matches + the alert system. Frames whose character has no known system + fall through and still get tinted (graceful upgrade — until + every active character is being tracked, we don't want intel + to silently skip frames). + + Returns count of frames actually updated for logs + tests. """ + # Rule 1: clear path bypasses filter + if level is None or level == ThreatLevel.CLEAR: + return self._fan_to_all(level, system) + + # Rule 2: no system to filter on + if not system: + return self._fan_to_all(level, system) + + # Rule 3: filter by per-character known system. Lookup goes through + # _character_systems (the canonical map) rather than frame attrs so + # bypassed-init test helpers without per-char data fall through to + # the legacy "tint all" semantics. + char_systems = getattr(self, "_character_systems", {}) or {} + count = 0 + for frame in list(self.preview_frames.values()): + try: + char_name = getattr(frame, "character_name", None) + known = char_systems.get(char_name) if char_name else None + # Known mismatch → skip; unknown or match → apply. + if known is not None and known != system: + continue + frame.set_threat_state(level, system) + count += 1 + except RuntimeError: + continue + return count + + def _fan_to_all(self, level: ThreatLevel | None, system: str | None) -> int: count = 0 for frame in list(self.preview_frames.values()): try: frame.set_threat_state(level, system) count += 1 except RuntimeError: - # Widget already deleted by Qt — skip continue return count diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py index 4787a1e..0c1b647 100644 --- a/src/argus_overview/ui/status_dock.py +++ b/src/argus_overview/ui/status_dock.py @@ -286,10 +286,26 @@ def clear(self) -> None: self.remove_chip(window_id) def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: - """Fan out a single threat state to every chip. Returns count updated.""" + """ + Fan a threat state out to chips, filtered by system (PR5 smart fan-out). + + Mirrors WindowManager.apply_threat_state filter rules: + 1. CLEAR / None level → flush every chip. + 2. system is None / empty → fan to all (legacy fallback). + 3. Otherwise → only chips whose tracked system matches, or whose + system is unknown (graceful upgrade until every chip has a + system from CharacterLocationTracker). + + Returns count of chips updated. + """ count = 0 + flush = level is None or level == ThreatLevel.CLEAR or not system for chip in list(self._chips.values()): try: + if not flush: + chip_system = getattr(chip, "_system", None) + if chip_system is not None and chip_system != system: + continue chip.set_threat_state(level, system) count += 1 except RuntimeError: diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index f96a4e3..5169053 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -9666,3 +9666,280 @@ def test_escape_when_not_focused_passes_through(self): except RuntimeError: pass # Expected: super().keyPressEvent hits bypassed-init widget assert tab._focus_window_id is None + + +# ============================================================================= +# Smart per-character threat fan-out (PR5) +# ============================================================================= + + +def _frame_mock(character_name: str): + """Mock preview frame exposing the character_name + set_threat_state surface.""" + f = MagicMock() + f.character_name = character_name + return f + + +class TestWindowPreviewWidgetCharacterSystem: + """Tests for the per-frame _character_system setter.""" + + def test_set_and_get(self): + from argus_overview.ui.main_tab import WindowPreviewWidget + + with patch.object(WindowPreviewWidget, "__init__", return_value=None): + widget = WindowPreviewWidget.__new__(WindowPreviewWidget) + widget._character_system = None + + widget.set_character_system("Jita") + assert widget.get_character_system() == "Jita" + + widget.set_character_system(None) + assert widget.get_character_system() is None + + +class TestWindowManagerSetCharacterSystemPushesToFrames: + """set_character_system updates the map AND pushes to matching frames.""" + + def _make_manager(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = {} + return manager + + def test_pushes_to_matching_frame_only(self): + manager = self._make_manager() + alice_frame = _frame_mock("Alice") + bob_frame = _frame_mock("Bob") + manager.preview_frames = {"w1": alice_frame, "w2": bob_frame} + + manager.set_character_system("Alice", "Jita") + + alice_frame.set_character_system.assert_called_once_with("Jita") + bob_frame.set_character_system.assert_not_called() + + def test_pushes_to_multiple_frames_for_same_character(self): + manager = self._make_manager() + f1 = _frame_mock("Alice") + f2 = _frame_mock("Alice") + manager.preview_frames = {"w1": f1, "w2": f2} + + manager.set_character_system("Alice", "Jita") + + f1.set_character_system.assert_called_once_with("Jita") + f2.set_character_system.assert_called_once_with("Jita") + + def test_clearing_character_pushes_none_to_frames(self): + manager = self._make_manager() + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + manager._character_systems = {"Alice": "Jita"} + + manager.set_character_system("Alice", None) + + alice.set_character_system.assert_called_once_with(None) + assert "Alice" not in manager._character_systems + + def test_skips_deleted_widgets(self): + manager = self._make_manager() + alive = _frame_mock("Alice") + dead = _frame_mock("Alice") + dead.set_character_system.side_effect = RuntimeError("widget deleted") + manager.preview_frames = {"alive": alive, "dead": dead} + + # Should not raise even though one frame is gone. + manager.set_character_system("Alice", "Jita") + + alive.set_character_system.assert_called_once_with("Jita") + + +class TestWindowManagerApplyThreatStateSmartFanout: + """PR5 smart fan-out filter rules.""" + + def _make_manager(self, char_systems: dict[str, str] | None = None): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = dict(char_systems or {}) + return manager + + def test_clear_flushes_all_regardless_of_system(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita", "Bob": "Amarr"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.CLEAR, "HED-GP") + + assert count == 2 + alice.set_threat_state.assert_called_once_with(ThreatLevel.CLEAR, "HED-GP") + bob.set_threat_state.assert_called_once_with(ThreatLevel.CLEAR, "HED-GP") + + def test_none_level_flushes_all(self): + manager = self._make_manager(char_systems={"Alice": "Jita"}) + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + + count = manager.apply_threat_state(None, "HED-GP") + + assert count == 1 + alice.set_threat_state.assert_called_once() + + def test_no_alert_system_falls_through_to_all(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.WARNING, None) + + assert count == 2 + + def test_empty_alert_system_falls_through_to_all(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita"}) + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "") + + assert count == 1 + + def test_only_matching_character_tints(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "HED-GP", "Bob": "Amarr"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + alice.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") + bob.set_threat_state.assert_not_called() + + def test_unknown_character_falls_through_to_apply(self): + """Graceful upgrade: untracked characters still tint.""" + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "HED-GP"}) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") # Bob has no known system + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 2 + alice.set_threat_state.assert_called_once() + bob.set_threat_state.assert_called_once() + + def test_no_per_char_data_fans_to_all_legacy_behavior(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={}) + f1 = _frame_mock("Alice") + f2 = _frame_mock("Bob") + manager.preview_frames = {"w1": f1, "w2": f2} + + count = manager.apply_threat_state(ThreatLevel.WARNING, "HED-GP") + + assert count == 2 + + def test_skips_deleted_widgets(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "HED-GP"}) + alive = _frame_mock("Alice") + dead = _frame_mock("Alice") + dead.set_threat_state.side_effect = RuntimeError("widget deleted") + manager.preview_frames = {"alive": alive, "dead": dead} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + + +class TestStatusDockSmartFanout: + """PR5 smart fan-out applied to chips via their _system attribute.""" + + def test_only_matching_chips_tint(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "HED-GP") + dock.set_character_system("Bob", "Amarr") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + assert dock._chips["0x1"]._threat_level == ThreatLevel.DANGER + assert dock._chips["0x2"]._threat_level is None + finally: + dock.deleteLater() + + def test_unknown_chip_falls_through(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") # Bob has no system + dock.set_character_system("Alice", "HED-GP") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + # Both tint: Alice matches, Bob unknown → graceful fallback. + assert count == 2 + finally: + dock.deleteLater() + + def test_clear_bypasses_filter(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "HED-GP") + dock.set_character_system("Bob", "Amarr") + # Tint both first + dock._chips["0x1"].set_threat_state(ThreatLevel.DANGER, "HED-GP") + dock._chips["0x2"].set_threat_state(ThreatLevel.WARNING, "Amarr") + + count = dock.set_threat_state(ThreatLevel.CLEAR, "HED-GP") + + assert count == 2 + for chip in dock._chips.values(): + assert chip._threat_level is None + finally: + dock.deleteLater() + + def test_no_system_fans_to_all(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "Jita") + dock.set_character_system("Bob", "Amarr") + + count = dock.set_threat_state(ThreatLevel.WARNING, None) + assert count == 2 + finally: + dock.deleteLater() From 2319f81865f9e85dcca6a591bbab3bb7471f77c8 Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 23:15:01 -0700 Subject: [PATCH 06/10] feat(intel): jumps-from threat fan-out with adjacency falloff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smart fan-out (PR5) only tinted exact-system matches. Real fleet awareness needs adjacency: a hostile one jump from your character is still a meaningful threat. This PR extends the filter with JumpCalculator and a per-jump alpha falloff so adjacent-system alerts tint at reduced intensity instead of being silently skipped. Algorithm (intel/threat_filter.resolve_tint): - Same system: should=True, alpha=1.0 - Unknown character location OR no alert system: should=True, alpha=1.0 (graceful fallback) - With JumpCalculator + max_jumps>0: distance == None or > max_jumps → should=False otherwise → alpha = max(0.4, 0.5 ** distance) (1 jump = 0.5, 2 jumps clamped to 0.4 floor) - Without calculator OR max_jumps=0: PR5 exact-match-only behavior Changes: - intel/threat_filter.py — new module, the shared filter helper used by both WindowManager and StatusDock so the rules can't drift between the preview grid and the chip strip. - WindowPreviewWidget.set_threat_state(level, system, initial_alpha=1.0): honors an explicit initial alpha, clamped to [0, 1]. The pulse animation now also gates on initial_alpha >= 0.9 — distant alerts shouldn't pulse, just glow. - WindowManager + StatusDock both gain set_jump_calculator(calc, max_jumps=1). apply_threat_state / set_threat_state route the known-mismatch branch through resolve_tint and pass the resulting alpha to the widget. - MainWindowV21._init_threat_jump_filter constructs one shared JumpCalculator and injects it into both targets, gated on intel.threat_jumps_threshold (default 1, 0 disables). Backward compat: - Bypassed-init test helpers without _jump_calculator/_jump_max default to PR5 behavior (exact-match-only). - Two existing fan-out assertions updated to expect initial_alpha=1.0 kwarg (the old 2-arg call shape became 3-arg). The composite UX upgrade: "hostiles HED-GP +3" fires → character in HED-GP pulses red at full intensity → characters one jump away (Sosala, 4-EP12, etc.) tint orange at half intensity but don't pulse → distant characters stay calm. EVE-O Preview can't do any tier of this. 25 new tests (11 helper, 5 widget initial_alpha, 5 manager + 4 dock adjacency behavior). Suite: 2331 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/intel/threat_filter.py | 75 ++++++ src/argus_overview/ui/main_tab.py | 86 ++++--- src/argus_overview/ui/main_window_v21.py | 23 ++ src/argus_overview/ui/status_dock.py | 44 +++- tests/test_main_tab.py | 264 +++++++++++++++++++++- tests/test_threat_filter.py | 111 +++++++++ 6 files changed, 559 insertions(+), 44 deletions(-) create mode 100644 src/argus_overview/intel/threat_filter.py create mode 100644 tests/test_threat_filter.py diff --git a/src/argus_overview/intel/threat_filter.py b/src/argus_overview/intel/threat_filter.py new file mode 100644 index 0000000..6b9220b --- /dev/null +++ b/src/argus_overview/intel/threat_filter.py @@ -0,0 +1,75 @@ +""" +Threat fan-out filter — resolves whether a frame/chip should tint for an +incoming intel alert, and at what intensity. + +Used by both WindowManager.apply_threat_state and StatusDock.set_threat_state +so the per-character tinting rules stay symmetric across the preview grid +and the chip strip. + +PR6 of the intel-aware UI uplift. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from argus_overview.intel.jumps import JumpCalculator + + +# Alpha falloff: same-system = 1.0, each additional jump multiplies by this +# until we hit the floor or exceed max_jumps. +_PER_JUMP_FALLOFF = 0.5 +# Absolute floor for adjacent-system alerts so they remain visible. +_MIN_ADJACENT_ALPHA = 0.4 + + +def resolve_tint( + known_system: str | None, + alert_system: str | None, + jump_calculator: JumpCalculator | None = None, + max_jumps: int = 0, +) -> tuple[bool, float]: + """ + Decide whether a frame/chip should tint for an alert, and at what alpha. + + Args: + known_system: The character's last-known current system. None if + we don't have a per-character location yet. + alert_system: The system the intel report refers to. None if intel + couldn't attribute the report to a system. + jump_calculator: Optional graph for adjacency lookups. When None, + only exact matches and the unknown-fallthrough rules apply. + max_jumps: Maximum jump distance to consider "near". Zero means + exact-match only (PR5 behavior). + + Returns: + (should_apply, alpha) + should_apply: True if the frame/chip should call set_threat_state + alpha: Initial alpha in [0.0, 1.0]; same-system = 1.0, + each jump out scales down by _PER_JUMP_FALLOFF + with a floor of _MIN_ADJACENT_ALPHA. + """ + # No known location → graceful fallback (apply at full intensity). + if known_system is None: + return True, 1.0 + + # No alert system to match against → fallback (apply at full). + if not alert_system: + return True, 1.0 + + # Same system → full intensity, no calculator needed. + if known_system.lower() == alert_system.lower(): + return True, 1.0 + + # Need a calculator + non-zero threshold to consider adjacency. + if jump_calculator is None or max_jumps <= 0: + return False, 0.0 + + distance = jump_calculator.distance(known_system, alert_system) + if distance is None or distance > max_jumps: + return False, 0.0 + + # Adjacent within threshold — scale alpha down by distance. + alpha = max(_MIN_ADJACENT_ALPHA, _PER_JUMP_FALLOFF**distance) + return True, alpha diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index b63e022..7e8122c 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -866,13 +866,22 @@ def set_character_system(self, system: str | None) -> None: def get_character_system(self) -> str | None: return self._character_system - def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> None: + def set_threat_state( + self, + level: ThreatLevel | None, + system: str | None = None, + initial_alpha: float = 1.0, + ) -> None: """ Update the intel threat state for this preview frame. Args: level: Threat level. None or CLEAR clears the border. system: System name the threat refers to (kept for tooltip + dock). + initial_alpha: Starting alpha [0.0, 1.0] for the border. Defaults + to 1.0 (full intensity for same-system alerts). Lower values + are used by WindowManager when fanning to characters in + adjacent systems via the jumps-from filter (PR6). """ prev_level = self._threat_level @@ -886,13 +895,18 @@ def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) self._threat_level = level self._threat_system = system - self._threat_alpha = 1.0 + self._threat_alpha = max(0.0, min(1.0, initial_alpha)) - # Pulse on upgrade into danger/critical + # Pulse on upgrade into danger/critical (only at full-ish intensity — + # don't pulse for distant adjacent-system alerts). upgraded = prev_level is None or THREAT_LEVEL_RANK.get( prev_level, 0 ) < THREAT_LEVEL_RANK.get(level, 0) - if upgraded and level in (ThreatLevel.DANGER, ThreatLevel.CRITICAL): + if ( + upgraded + and level in (ThreatLevel.DANGER, ThreatLevel.CRITICAL) + and initial_alpha >= 0.9 + ): self._start_pulse() # Restart decay timer @@ -1168,6 +1182,11 @@ def __init__(self, character_manager, capture_system, settings_manager=None): # Survives window add/remove cycles. Used for chip system labels and # (in a follow-up PR) smart per-character threat fan-out. self._character_systems: dict[str, str] = {} + # PR6: optional jump calculator + max-jumps threshold for the + # adjacency-aware fan-out. Default max_jumps=0 preserves the PR5 + # exact-match-only filter when no calculator is wired up. + self._jump_calculator = None # type: ignore[var-annotated] + self._jump_max: int = 0 self.pending_requests: dict[ str, tuple[str, float] ] = {} # request_id -> (window_id, timestamp) @@ -1272,46 +1291,55 @@ def set_character_system(self, character_name: str, system: str | None) -> None: def get_character_system(self, character_name: str) -> str | None: return self._character_systems.get(character_name) + def set_jump_calculator(self, calculator, max_jumps: int = 1) -> None: + """ + Wire an adjacency calculator for the jumps-from threat filter (PR6). + + Args: + calculator: A JumpCalculator (or None to disable adjacency). + max_jumps: Maximum jump distance to consider "near"; 0 disables + adjacency tinting (exact-match-only). Default 1. + """ + self._jump_calculator = calculator + self._jump_max = max(0, int(max_jumps)) + def apply_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: """ Fan an intel threat state out to preview frames, filtered by system. - Filter rules (PR5 smart fan-out): - 1. CLEAR or None level: flush every frame regardless of system. - Explicit clears must always win. - 2. system is None / unknown: fall back to fanning all frames - (legacy behavior — preserves correctness when intel can't - attribute the report to a system). - 3. Otherwise: only frames whose character's known system matches - the alert system. Frames whose character has no known system - fall through and still get tinted (graceful upgrade — until - every active character is being tracked, we don't want intel - to silently skip frames). - - Returns count of frames actually updated for logs + tests. + Filter rules: + 1. CLEAR / None level → flush every frame regardless of system. + 2. system is None / empty → fan to all (legacy fallback). + 3. Otherwise → resolve_tint() decides per-frame: same-system at + full alpha, adjacent within max_jumps at falloff alpha, + beyond threshold skipped, unknown character system tinted at + full alpha (graceful upgrade). + + Returns count of frames actually updated. """ - # Rule 1: clear path bypasses filter - if level is None or level == ThreatLevel.CLEAR: - return self._fan_to_all(level, system) + from argus_overview.intel.threat_filter import resolve_tint - # Rule 2: no system to filter on - if not system: + # Rule 1 + 2: explicit-flush branches + if level is None or level == ThreatLevel.CLEAR or not system: return self._fan_to_all(level, system) - # Rule 3: filter by per-character known system. Lookup goes through - # _character_systems (the canonical map) rather than frame attrs so - # bypassed-init test helpers without per-char data fall through to - # the legacy "tint all" semantics. char_systems = getattr(self, "_character_systems", {}) or {} + calculator = getattr(self, "_jump_calculator", None) + max_jumps = getattr(self, "_jump_max", 0) count = 0 for frame in list(self.preview_frames.values()): try: char_name = getattr(frame, "character_name", None) known = char_systems.get(char_name) if char_name else None - # Known mismatch → skip; unknown or match → apply. - if known is not None and known != system: + should_apply, alpha = resolve_tint( + known_system=known, + alert_system=system, + jump_calculator=calculator, + max_jumps=max_jumps, + ) + if not should_apply: continue - frame.set_threat_state(level, system) + frame.set_threat_state(level, system, initial_alpha=alpha) count += 1 except RuntimeError: continue diff --git a/src/argus_overview/ui/main_window_v21.py b/src/argus_overview/ui/main_window_v21.py index 774b484..d8dd4ae 100644 --- a/src/argus_overview/ui/main_window_v21.py +++ b/src/argus_overview/ui/main_window_v21.py @@ -178,6 +178,29 @@ def _init_location_tracker(self) -> None: self.location_tracker.character_system_changed.connect(self._on_character_system_changed) self.location_tracker.start() + # PR6: wire one shared JumpCalculator into both threat fan-out paths + # so adjacent-system alerts also tint at reduced intensity. max_jumps + # is gated on intel.threat_jumps_threshold (default 1). + self._init_threat_jump_filter() + + def _init_threat_jump_filter(self) -> None: + """Wire a shared JumpCalculator into the manager + dock fan-out.""" + from argus_overview.intel.jumps import JumpCalculator + + max_jumps = int(self.settings_manager.get("intel.threat_jumps_threshold", 1)) + if max_jumps <= 0: + self.jump_calculator = None + return + self.jump_calculator = JumpCalculator() + if not hasattr(self, "main_tab"): + return + wm = getattr(self.main_tab, "window_manager", None) + if wm is not None and hasattr(wm, "set_jump_calculator"): + wm.set_jump_calculator(self.jump_calculator, max_jumps=max_jumps) + dock = getattr(self.main_tab, "status_dock", None) + if dock is not None and hasattr(dock, "set_jump_calculator"): + dock.set_jump_calculator(self.jump_calculator, max_jumps=max_jumps) + @Slot(str, str) def _on_character_system_changed(self, character_name: str, system: str) -> None: """Forward per-character system updates to the dock + window manager.""" diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py index 0c1b647..f30b28f 100644 --- a/src/argus_overview/ui/status_dock.py +++ b/src/argus_overview/ui/status_dock.py @@ -226,6 +226,10 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.logger = logging.getLogger(__name__) self._chips: dict[str, CharacterChip] = {} + # PR6 jumps-from filter — set via set_jump_calculator. Default + # max_jumps=0 keeps the PR5 exact-match-only behavior. + self._jump_calculator = None + self._jump_max: int = 0 self.setFixedHeight(56) outer = QVBoxLayout(self) @@ -285,28 +289,46 @@ def clear(self) -> None: for window_id in list(self._chips.keys()): self.remove_chip(window_id) + def set_jump_calculator(self, calculator, max_jumps: int = 1) -> None: + """Wire an adjacency calculator for the jumps-from filter (PR6).""" + self._jump_calculator = calculator + self._jump_max = max(0, int(max_jumps)) + def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) -> int: """ - Fan a threat state out to chips, filtered by system (PR5 smart fan-out). + Fan a threat state out to chips, filtered by system. - Mirrors WindowManager.apply_threat_state filter rules: + Filter rules (mirror WindowManager.apply_threat_state for symmetry): 1. CLEAR / None level → flush every chip. 2. system is None / empty → fan to all (legacy fallback). - 3. Otherwise → only chips whose tracked system matches, or whose - system is unknown (graceful upgrade until every chip has a - system from CharacterLocationTracker). + 3. Otherwise → resolve_tint() per chip. Same-system at full alpha, + adjacent within max_jumps at falloff alpha, beyond skipped, + unknown chip-system tinted at full alpha (graceful upgrade). Returns count of chips updated. """ - count = 0 + from argus_overview.intel.threat_filter import resolve_tint + flush = level is None or level == ThreatLevel.CLEAR or not system + count = 0 + calculator = getattr(self, "_jump_calculator", None) + max_jumps = getattr(self, "_jump_max", 0) for chip in list(self._chips.values()): try: - if not flush: - chip_system = getattr(chip, "_system", None) - if chip_system is not None and chip_system != system: - continue - chip.set_threat_state(level, system) + if flush: + chip.set_threat_state(level, system) + count += 1 + continue + chip_system = getattr(chip, "_system", None) + should_apply, alpha = resolve_tint( + known_system=chip_system, + alert_system=system, + jump_calculator=calculator, + max_jumps=max_jumps, + ) + if not should_apply: + continue + chip.set_threat_state(level, system, alpha=alpha) count += 1 except RuntimeError: continue diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index 5169053..9d81171 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -9260,9 +9260,17 @@ def test_apply_threat_state_fans_out_to_all_frames(self): count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") assert count == 3 - f1.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") - f2.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") - f3.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") + # PR6: smart-filter branch passes initial_alpha=1.0 explicitly when + # the per-character system is unknown (graceful fallback). + f1.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ) + f2.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ) + f3.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ) def test_apply_threat_state_empty_frames(self): from argus_overview.intel.parser import ThreatLevel @@ -9823,7 +9831,9 @@ def test_only_matching_character_tints(self): count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") assert count == 1 - alice.set_threat_state.assert_called_once_with(ThreatLevel.DANGER, "HED-GP") + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ) bob.set_threat_state.assert_not_called() def test_unknown_character_falls_through_to_apply(self): @@ -9943,3 +9953,249 @@ def test_no_system_fans_to_all(self, qapp): assert count == 2 finally: dock.deleteLater() + + +# ============================================================================= +# Jumps-from threat fan-out (PR6) +# ============================================================================= + + +class TestWindowPreviewWidgetInitialAlpha: + """set_threat_state honors the initial_alpha param.""" + + def _make_widget(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + + def test_default_alpha_is_one(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "Jita") + assert widget._threat_alpha == 1.0 + finally: + widget.deleteLater() + + def test_explicit_alpha_applied(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "Jita", initial_alpha=0.5) + assert widget._threat_alpha == 0.5 + finally: + widget.deleteLater() + + def test_alpha_clamped_to_unit_range(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "Jita", initial_alpha=2.5) + assert widget._threat_alpha == 1.0 + widget.set_threat_state(ThreatLevel.WARNING, "Jita", initial_alpha=-0.5) + assert widget._threat_alpha == 0.0 + finally: + widget.deleteLater() + + def test_pulse_skipped_for_low_alpha_alerts(self, qapp): + """Adjacent-system alerts (low alpha) should NOT pulse.""" + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.DANGER, "Jita", initial_alpha=0.5) + assert widget._pulse_phase == 0.0 + finally: + widget.deleteLater() + + def test_pulse_fires_for_full_alpha_danger(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.DANGER, "Jita", initial_alpha=1.0) + assert widget._pulse_phase == 1.0 + finally: + widget.deleteLater() + + +def _calc_mock(distances: dict[tuple[str, str], int | None]): + """Build a MagicMock JumpCalculator from a (from, to) -> distance map.""" + calc = MagicMock() + + def _distance(a, b): + return distances.get((a, b)) or distances.get((b, a)) + + calc.distance.side_effect = _distance + return calc + + +class TestWindowManagerJumpsFromFanout: + """WindowManager.apply_threat_state with jumps-from filter.""" + + def _make_manager( + self, + char_systems: dict[str, str], + max_jumps: int = 0, + distances: dict[tuple[str, str], int | None] | None = None, + ): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = dict(char_systems) + manager._jump_calculator = _calc_mock(distances or {}) if distances else None + manager._jump_max = max_jumps + return manager + + def test_set_jump_calculator_stores_calculator_and_max(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + calc = MagicMock() + + manager.set_jump_calculator(calc, max_jumps=3) + + assert manager._jump_calculator is calc + assert manager._jump_max == 3 + + def test_set_jump_calculator_clamps_negative_max(self): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.set_jump_calculator(MagicMock(), max_jumps=-5) + + assert manager._jump_max == 0 + + def test_adjacent_character_tinted_with_falloff_alpha(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager( + char_systems={"Alice": "Jita", "Bob": "HED-GP"}, + max_jumps=2, + distances={("Jita", "HED-GP"): 1, ("HED-GP", "HED-GP"): 0}, + ) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 2 + bob.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ) + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5 + ) + + def test_beyond_threshold_skipped(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager( + char_systems={"Alice": "Jita"}, + max_jumps=1, + distances={("Jita", "HED-GP"): 4}, + ) + alice = _frame_mock("Alice") + manager.preview_frames = {"w1": alice} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 0 + alice.set_threat_state.assert_not_called() + + def test_no_calculator_keeps_pr5_exact_match_only(self): + from argus_overview.intel.parser import ThreatLevel + + manager = self._make_manager(char_systems={"Alice": "Jita", "Bob": "HED-GP"}, max_jumps=0) + alice = _frame_mock("Alice") + bob = _frame_mock("Bob") + manager.preview_frames = {"w1": alice, "w2": bob} + + count = manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + bob.set_threat_state.assert_called_once() + alice.set_threat_state.assert_not_called() + + +class TestStatusDockJumpsFromFanout: + """StatusDock.set_threat_state with jumps-from filter.""" + + def test_set_jump_calculator_stores_calculator_and_max(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = MagicMock() + dock.set_jump_calculator(calc, max_jumps=2) + assert dock._jump_calculator is calc + assert dock._jump_max == 2 + finally: + dock.deleteLater() + + def test_adjacent_chip_tinted_with_falloff_alpha(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = _calc_mock({("Jita", "HED-GP"): 1}) + dock.set_jump_calculator(calc, max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "Jita") + dock.set_character_system("Bob", "HED-GP") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 2 + assert dock._chips["0x2"]._threat_alpha == 1.0 + assert dock._chips["0x1"]._threat_alpha == 0.5 + finally: + dock.deleteLater() + + def test_beyond_threshold_chip_skipped(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = _calc_mock({("Jita", "HED-GP"): 5}) + dock.set_jump_calculator(calc, max_jumps=1) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "Jita") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 0 + assert dock._chips["0x1"]._threat_level is None + finally: + dock.deleteLater() + + def test_no_calculator_keeps_pr5_behavior(self, qapp): + from argus_overview.intel.parser import ThreatLevel + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.add_chip("0x1", "Alice") + dock.add_chip("0x2", "Bob") + dock.set_character_system("Alice", "Jita") + dock.set_character_system("Bob", "HED-GP") + + count = dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert count == 1 + assert dock._chips["0x2"]._threat_level is not None + assert dock._chips["0x1"]._threat_level is None + finally: + dock.deleteLater() diff --git a/tests/test_threat_filter.py b/tests/test_threat_filter.py new file mode 100644 index 0000000..9329b9e --- /dev/null +++ b/tests/test_threat_filter.py @@ -0,0 +1,111 @@ +"""Tests for intel/threat_filter.resolve_tint helper (PR6).""" + +from unittest.mock import MagicMock + +from argus_overview.intel.threat_filter import resolve_tint + + +class TestResolveTintExplicitFlush: + """Cases where the helper falls through to apply at full alpha.""" + + def test_unknown_character_falls_through_full(self): + should, alpha = resolve_tint(known_system=None, alert_system="HED-GP") + assert should is True + assert alpha == 1.0 + + def test_no_alert_system_falls_through_full(self): + should, alpha = resolve_tint(known_system="Jita", alert_system=None) + assert should is True + assert alpha == 1.0 + + def test_empty_alert_system_falls_through_full(self): + should, alpha = resolve_tint(known_system="Jita", alert_system="") + assert should is True + assert alpha == 1.0 + + +class TestResolveTintExactMatch: + def test_same_system_full_alpha(self): + should, alpha = resolve_tint(known_system="HED-GP", alert_system="HED-GP") + assert should is True + assert alpha == 1.0 + + def test_case_insensitive_match(self): + should, alpha = resolve_tint(known_system="hed-gp", alert_system="HED-GP") + assert should is True + assert alpha == 1.0 + + +class TestResolveTintAdjacency: + """Cases that need a JumpCalculator + non-zero max_jumps.""" + + def test_no_calculator_skips_mismatch(self): + should, alpha = resolve_tint( + known_system="Amarr", alert_system="HED-GP", jump_calculator=None + ) + assert should is False + assert alpha == 0.0 + + def test_zero_max_jumps_skips_mismatch_even_with_calculator(self): + calc = MagicMock() + calc.distance.return_value = 1 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=0, + ) + assert should is False + # Calculator should NOT have been queried — max_jumps=0 short-circuits + calc.distance.assert_not_called() + + def test_one_jump_within_threshold_uses_falloff_alpha(self): + calc = MagicMock() + calc.distance.return_value = 1 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=2, + ) + assert should is True + # 0.5 ** 1 = 0.5 (above floor 0.4) + assert alpha == 0.5 + + def test_two_jumps_within_threshold_uses_floor_alpha(self): + calc = MagicMock() + calc.distance.return_value = 2 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=2, + ) + assert should is True + # 0.5 ** 2 = 0.25 → clamped to 0.4 floor + assert alpha == 0.4 + + def test_distance_beyond_threshold_skipped(self): + calc = MagicMock() + calc.distance.return_value = 3 + should, alpha = resolve_tint( + known_system="Amarr", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=2, + ) + assert should is False + assert alpha == 0.0 + + def test_unknown_distance_skipped(self): + """JumpCalculator returns None when graph lookup fails.""" + calc = MagicMock() + calc.distance.return_value = None + should, alpha = resolve_tint( + known_system="Unknown", + alert_system="HED-GP", + jump_calculator=calc, + max_jumps=5, + ) + assert should is False + assert alpha == 0.0 From 4785c66282065b9d48f8a19dd3eba08a49e80a30 Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 23:22:59 -0700 Subject: [PATCH 07/10] feat(ui): "+Nj" distance badge on adjacent-tinted chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop on PR6's adjacency UX. Before this PR, a chip dimmed at 50% red gave you no signal about *why* it was dim — was the alert weak, or was the character far from it? Now adjacent-system alerts paint a "+Nj" badge in the threat color next to the dot, and the tooltip explicitly says "warning (1j away)". Changes: - CharacterChip._threat_distance: int | None — set by the dock during smart fan-out, drawn in paintEvent as bold 7pt text just left of the threat dot. Zero or None → no badge (same-system). - CharacterChip.set_threat_state gains distance kwarg. Tooltip now appends "(Nj away)" when distance > 0. - StatusDock.set_threat_state queries jump_calculator.distance for chips whose alpha came back < 1.0 from resolve_tint, then passes through. Errors swallowed (calculator misconfig shouldn't crash the alert path). - intel/threat_filter.resolve_tint: hardened to swallow AttributeError/TypeError/ValueError from calculator.distance and return (False, 0.0) — same defensive policy. 12 new tests: TestCharacterChipDistanceBadge: state, paint smoke, tooltip TestStatusDockPassesDistanceToChip: adjacency wiring, error swallow Suite: 2343 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/intel/threat_filter.py | 6 +- src/argus_overview/ui/status_dock.py | 70 +++++++++- tests/test_status_dock.py | 158 ++++++++++++++++++++++ 3 files changed, 227 insertions(+), 7 deletions(-) diff --git a/src/argus_overview/intel/threat_filter.py b/src/argus_overview/intel/threat_filter.py index 6b9220b..486d57c 100644 --- a/src/argus_overview/intel/threat_filter.py +++ b/src/argus_overview/intel/threat_filter.py @@ -66,7 +66,11 @@ def resolve_tint( if jump_calculator is None or max_jumps <= 0: return False, 0.0 - distance = jump_calculator.distance(known_system, alert_system) + try: + distance = jump_calculator.distance(known_system, alert_system) + except (AttributeError, TypeError, ValueError): + # Calculator misconfigured / corrupt — treat as unknown distance. + return False, 0.0 if distance is None or distance > max_jumps: return False, 0.0 diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py index f30b28f..6a5081d 100644 --- a/src/argus_overview/ui/status_dock.py +++ b/src/argus_overview/ui/status_dock.py @@ -84,6 +84,9 @@ def __init__( self._system: str | None = None self._threat_level: ThreatLevel | None = None self._threat_alpha: float = 0.0 + # PR7: jumps from this chip's character to the alert system. None when + # same-system or unknown; positive int for adjacent. Renders as +Nj. + self._threat_distance: int | None = None self.setFixedHeight(40) self.setMinimumWidth(160) @@ -157,7 +160,10 @@ def _tooltip_text(self) -> str: if self._system: parts.append(f"System: {self._system}") if self._threat_level is not None: - parts.append(f"Threat: {self._threat_level.value}") + line = f"Threat: {self._threat_level.value}" + if self._threat_distance and self._threat_distance > 0: + line += f" ({self._threat_distance}j away)" + parts.append(line) parts.append("Click to focus window") return "\n".join(parts) @@ -168,14 +174,32 @@ def set_system(self, system: str | None) -> None: self.setToolTip(self._tooltip_text()) def set_threat_state( - self, level: ThreatLevel | None, system: str | None = None, alpha: float = 1.0 + self, + level: ThreatLevel | None, + system: str | None = None, + alpha: float = 1.0, + distance: int | None = None, ) -> None: + """ + Update threat state for this chip. + + Args: + level: Threat level. None or CLEAR clears state. + system: System the alert refers to. + alpha: Initial alpha [0, 1] for the threat dot. PR6 falloff for + adjacent-system alerts uses < 1.0. + distance: Jumps from this chip's character to the alert system. + None for same-system or unknown. Positive ints render as + "+Nj" badge next to the threat dot (PR7). + """ if level is None or level == ThreatLevel.CLEAR: self._threat_level = None self._threat_alpha = 0.0 + self._threat_distance = None else: self._threat_level = level self._threat_alpha = max(0.0, min(1.0, alpha)) + self._threat_distance = distance if distance and distance > 0 else None if system is not None: self.set_system(system) else: @@ -204,9 +228,31 @@ def paintEvent(self, event): painter.setPen(QPen(color.darker(140), 1)) painter.setBrush(QBrush(color)) # Draw threat dot vertically centered, left of the right edge - x = self.width() - self.DOT_SIZE - 8 - y = (self.height() - self.DOT_SIZE) // 2 - painter.drawEllipse(x, y, self.DOT_SIZE, self.DOT_SIZE) + dot_x = self.width() - self.DOT_SIZE - 8 + dot_y = (self.height() - self.DOT_SIZE) // 2 + painter.drawEllipse(dot_x, dot_y, self.DOT_SIZE, self.DOT_SIZE) + + # PR7: distance badge for adjacent-system alerts. + # Renders as "+Nj" just left of the threat dot in the same color. + if self._threat_distance and self._threat_distance > 0: + from PySide6.QtGui import QFont + + badge_text = f"+{self._threat_distance}j" + font = painter.font() + badge_font = QFont(font) + badge_font.setPointSize(7) + badge_font.setBold(True) + painter.setFont(badge_font) + # Foreground stays in the threat color but bumped opaque so + # it stays legible even when the dot itself is dim. + text_color = QColor(r, g, b, max(180, alpha)) + painter.setPen(QPen(text_color)) + metrics = painter.fontMetrics() + text_w = metrics.horizontalAdvance(badge_text) + # Place to the left of the dot, vertically centered. + text_x = dot_x - text_w - 3 + text_y = (self.height() + metrics.ascent() - metrics.descent()) // 2 + painter.drawText(text_x, text_y, badge_text) finally: painter.end() @@ -328,7 +374,19 @@ def set_threat_state(self, level: ThreatLevel | None, system: str | None = None) ) if not should_apply: continue - chip.set_threat_state(level, system, alpha=alpha) + # PR7: surface the jump distance for the +Nj badge. + distance: int | None = None + if ( + alpha < 1.0 + and chip_system + and calculator is not None + and chip_system.lower() != system.lower() + ): + try: + distance = calculator.distance(chip_system, system) + except (AttributeError, TypeError, ValueError): + distance = None + chip.set_threat_state(level, system, alpha=alpha, distance=distance) count += 1 except RuntimeError: continue diff --git a/tests/test_status_dock.py b/tests/test_status_dock.py index bfd5464..9e3cb70 100644 --- a/tests/test_status_dock.py +++ b/tests/test_status_dock.py @@ -405,3 +405,161 @@ def test_dock_optional_does_not_crash_when_missing(self): # Should not raise even though main_tab has no status_dock attr window._on_intel_alert(report, AlertType.VISUAL_BORDER) window.main_tab.window_manager.apply_threat_state.assert_called_once() + + +# ============================================================================= +# Distance badge (PR7) +# ============================================================================= + + +class TestCharacterChipDistanceBadge: + """+Nj badge state on the chip.""" + + def test_default_distance_is_none(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + assert chip._threat_distance is None + finally: + chip.deleteLater() + + def test_distance_stored_when_positive(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=2) + assert chip._threat_distance == 2 + finally: + chip.deleteLater() + + def test_zero_distance_treated_as_none(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + # Same-system case: caller passes distance=0; chip stores None + chip.set_threat_state(ThreatLevel.DANGER, "HED-GP", alpha=1.0, distance=0) + assert chip._threat_distance is None + finally: + chip.deleteLater() + + def test_clear_resets_distance(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=2) + chip.set_threat_state(ThreatLevel.CLEAR) + assert chip._threat_distance is None + finally: + chip.deleteLater() + + def test_paint_with_distance_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=1) + chip.repaint() + finally: + chip.deleteLater() + + def test_paint_with_no_distance_does_not_crash(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.resize(200, 40) + chip.set_threat_state(ThreatLevel.DANGER, "HED-GP", alpha=1.0) + chip.repaint() + finally: + chip.deleteLater() + + def test_tooltip_includes_distance(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("Jita") + chip.set_threat_state(ThreatLevel.WARNING, "HED-GP", alpha=0.5, distance=2) + tip = chip.toolTip() + assert "2j away" in tip + finally: + chip.deleteLater() + + def test_tooltip_omits_distance_for_same_system(self, qapp): + chip = CharacterChip("0xABC", "TestPilot") + try: + chip.set_system("HED-GP") + chip.set_threat_state(ThreatLevel.DANGER, "HED-GP", alpha=1.0) + tip = chip.toolTip() + assert "j away" not in tip + finally: + chip.deleteLater() + + +class TestStatusDockPassesDistanceToChip: + """Dock fan-out queries calculator.distance and passes it to chips.""" + + def _make_calc(self, distance: int | None): + calc = MagicMock() + calc.distance.return_value = distance + return calc + + def test_adjacent_chip_receives_distance(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.set_jump_calculator(self._make_calc(distance=1), max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "Jita") + + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert dock._chips["0x1"]._threat_distance == 1 + finally: + dock.deleteLater() + + def test_same_system_chip_has_no_distance(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.set_jump_calculator(self._make_calc(distance=0), max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "HED-GP") + + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert dock._chips["0x1"]._threat_distance is None + finally: + dock.deleteLater() + + def test_unknown_chip_system_has_no_distance(self, qapp): + """Graceful fallback: unknown chip tints at full alpha, no badge.""" + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + dock.set_jump_calculator(self._make_calc(distance=99), max_jumps=2) + dock.add_chip("0x1", "Alice") + # No set_character_system → chip._system stays None + + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + assert dock._chips["0x1"]._threat_distance is None + # Calculator should not have been queried at all (alpha=1.0 path) + finally: + dock.deleteLater() + + def test_calculator_error_swallowed(self, qapp): + from argus_overview.ui.status_dock import StatusDock + + dock = StatusDock() + try: + calc = MagicMock() + calc.distance.side_effect = ValueError("graph corrupt") + dock.set_jump_calculator(calc, max_jumps=2) + dock.add_chip("0x1", "Alice") + dock.set_character_system("Alice", "Jita") + + # Force smart-filter branch by stubbing resolve_tint? No — just + # rely on real resolve_tint: it would call calculator.distance + # too. Either way, the dock should not raise. + dock.set_threat_state(ThreatLevel.DANGER, "HED-GP") + + # Chip threat state may or may not be set depending on which + # call raised, but the dock must not have crashed. + assert "0x1" in dock._chips + finally: + dock.deleteLater() From 0b930923addf6ca9d101787d75b0b90fa4695b4c Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 23:35:58 -0700 Subject: [PATCH 08/10] feat(ui): per-character accent border on preview frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frames now paint a 2px accent border in the same color the character's chip uses for its avatar. Visible only when no threat tint is active — threat overdraws it during alerts. At small grid sizes (12+ multiboxed clients) the accent makes it instant to identify which client a frame belongs to without reading the name label. Changes: - main_tab.py: promoted CHIP_ACCENT_COLORS + accent_for from status_dock into CHARACTER_ACCENT_COLORS + character_accent_color. Both modules now share one palette so frame border and chip avatar always match. - WindowPreviewWidget._accent_color set on init from character_name. paintEvent draws the 2px rounded accent rect when: * threat_level is None * flash_color is None When threat fires, the threat border (3-5px) overdraws the accent. - status_dock.py: keeps CHIP_ACCENT_COLORS / accent_for as backward- compatible aliases pointing at the main_tab helpers. No public-API break for downstream callers (or PR3 tests). 9 new tests: TestCharacterAccentColor (3): deterministic, QColor type, palette size TestCharacterAccentChipFrameMatch (2): same color across surfaces + legacy aliases resolve to the new home TestWindowPreviewAccentBorder (4): init state, paint smoke for clear / threat / flash branches Suite: 2352 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 45 ++++++++++- src/argus_overview/ui/status_dock.py | 29 +++----- tests/test_main_tab.py | 107 +++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 20 deletions(-) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 7e8122c..067d5cf 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -73,6 +73,32 @@ THREAT_PULSE_TICK_MS = 33 # ~30 fps pulse THREAT_PULSE_DURATION_MS = 600 # one pulse cycle +# Per-character accent palette (PR8). Shared by both frames + chips so +# the same character renders in the same color across the preview grid +# and the status dock. Palette is fixed-length; deterministic hash maps +# names to indices. +CHARACTER_ACCENT_COLORS: list[tuple[int, int, int]] = [ + (255, 100, 100), + (100, 255, 100), + (100, 150, 255), + (255, 200, 80), + (220, 120, 220), + (100, 220, 220), + (255, 165, 60), + (170, 130, 255), +] + + +def character_accent_color(name: str) -> QColor: + """Deterministic accent color for a character name. + + Used by both WindowPreviewWidget (frame border) and CharacterChip + (avatar fill) so visual identity is consistent across surfaces. + """ + r, g, b = CHARACTER_ACCENT_COLORS[abs(hash(name)) % len(CHARACTER_ACCENT_COLORS)] + return QColor(r, g, b) + + # Module-level constant: avoids re-creating the dict on every pil_to_qimage call _FORMAT_MAP = { "RGB": (3, QImage.Format.Format_RGB888), @@ -633,6 +659,9 @@ def __init__( # WindowManager.apply_threat_state for smart fan-out. self._character_system: str | None = None + # PR8: per-character accent color, shared with the chip. + self._accent_color: QColor = character_accent_color(character_name) + # Intel threat state (PR1: intel-aware preview borders) self._threat_level: ThreatLevel | None = None self._threat_system: str | None = None @@ -983,12 +1012,26 @@ def _clear_flash(self) -> None: self.update() def paintEvent(self, event): - """Custom paint: threat border, focus dot, lock icon, legacy flash.""" + """Custom paint: accent, threat border, focus dot, lock icon, flash.""" super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) + # 0. Per-character accent border (PR8) — only visible when no + # threat or legacy-flash overlay is active. Gives instant visual + # identity at small grid sizes and matches the chip avatar color. + if ( + self._threat_level is None + and self._flash_color is None + and getattr(self, "_accent_color", None) is not None + ): + pen = QPen(self._accent_color) + pen.setWidth(2) + painter.setPen(pen) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRoundedRect(2, 2, self.width() - 4, self.height() - 4, 4, 4) + # 1. Threat-tint border (PR1) — drawn first so dot/lock paint over it if self._threat_level is not None and self._threat_alpha > 0.0: r, g, b = THREAT_BORDER_COLORS.get(self._threat_level, (255, 255, 255)) diff --git a/src/argus_overview/ui/status_dock.py b/src/argus_overview/ui/status_dock.py index 6a5081d..d60c03f 100644 --- a/src/argus_overview/ui/status_dock.py +++ b/src/argus_overview/ui/status_dock.py @@ -28,25 +28,16 @@ ) from argus_overview.intel.parser import ThreatLevel -from argus_overview.ui.main_tab import THREAT_BORDER_COLORS - -# Stable accent palette — same hue per character across sessions -CHIP_ACCENT_COLORS: list[tuple[int, int, int]] = [ - (255, 100, 100), - (100, 255, 100), - (100, 150, 255), - (255, 200, 80), - (220, 120, 220), - (100, 220, 220), - (255, 165, 60), - (170, 130, 255), -] - - -def accent_for(name: str) -> QColor: - """Deterministic accent color for a character name.""" - r, g, b = CHIP_ACCENT_COLORS[abs(hash(name)) % len(CHIP_ACCENT_COLORS)] - return QColor(r, g, b) +from argus_overview.ui.main_tab import ( + CHARACTER_ACCENT_COLORS, + THREAT_BORDER_COLORS, + character_accent_color, +) + +# Backward-compatible aliases — PR8 promoted these to main_tab.py so +# frames + chips share one palette + helper. Public names preserved. +CHIP_ACCENT_COLORS = CHARACTER_ACCENT_COLORS +accent_for = character_accent_color def _initials(name: str) -> str: diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index 9d81171..804e767 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -10199,3 +10199,110 @@ def test_no_calculator_keeps_pr5_behavior(self, qapp): assert dock._chips["0x1"]._threat_level is None finally: dock.deleteLater() + + +# ============================================================================= +# Per-character accent border (PR8) +# ============================================================================= + + +class TestCharacterAccentColor: + """Shared accent palette helper used by frames + chips.""" + + def test_deterministic(self): + from argus_overview.ui.main_tab import character_accent_color + + assert character_accent_color("Pilot1").rgb() == character_accent_color("Pilot1").rgb() + + def test_returns_qcolor(self): + from PySide6.QtGui import QColor + + from argus_overview.ui.main_tab import character_accent_color + + assert isinstance(character_accent_color("X"), QColor) + + def test_palette_has_eight_entries(self): + from argus_overview.ui.main_tab import CHARACTER_ACCENT_COLORS + + assert len(CHARACTER_ACCENT_COLORS) == 8 + + +class TestCharacterAccentChipFrameMatch: + """Same character → same color across the frame and the chip.""" + + def test_frame_and_chip_share_accent(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + from argus_overview.ui.status_dock import CharacterChip + + widget = WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + chip = CharacterChip("0xABC", "TestPilot") + try: + assert widget._accent_color.rgb() == chip._accent.rgb() + finally: + widget.deleteLater() + chip.deleteLater() + + def test_legacy_chip_aliases_resolve_to_main_tab_helpers(self): + from argus_overview.ui.main_tab import ( + CHARACTER_ACCENT_COLORS, + character_accent_color, + ) + from argus_overview.ui.status_dock import CHIP_ACCENT_COLORS, accent_for + + assert CHIP_ACCENT_COLORS is CHARACTER_ACCENT_COLORS + assert accent_for is character_accent_color + + +class TestWindowPreviewAccentBorder: + """Frame paints the accent border only when no threat is active.""" + + def _make_widget(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + + def test_accent_color_set_on_init(self, qapp): + from PySide6.QtGui import QColor + + widget = self._make_widget(qapp) + try: + assert isinstance(widget._accent_color, QColor) + finally: + widget.deleteLater() + + def test_paint_clear_state_does_not_crash(self, qapp): + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + # No threat, no flash → accent border should paint + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_with_threat_does_not_crash(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP") + widget.repaint() + finally: + widget.deleteLater() + + def test_paint_with_flash_does_not_crash(self, qapp): + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + widget.flash_border("#FF0000", 1000) + widget.repaint() + finally: + widget.deleteLater() From 06f90a3bcc5c83aedd14169393db13d354f401a2 Mon Sep 17 00:00:00 2001 From: AreteDriver Date: Sat, 25 Apr 2026 23:49:23 -0700 Subject: [PATCH 09/10] feat(ui): "+Nj" distance badge on preview frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetrizes PR #68 across the grid + dock. The chip strip already showed "+Nj" badges for adjacent-system alerts; now the preview frames carry the same chrome. At a glance, a dim red border with "+1j" text reads as: "this character is one jump from where the hostile is". Changes: - WindowPreviewWidget._threat_distance state, mirrored from CharacterChip - set_threat_state(level, system, initial_alpha=1.0, distance=None) — stores distance, kwarg matches chip API - paintEvent draws "+Nj" in 8pt bold threat color near top-left, just right of the lock icon area, only when threat is active and distance > 0 - Decay path clears distance when alpha hits zero (alongside level/system) - WindowManager.apply_threat_state queries jump_calculator.distance for frames whose alpha came back < 1.0, then passes through. Mirrors StatusDock.set_threat_state from PR7. Calculator errors swallowed. Three existing fan-out assertions updated to expect distance=None kwarg (call shape changed from 3-arg to 4-arg). 10 new tests (6 frame state + paint, 4 manager wiring + error swallow). Suite: 2362 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 45 +++++++- tests/test_main_tab.py | 177 +++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 7 deletions(-) diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 067d5cf..3f43360 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -665,6 +665,10 @@ def __init__( # Intel threat state (PR1: intel-aware preview borders) self._threat_level: ThreatLevel | None = None self._threat_system: str | None = None + # PR9: jumps from this character to the alert system. None for + # same-system / unknown; positive int renders as "+Nj" near the + # top-left of the threat border. + self._threat_distance: int | None = None # Decay alpha drives the inset border; ranges 0.0 (off) to 1.0 (full) self._threat_alpha: float = 0.0 self._threat_decay_steps: int = max(1, THREAT_DECAY_DURATION_MS // THREAT_DECAY_TICK_MS) @@ -900,6 +904,7 @@ def set_threat_state( level: ThreatLevel | None, system: str | None = None, initial_alpha: float = 1.0, + distance: int | None = None, ) -> None: """ Update the intel threat state for this preview frame. @@ -911,6 +916,9 @@ def set_threat_state( to 1.0 (full intensity for same-system alerts). Lower values are used by WindowManager when fanning to characters in adjacent systems via the jumps-from filter (PR6). + distance: Jumps from this character to the alert system. + None for same-system / unknown. Positive ints render as a + "+Nj" badge near the top-left of the frame (PR9). """ prev_level = self._threat_level @@ -918,6 +926,7 @@ def set_threat_state( self._threat_level = None self._threat_system = None self._threat_alpha = 0.0 + self._threat_distance = None self._threat_decay_timer.stop() self.update() return @@ -925,6 +934,7 @@ def set_threat_state( self._threat_level = level self._threat_system = system self._threat_alpha = max(0.0, min(1.0, initial_alpha)) + self._threat_distance = distance if distance and distance > 0 else None # Pulse on upgrade into danger/critical (only at full-ish intensity — # don't pulse for distant adjacent-system alerts). @@ -951,6 +961,7 @@ def _tick_threat_decay(self) -> None: if self._threat_alpha <= 0.0: self._threat_level = None self._threat_system = None + self._threat_distance = None self._threat_decay_timer.stop() self.update() @@ -1055,6 +1066,25 @@ def paintEvent(self, event): 4, ) + # PR9: distance badge — "+Nj" near the top-left corner inside + # the threat border, in the threat color. Only renders for + # adjacent-system alerts (distance > 0). + if self._threat_distance and self._threat_distance > 0: + from PySide6.QtGui import QFont + + badge_text = f"+{self._threat_distance}j" + badge_font = QFont(painter.font()) + badge_font.setPointSize(8) + badge_font.setBold(True) + painter.setFont(badge_font) + # Foreground stays in the threat color, opaque so it's + # legible even when the border itself is dim. + text_color = QColor(r, g, b, max(200, alpha)) + painter.setPen(QPen(text_color)) + # Shifted right of where the lock icon sits (x=6) so they + # don't overlap when both are visible. + painter.drawText(28, 18, badge_text) + # 2. Legacy flash overlay (compat with border_flash_requested) if self._flash_color is not None: pen = QPen(self._flash_color) @@ -1382,7 +1412,20 @@ def apply_threat_state(self, level: ThreatLevel | None, system: str | None = Non ) if not should_apply: continue - frame.set_threat_state(level, system, initial_alpha=alpha) + # PR9: surface the jump distance for the +Nj frame badge. + # Mirrors StatusDock.set_threat_state behavior (PR7). + distance: int | None = None + if ( + alpha < 1.0 + and known + and calculator is not None + and known.lower() != system.lower() + ): + try: + distance = calculator.distance(known, system) + except (AttributeError, TypeError, ValueError): + distance = None + frame.set_threat_state(level, system, initial_alpha=alpha, distance=distance) count += 1 except RuntimeError: continue diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index 804e767..4b9d32b 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -9262,14 +9262,15 @@ def test_apply_threat_state_fans_out_to_all_frames(self): assert count == 3 # PR6: smart-filter branch passes initial_alpha=1.0 explicitly when # the per-character system is unknown (graceful fallback). + # PR9: also passes distance=None for the +Nj badge surface. f1.set_threat_state.assert_called_once_with( - ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None ) f2.set_threat_state.assert_called_once_with( - ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None ) f3.set_threat_state.assert_called_once_with( - ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None ) def test_apply_threat_state_empty_frames(self): @@ -9832,7 +9833,7 @@ def test_only_matching_character_tints(self): assert count == 1 alice.set_threat_state.assert_called_once_with( - ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None ) bob.set_threat_state.assert_not_called() @@ -10090,10 +10091,10 @@ def test_adjacent_character_tinted_with_falloff_alpha(self): assert count == 2 bob.set_threat_state.assert_called_once_with( - ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0 + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None ) alice.set_threat_state.assert_called_once_with( - ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5 + ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5, distance=1 ) def test_beyond_threshold_skipped(self): @@ -10306,3 +10307,167 @@ def test_paint_with_flash_does_not_crash(self, qapp): widget.repaint() finally: widget.deleteLater() + + +# ============================================================================= +# Frame distance badge (PR9 — symmetrize PR7 across grid + dock) +# ============================================================================= + + +class TestWindowPreviewWidgetDistanceBadge: + def _make_widget(self, qapp): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="TestPilot", + capture_system=MagicMock(), + ) + + def test_default_distance_is_none(self, qapp): + widget = self._make_widget(qapp) + try: + assert widget._threat_distance is None + finally: + widget.deleteLater() + + def test_distance_stored_when_positive(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=2) + assert widget._threat_distance == 2 + finally: + widget.deleteLater() + + def test_zero_distance_treated_as_none(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=0) + assert widget._threat_distance is None + finally: + widget.deleteLater() + + def test_clear_resets_distance(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=2) + widget.set_threat_state(ThreatLevel.CLEAR) + assert widget._threat_distance is None + finally: + widget.deleteLater() + + def test_decay_to_zero_clears_distance(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=2) + # Tighten decay so a single tick fully drains. + widget._threat_decay_steps = 1 + widget._threat_alpha = 0.5 # ensure > 0 so first branch runs + widget._tick_threat_decay() + assert widget._threat_distance is None + assert widget._threat_level is None + finally: + widget.deleteLater() + + def test_paint_with_distance_does_not_crash(self, qapp): + from argus_overview.intel.parser import ThreatLevel + + widget = self._make_widget(qapp) + try: + widget.resize(200, 150) + widget.set_threat_state(ThreatLevel.WARNING, "HED-GP", initial_alpha=0.5, distance=1) + widget.repaint() + finally: + widget.deleteLater() + + +def _frame_mock_v2(character_name: str): + """Mock frame for WindowManager tests — preserves character_name.""" + f = MagicMock() + f.character_name = character_name + return f + + +class TestWindowManagerPassesDistanceToFrame: + def _make_calc(self, distance: int | None): + calc = MagicMock() + calc.distance.return_value = distance + return calc + + def _make_manager(self, char_systems=None, max_jumps=0, calc=None): + from argus_overview.ui.main_tab import WindowManager + + manager = WindowManager.__new__(WindowManager) + manager.preview_frames = {} + manager._character_systems = dict(char_systems or {}) + manager._jump_calculator = calc + manager._jump_max = max_jumps + return manager + + def test_adjacent_frame_receives_distance(self): + from argus_overview.intel.parser import ThreatLevel + + calc = self._make_calc(distance=1) + manager = self._make_manager(char_systems={"Alice": "Jita"}, max_jumps=2, calc=calc) + alice = _frame_mock_v2("Alice") + manager.preview_frames = {"w1": alice} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5, distance=1 + ) + + def test_same_system_frame_distance_is_none(self): + from argus_overview.intel.parser import ThreatLevel + + calc = self._make_calc(distance=0) + manager = self._make_manager(char_systems={"Alice": "HED-GP"}, max_jumps=2, calc=calc) + alice = _frame_mock_v2("Alice") + manager.preview_frames = {"w1": alice} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + calc.distance.assert_not_called() + + def test_unknown_character_distance_is_none(self): + from argus_overview.intel.parser import ThreatLevel + + calc = self._make_calc(distance=99) + manager = self._make_manager(char_systems={}, max_jumps=2, calc=calc) + anon = _frame_mock_v2("Bob") + manager.preview_frames = {"w1": anon} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + anon.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=1.0, distance=None + ) + + def test_calculator_error_swallowed(self): + from argus_overview.intel.parser import ThreatLevel + + calc = MagicMock() + # First call (inside resolve_tint) returns a usable distance, second + # call (PR9 explicit query for the badge) raises. + calc.distance.side_effect = [1, ValueError("graph corrupt")] + manager = self._make_manager(char_systems={"Alice": "Jita"}, max_jumps=2, calc=calc) + alice = _frame_mock_v2("Alice") + manager.preview_frames = {"w1": alice} + + manager.apply_threat_state(ThreatLevel.DANGER, "HED-GP") + + alice.set_threat_state.assert_called_once_with( + ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5, distance=None + ) From 0750ed9a86b5df2f11e6d35f879e45865db592fd Mon Sep 17 00:00:00 2001 From: "James C. Young" Date: Sun, 26 Apr 2026 02:40:39 -0700 Subject: [PATCH 10/10] =?UTF-8?q?feat(ui):=20replay=20strip=20=E2=80=94=20?= =?UTF-8?q?toggleable=20scrub=20of=20recent=20capture=20frames=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional horizontal strip below each preview that holds the last ~5 seconds of capture as 6 thumbnails. Hovering a cell swaps the main image to that buffered frame; leaving the strip restores the live feed. Useful for combat replay: "did I see a sabre uncloak just before they jumped in?" Design choices: - Toggleable via right-click menu rather than hover-driven, because the existing opacity-on-hover (PR1) preserves click-through to the EVE client and a competing hover gesture would force a mode switch users didn't ask for. - Ring buffer always accumulates (size 6, 800ms throttle = ~5s window). Memory cost only meaningful when the strip is on (~170KB per widget). - Per-character persistence via settings_manager["replay_strip_enabled"] so the toggle survives session restarts. - Bypasses the ActionRegistry for this one toggle. Documented inline: the action is per-widget state, registering it as a tier-3 CONTEXT action would force a registry refactor for one feature. Changes: - New ui/replay_strip.py: ReplayStrip(QWidget) with set_frames(), frame_hovered(int) signal (-1 when no cell is hovered), thumbnail paint, hover highlight overlay. - WindowPreviewWidget: deque(maxlen=6) ring buffer, _sample_replay_buffer in update_frame, enable_replay_strip(bool), _on_replay_frame_hovered swap path, _toggle_replay_strip action wired into contextMenuEvent, init reads settings_manager["replay_strip_enabled"] and restores. Defensive getattr/hasattr on the new methods so existing bypassed-init test helpers keep working without modification. 29 new tests (12 strip widget, 5 ring buffer, 6 toggle lifecycle, 3 hover swap, 3 persistence). Suite: 2391 passed, 5 skipped. Co-authored-by: AreteDriver Co-authored-by: Claude Opus 4.7 (1M context) --- src/argus_overview/ui/main_tab.py | 139 +++++++++++++++ src/argus_overview/ui/replay_strip.py | 147 ++++++++++++++++ tests/test_main_tab.py | 239 ++++++++++++++++++++++++++ tests/test_replay_strip.py | 184 ++++++++++++++++++++ 4 files changed, 709 insertions(+) create mode 100644 src/argus_overview/ui/replay_strip.py create mode 100644 tests/test_replay_strip.py diff --git a/src/argus_overview/ui/main_tab.py b/src/argus_overview/ui/main_tab.py index 3f43360..215733d 100644 --- a/src/argus_overview/ui/main_tab.py +++ b/src/argus_overview/ui/main_tab.py @@ -73,6 +73,11 @@ THREAT_PULSE_TICK_MS = 33 # ~30 fps pulse THREAT_PULSE_DURATION_MS = 600 # one pulse cycle +# Replay strip (PR10) — small ring buffer of recent capture pixmaps. +# 6 cells × 800ms throttle = ~5s of recent history. +REPLAY_BUFFER_SIZE = 6 +REPLAY_THROTTLE_MS = 800 + # Per-character accent palette (PR8). Shared by both frames + chips so # the same character renders in the same color across the preview grid # and the status dock. Palette is fixed-length; deterministic hash maps @@ -687,6 +692,16 @@ def __init__( self._flash_timer.setSingleShot(True) self._flash_timer.timeout.connect(self._clear_flash) + # PR10: replay strip — bounded ring buffer of recent frame pixmaps, + # sampled at most once per REPLAY_THROTTLE_MS so the buffer covers + # ~5 seconds of capture even at high refresh rates. + from collections import deque + + self._replay_buffer: deque = deque(maxlen=REPLAY_BUFFER_SIZE) + self._replay_last_sample_ms: int = 0 + self._replay_strip = None # type: ignore[var-annotated] + self._replay_view_index: int | None = None # None = live; int = buffered + # Setup UI self.setMinimumSize(200, 150) self.setMaximumSize(600, 450) @@ -738,6 +753,16 @@ def __init__( self._normal_min_size: QSize = self.minimumSize() self._normal_max_size: QSize = self.maximumSize() + # PR10: restore the replay-strip toggle from settings if it was + # previously enabled for this character. + if self.settings_manager is not None: + try: + store = self.settings_manager.get("replay_strip_enabled", {}) or {} + if isinstance(store, dict) and store.get(self.character_name): + self.enable_replay_strip(True) + except (AttributeError, TypeError): + pass + def _load_settings(self): """Load settings from settings_manager""" if self.settings_manager: @@ -800,6 +825,17 @@ def update_frame(self, image: Image.Image): self.current_pixmap = QPixmap.fromImage(qimage) del qimage # Release intermediate QImage memory + # PR10: sample into the replay ring buffer at most once per + # REPLAY_THROTTLE_MS so the buffer covers ~5s regardless of + # capture rate. Only sample the unscaled pixmap; the strip + # rescales itself for display. + self._sample_replay_buffer(self.current_pixmap) + + # If the user is currently scrubbing through the strip, hold + # the buffered view — don't overwrite it with the live frame. + if getattr(self, "_replay_view_index", None) is not None: + return + # Scale to fit widget while maintaining aspect ratio scaled_pixmap = self.current_pixmap.scaled( self.image_label.size(), @@ -1022,6 +1058,81 @@ def _clear_flash(self) -> None: self._flash_color = None self.update() + # ----- PR10 replay strip ------------------------------------------------ + def _sample_replay_buffer(self, pixmap: QPixmap) -> None: + """Throttle-sample a captured pixmap into the ring buffer.""" + if pixmap is None or pixmap.isNull(): + return + # Defensive: bypassed-init test helpers may not set these. + if not hasattr(self, "_replay_buffer"): + return + now_ms = int(time.monotonic() * 1000) + if now_ms - getattr(self, "_replay_last_sample_ms", 0) < REPLAY_THROTTLE_MS: + return + # Store an immutable copy at modest size; full-res pixmaps would + # blow up memory across many widgets. 240×180 keeps it ~170KB max. + thumb = pixmap.scaled( + 240, + 180, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self._replay_buffer.append(thumb) + self._replay_last_sample_ms = now_ms + if self._replay_strip is not None: + try: + self._replay_strip.set_frames(list(self._replay_buffer)) + except RuntimeError: + self._replay_strip = None + + def is_replay_strip_enabled(self) -> bool: + return getattr(self, "_replay_strip", None) is not None + + def enable_replay_strip(self, enabled: bool) -> None: + """Show or hide the replay strip below the main image.""" + if enabled and self._replay_strip is None: + from argus_overview.ui.replay_strip import ReplayStrip + + self._replay_strip = ReplayStrip(parent=self) + self._replay_strip.frame_hovered.connect(self._on_replay_frame_hovered) + # Append below the existing image_label / info_label / timer. + self.layout().addWidget(self._replay_strip) + self._replay_strip.set_frames(list(self._replay_buffer)) + elif not enabled and self._replay_strip is not None: + try: + self._replay_strip.frame_hovered.disconnect(self._on_replay_frame_hovered) + except (RuntimeError, TypeError): + pass + self.layout().removeWidget(self._replay_strip) + self._replay_strip.deleteLater() + self._replay_strip = None + # Drop any held buffered view. + self._replay_view_index = None + + def _on_replay_frame_hovered(self, idx: int) -> None: + """Swap the main image label between live capture and a buffered frame.""" + if idx < 0 or idx >= len(self._replay_buffer): + self._replay_view_index = None + # Restore the live capture if we have one cached. + if self.current_pixmap is not None and not self.current_pixmap.isNull(): + scaled = self.current_pixmap.scaled( + self.image_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self.image_label.setPixmap(scaled) + return + self._replay_view_index = idx + buffered = self._replay_buffer[idx] + if buffered is None or buffered.isNull(): + return + scaled = buffered.scaled( + self.image_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self.image_label.setPixmap(scaled) + def paintEvent(self, event): """Custom paint: accent, threat border, focus dot, lock icon, flash.""" super().paintEvent(event) @@ -1189,8 +1300,36 @@ def contextMenuEvent(self, event): parent=self, ) + # PR10: replay-strip toggle. Added directly here rather than via + # ActionRegistry because the action is per-widget state, not a + # global tier-1 / tier-2 action — registering it as a tier-3 + # CONTEXT action would force a registry refactor for one toggle. + menu.addSeparator() + replay_label = ( + "Hide replay strip" if self.is_replay_strip_enabled() else "Show replay strip" + ) + replay_action = menu.addAction(replay_label) + replay_action.triggered.connect(self._toggle_replay_strip) + menu.exec(event.globalPos()) + def _toggle_replay_strip(self) -> None: + """Flip the replay strip on/off and persist the choice per character.""" + new_state = not self.is_replay_strip_enabled() + self.enable_replay_strip(new_state) + if self.settings_manager is not None: + try: + store = self.settings_manager.get("replay_strip_enabled", {}) or {} + if not isinstance(store, dict): + store = {} + if new_state: + store[self.character_name] = True + else: + store.pop(self.character_name, None) + self.settings_manager.set("replay_strip_enabled", store) + except (AttributeError, TypeError) as e: + self.logger.debug(f"Failed to persist replay-strip toggle: {e}") + def _show_label_dialog(self): """Show dialog to set custom label""" current = self.custom_label or "" diff --git a/src/argus_overview/ui/replay_strip.py b/src/argus_overview/ui/replay_strip.py new file mode 100644 index 0000000..e5f8eb8 --- /dev/null +++ b/src/argus_overview/ui/replay_strip.py @@ -0,0 +1,147 @@ +""" +Replay strip — small horizontal row of recent capture frames. + +Docked at the bottom of a WindowPreviewWidget when the user toggles +"Show replay strip" from the context menu. Hovering a cell emits +frame_hovered(idx) so the parent widget can swap its main image to the +buffered frame; leaving the strip emits -1 so the parent restores live +capture. + +PR10 of the intel-aware UI uplift. Memory cost is bounded: the parent +holds the QPixmaps; the strip just paints references. +""" + +from __future__ import annotations + +import logging + +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainter, QPen, QPixmap +from PySide6.QtWidgets import QWidget + + +class ReplayStrip(QWidget): + """ + Horizontal row of recent frame thumbnails. + + Stateless beyond its frame list + hover index — the parent widget + owns the ring buffer and the live/buffered display swap. + + Signals: + frame_hovered(int): The cell index under the mouse, or -1 when + no cell is hovered (mouse left the strip). + """ + + frame_hovered = Signal(int) # cell index, or -1 when not hovering + + STRIP_HEIGHT = 32 + CELL_PADDING = 2 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self._frames: list[QPixmap] = [] + self._hover_index: int = -1 + + self.setFixedHeight(self.STRIP_HEIGHT) + self.setMouseTracking(True) + self.setStyleSheet( + """ + ReplayStrip { + background-color: rgba(20, 20, 20, 220); + border-top: 1px solid #3a3a3a; + } + """ + ) + + # ----- Public API ------------------------------------------------------- + def set_frames(self, frames: list[QPixmap]) -> None: + """Update the strip with a new ordered list (oldest → newest).""" + self._frames = list(frames) + # Re-clamp hover if it now points past the end. + if self._hover_index >= len(self._frames): + self._hover_index = -1 + self.update() + + def frame_count(self) -> int: + return len(self._frames) + + def hover_index(self) -> int: + return self._hover_index + + # ----- Internals -------------------------------------------------------- + def _cell_width(self) -> int: + if not self._frames: + return 0 + usable = max(0, self.width() - self.CELL_PADDING * 2) + return usable // max(1, len(self._frames)) + + def _index_at(self, x: int) -> int: + cell_w = self._cell_width() + if cell_w <= 0: + return -1 + idx = (x - self.CELL_PADDING) // cell_w + if idx < 0 or idx >= len(self._frames): + return -1 + return int(idx) + + def sizeHint(self) -> QSize: + return QSize(200, self.STRIP_HEIGHT) + + # ----- Events ----------------------------------------------------------- + def paintEvent(self, event): + super().paintEvent(event) + if not self._frames: + return + + painter = QPainter(self) + try: + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + cell_w = self._cell_width() + if cell_w <= 0: + return + inner_h = self.height() - self.CELL_PADDING * 2 + for i, pixmap in enumerate(self._frames): + x = self.CELL_PADDING + i * cell_w + y = self.CELL_PADDING + # Draw thumbnail clipped to cell rect, preserving aspect. + if pixmap is not None and not pixmap.isNull(): + scaled = pixmap.scaled( + cell_w - 2, + inner_h, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + # Center in cell + cx = x + (cell_w - scaled.width()) // 2 + cy = y + (inner_h - scaled.height()) // 2 + painter.drawPixmap(cx, cy, scaled) + # Cell border + painter.setPen(QPen(QColor(60, 60, 60), 1)) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRect(x, y, cell_w - 1, inner_h - 1) + + # Highlight the hovered cell. + if 0 <= self._hover_index < len(self._frames): + hx = self.CELL_PADDING + self._hover_index * cell_w + hy = self.CELL_PADDING + painter.setPen(QPen(QColor(120, 200, 255, 230), 2)) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRect(hx, hy, cell_w - 1, inner_h - 1) + finally: + painter.end() + + def mouseMoveEvent(self, event): + idx = self._index_at(int(event.position().x())) + if idx != self._hover_index: + self._hover_index = idx + self.frame_hovered.emit(idx) + self.update() + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + if self._hover_index != -1: + self._hover_index = -1 + self.frame_hovered.emit(-1) + self.update() + super().leaveEvent(event) diff --git a/tests/test_main_tab.py b/tests/test_main_tab.py index 4b9d32b..fab376b 100644 --- a/tests/test_main_tab.py +++ b/tests/test_main_tab.py @@ -10471,3 +10471,242 @@ def test_calculator_error_swallowed(self): alice.set_threat_state.assert_called_once_with( ThreatLevel.DANGER, "HED-GP", initial_alpha=0.5, distance=None ) + + +# ============================================================================= +# Replay strip integration on WindowPreviewWidget (PR10) +# ============================================================================= + + +def _replay_widget(qapp, settings_manager=None): + from argus_overview.ui.main_tab import WindowPreviewWidget + + return WindowPreviewWidget( + window_id="0xABC", + character_name="ReplayPilot", + capture_system=MagicMock(), + settings_manager=settings_manager, + ) + + +def _fake_pixmap(color=Qt.GlobalColor.red): + from PySide6.QtGui import QPixmap + + pm = QPixmap(200, 150) + pm.fill(color) + return pm + + +class TestWindowPreviewReplayBuffer: + def test_buffer_starts_empty(self, qapp): + widget = _replay_widget(qapp) + try: + assert len(widget._replay_buffer) == 0 + finally: + widget.deleteLater() + + def test_sample_appends_one_frame(self, qapp): + widget = _replay_widget(qapp) + try: + widget._sample_replay_buffer(_fake_pixmap()) + assert len(widget._replay_buffer) == 1 + finally: + widget.deleteLater() + + def test_sample_throttled(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + widget._sample_replay_buffer(_fake_pixmap()) + # Pretend no time has passed by leaving _replay_last_sample_ms as set + widget._sample_replay_buffer(_fake_pixmap()) + # Throttled — still one + assert len(widget._replay_buffer) == 1 + + # Advance past the throttle window + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + assert len(widget._replay_buffer) == 2 + finally: + widget.deleteLater() + + def test_buffer_respects_maxlen(self, qapp): + from argus_overview.ui.main_tab import REPLAY_BUFFER_SIZE, REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(REPLAY_BUFFER_SIZE * 2): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + assert len(widget._replay_buffer) == REPLAY_BUFFER_SIZE + finally: + widget.deleteLater() + + def test_null_pixmap_skipped(self, qapp): + from PySide6.QtGui import QPixmap + + widget = _replay_widget(qapp) + try: + widget._sample_replay_buffer(QPixmap()) # null + assert len(widget._replay_buffer) == 0 + finally: + widget.deleteLater() + + +class TestWindowPreviewReplayStripToggle: + def test_disabled_by_default(self, qapp): + widget = _replay_widget(qapp) + try: + assert widget.is_replay_strip_enabled() is False + assert widget._replay_strip is None + finally: + widget.deleteLater() + + def test_enable_creates_strip(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + assert widget.is_replay_strip_enabled() is True + assert widget._replay_strip is not None + finally: + widget.deleteLater() + + def test_disable_destroys_strip(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + widget.enable_replay_strip(False) + assert widget.is_replay_strip_enabled() is False + assert widget._replay_strip is None + finally: + widget.deleteLater() + + def test_enable_idempotent(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + strip_obj = widget._replay_strip + widget.enable_replay_strip(True) + # Same instance — second call is a no-op. + assert widget._replay_strip is strip_obj + finally: + widget.deleteLater() + + def test_disable_clears_held_view_index(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + widget._replay_view_index = 2 + widget.enable_replay_strip(False) + assert widget._replay_view_index is None + finally: + widget.deleteLater() + + def test_enable_pushes_existing_buffer_to_strip(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(3): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + + widget.enable_replay_strip(True) + assert widget._replay_strip.frame_count() == 3 + finally: + widget.deleteLater() + + +class TestWindowPreviewReplayHoverSwap: + def test_hover_index_swaps_image_label(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(3): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + + widget.enable_replay_strip(True) + widget._on_replay_frame_hovered(1) + + assert widget._replay_view_index == 1 + assert widget.image_label.pixmap() is not None + finally: + widget.deleteLater() + + def test_minus_one_restores_live(self, qapp): + from argus_overview.ui.main_tab import REPLAY_THROTTLE_MS + + widget = _replay_widget(qapp) + try: + for _ in range(2): + widget._replay_last_sample_ms -= REPLAY_THROTTLE_MS + 1 + widget._sample_replay_buffer(_fake_pixmap()) + + widget.current_pixmap = _fake_pixmap(color=Qt.GlobalColor.green) + widget.enable_replay_strip(True) + widget._on_replay_frame_hovered(0) + widget._on_replay_frame_hovered(-1) + + assert widget._replay_view_index is None + finally: + widget.deleteLater() + + def test_out_of_range_index_treated_as_minus_one(self, qapp): + widget = _replay_widget(qapp) + try: + widget.enable_replay_strip(True) + widget._replay_view_index = 0 + widget._on_replay_frame_hovered(99) + assert widget._replay_view_index is None + finally: + widget.deleteLater() + + +class TestWindowPreviewReplayPersistence: + def _settings_mock(self, replay_store): + """SettingsManager mock that returns differentiated values per key.""" + sm = MagicMock() + sm.get.side_effect = lambda key, default=None: ( + replay_store if key == "replay_strip_enabled" else default + ) + return sm + + def test_toggle_persists_to_settings(self, qapp): + sm = self._settings_mock({}) + + widget = _replay_widget(qapp, settings_manager=sm) + try: + widget._toggle_replay_strip() # off → on + # set was called with the toggled-on dict + calls = sm.set.call_args_list + last = calls[-1] + assert last.args[0] == "replay_strip_enabled" + assert last.args[1] == {"ReplayPilot": True} + finally: + widget.deleteLater() + + def test_toggle_off_removes_entry(self, qapp): + sm = self._settings_mock({"ReplayPilot": True}) + + widget = _replay_widget(qapp, settings_manager=sm) + try: + # Init turned strip on from settings; toggle should turn it off + assert widget.is_replay_strip_enabled() is True + widget._toggle_replay_strip() + calls = sm.set.call_args_list + last = calls[-1] + assert last.args[1] == {} # entry removed + finally: + widget.deleteLater() + + def test_init_restores_strip_from_settings(self, qapp): + sm = self._settings_mock({"ReplayPilot": True}) + + widget = _replay_widget(qapp, settings_manager=sm) + try: + assert widget.is_replay_strip_enabled() is True + finally: + widget.deleteLater() diff --git a/tests/test_replay_strip.py b/tests/test_replay_strip.py new file mode 100644 index 0000000..74e5e2a --- /dev/null +++ b/tests/test_replay_strip.py @@ -0,0 +1,184 @@ +"""Tests for ReplayStrip child widget (PR10).""" + +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import QMouseEvent, QPixmap + +from argus_overview.ui.replay_strip import ReplayStrip + + +def _pixmap(w: int = 50, h: int = 30, color: str = "#ff0000") -> QPixmap: + pm = QPixmap(w, h) + pm.fill(Qt.GlobalColor.red) + return pm + + +def _move_event(x: int, y: int) -> QMouseEvent: + return QMouseEvent( + QMouseEvent.Type.MouseMove, + QPointF(float(x), float(y)), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + + +class TestReplayStripInit: + def test_default_state_empty(self, qapp): + strip = ReplayStrip() + try: + assert strip.frame_count() == 0 + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + def test_fixed_height(self, qapp): + strip = ReplayStrip() + try: + assert strip.height() == ReplayStrip.STRIP_HEIGHT + finally: + strip.deleteLater() + + +class TestReplayStripSetFrames: + def test_set_frames_stores_count(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + assert strip.frame_count() == 3 + finally: + strip.deleteLater() + + def test_set_frames_replaces_previous(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.set_frames([_pixmap()]) + assert strip.frame_count() == 1 + finally: + strip.deleteLater() + + def test_set_frames_clamps_stale_hover(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + strip.resize(300, ReplayStrip.STRIP_HEIGHT) + # Force hover_index to position 2 by mouse-moving there. + cell_w = (300 - ReplayStrip.CELL_PADDING * 2) // 3 + x = ReplayStrip.CELL_PADDING + 2 * cell_w + 1 + strip.mouseMoveEvent(_move_event(x, 10)) + assert strip.hover_index() == 2 + + # Shrink the frame list — the stale hover should clamp to -1. + strip.set_frames([_pixmap()]) + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + +class TestReplayStripHover: + def test_mouse_move_emits_index(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + strip.resize(300, ReplayStrip.STRIP_HEIGHT) + received: list[int] = [] + strip.frame_hovered.connect(received.append) + + cell_w = (300 - ReplayStrip.CELL_PADDING * 2) // 3 + # Hover middle cell + x = ReplayStrip.CELL_PADDING + cell_w + cell_w // 2 + strip.mouseMoveEvent(_move_event(x, 10)) + + assert received == [1] + assert strip.hover_index() == 1 + finally: + strip.deleteLater() + + def test_mouse_move_outside_after_inside_emits_minus_one(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + + # First land on cell 0 so the hover index actually changes. + cell_w = (200 - ReplayStrip.CELL_PADDING * 2) // 2 + strip.mouseMoveEvent(_move_event(ReplayStrip.CELL_PADDING + cell_w // 2, 10)) + assert strip.hover_index() == 0 + + # Now connect listener and move past the right edge. + received: list[int] = [] + strip.frame_hovered.connect(received.append) + strip.mouseMoveEvent(_move_event(500, 10)) + + assert received == [-1] + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + def test_mouse_move_dedupes_same_index(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + received: list[int] = [] + strip.frame_hovered.connect(received.append) + + cell_w = (200 - ReplayStrip.CELL_PADDING * 2) // 2 + x1 = ReplayStrip.CELL_PADDING + cell_w // 2 + x2 = ReplayStrip.CELL_PADDING + cell_w // 4 + strip.mouseMoveEvent(_move_event(x1, 10)) + strip.mouseMoveEvent(_move_event(x2, 10)) + + # Both x are inside cell 0 → only one emission + assert received == [0] + finally: + strip.deleteLater() + + def test_leave_event_emits_minus_one(self, qapp): + from PySide6.QtCore import QEvent + + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + cell_w = (200 - ReplayStrip.CELL_PADDING * 2) // 2 + strip.mouseMoveEvent(_move_event(ReplayStrip.CELL_PADDING + cell_w // 2, 10)) + received: list[int] = [] + strip.frame_hovered.connect(received.append) + + strip.leaveEvent(QEvent(QEvent.Type.Leave)) + + assert received == [-1] + assert strip.hover_index() == -1 + finally: + strip.deleteLater() + + +class TestReplayStripPaint: + def test_paint_with_frames_does_not_crash(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap()]) + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + strip.repaint() + finally: + strip.deleteLater() + + def test_paint_empty_does_not_crash(self, qapp): + strip = ReplayStrip() + try: + strip.resize(200, ReplayStrip.STRIP_HEIGHT) + strip.repaint() + finally: + strip.deleteLater() + + def test_paint_with_hover_highlight_does_not_crash(self, qapp): + strip = ReplayStrip() + try: + strip.set_frames([_pixmap(), _pixmap(), _pixmap()]) + strip.resize(300, ReplayStrip.STRIP_HEIGHT) + cell_w = (300 - ReplayStrip.CELL_PADDING * 2) // 3 + strip.mouseMoveEvent(_move_event(ReplayStrip.CELL_PADDING + cell_w // 2, 10)) + strip.repaint() + finally: + strip.deleteLater()