feat: intel-aware multibox UX — 10-PR rollup#74
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 : <System>" 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…' into feat/character-status-dock
… feat/preview-focus-mode
…at/per-character-system-tracking
…ng' into feat/smart-threat-fanout
…eat/jumps-from-fanout
…t/chip-distance-badge
…eat/character-accent-border
…to feat/frame-distance-badge
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 <AreteDriver@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Code Review
This pull request implements a per-character location and threat tracking system, introducing a log-based location tracker, a character status dock, and visual enhancements such as threat-tinted borders and a frame replay strip. The review feedback identifies critical performance concerns regarding frequent directory globbing and potential data corruption in the UTF-16 log tailing logic. Additionally, the reviewer suggests ensuring character accent colors remain consistent across application restarts and recommends optimizing the threat filter to eliminate redundant jump distance calculations.
| if self._log_directory is None or not self._log_directory.exists(): | ||
| return | ||
| try: | ||
| files = list(self._log_directory.glob(_LOCAL_FILE_GLOB)) |
There was a problem hiding this comment.
The _poll method performs a full directory glob and iterates over all Local_*.txt files every 2 seconds. EVE Online players often accumulate thousands of historical log files over time. Performing stat() calls and dictionary lookups on thousands of files in the UI thread will cause significant performance issues and stuttering. Since the intent (per the docstring) is to track 'today's' logs, consider restricting the glob pattern to the current date or using os.scandir() to filter by modification time more efficiently.
| 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) |
There was a problem hiding this comment.
The tailing logic in _process_file is susceptible to data loss and encoding corruption.
- UTF-16 Alignment: If a read occurs while a character is being written,
fb.read()might return an odd number of bytes.raw.decode("utf-16-le", errors="ignore")will drop the trailing byte, and the next read will start at an odd offset, causing all subsequent data to be decoded as garbage. - Partial Line Loss: If the buffer ends with a partial line,
splitlines()will return it, but the regex (which ends with$) will fail to match. Sincestate.positionis updated to the end of the file, this partial data is never re-processed.
Consider ensuring reads are 2-byte aligned and only advancing state.position by the number of bytes corresponding to complete lines (ending in \n or \r).
| 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)] |
There was a problem hiding this comment.
The use of hash(name) for character accent colors is not persistent across application restarts because Python's hash seed is randomized by default. This means characters will be assigned different colors every time the application is launched, which undermines the goal of providing a consistent visual identity. Consider using a stable hash function like zlib.adler32 or a simple deterministic mapping.
| r, g, b = CHARACTER_ACCENT_COLORS[abs(hash(name)) % len(CHARACTER_ACCENT_COLORS)] | |
| r, g, b = CHARACTER_ACCENT_COLORS[sum(ord(c) for c in name) % len(CHARACTER_ACCENT_COLORS)] |
| ) -> 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 | ||
|
|
||
| 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 | ||
|
|
||
| # Adjacent within threshold — scale alpha down by distance. | ||
| alpha = max(_MIN_ADJACENT_ALPHA, _PER_JUMP_FALLOFF**distance) | ||
| return True, alpha |
There was a problem hiding this comment.
The resolve_tint function calculates the jump distance to determine the alpha falloff but does not return it. This forces callers in WindowManager.apply_threat_state and StatusDock.set_threat_state to call jump_calculator.distance a second time to populate the distance badge. This is redundant and inefficient. Consider returning the distance as a third element in the result tuple.
Bumps version to 3.2.0 and documents the 10-PR intel-aware UI arc that landed via #74. Also fixes the pre-existing version drift between pyproject.toml (3.1.2) and __init__.py (3.0.4) by syncing both to 3.2.0. CHANGELOG entry covers: - Intel-aware preview borders (PR #62) - Character status dock (PR #63) - Preview focus mode (PR #64) - Per-character system tracking from EVE Local logs (PR #65) - Smart per-character threat fan-out (PR #66) - Jumps-from fan-out with adjacency falloff (PR #67) - "+Nj" distance badges on chips + frames (PR #68 + PR #71) - Per-character accent border (PR #70) - Toggleable replay strip with frame scrubbing (PR #72) Plus the supporting infrastructure (intel/threat_filter, accent palette promotion, set_threat_state kwarg additions) and the new settings keys that gate the features. Co-authored-by: AreteDriver <AreteDriver@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recovery rollup
The original 10-PR stack (#62–#72) auto-merge cascade misbehaved: each PR squash-merged into its parent branch instead of main, then deleted itself, orphaning the next PR. `feat/frame-distance-badge` is the survivor and contains all 10 features plus the security CI fix (#73). This PR lands the entire chain on main as one merge.
What this lands
Stats
Test plan
Cleanup after this merges
🤖 Generated with Claude Code