Skip to content

feat: intel-aware multibox UX — 10-PR rollup#74

Merged
AreteDriver merged 19 commits into
mainfrom
feat/frame-distance-badge
Apr 26, 2026
Merged

feat: intel-aware multibox UX — 10-PR rollup#74
AreteDriver merged 19 commits into
mainfrom
feat/frame-distance-badge

Conversation

@AreteDriver
Copy link
Copy Markdown
Owner

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

  1. feat(ui): intel-aware preview borders with decay and pulse #62 intel-aware preview borders (threat tint, decay, pulse)
  2. feat(ui): character status dock with per-chip threat state #63 character status dock (chip strip + per-chip threat dot)
  3. feat(ui): preview focus mode (spotlight + dim) #64 preview focus mode (spotlight + dim)
  4. feat(intel): per-character system tracking from Local logs #65 per-character system tracking from Local logs
  5. feat(ui): smart per-character threat fan-out #66 smart per-character threat fan-out
  6. feat(intel): jumps-from threat fan-out with adjacency falloff #67 jumps-from threat fan-out with adjacency falloff
  7. feat(ui): "+Nj" distance badge on adjacent-tinted chips #68 "+Nj" distance badge on adjacent-tinted chips
  8. feat(ui): per-character accent border on preview frames #70 per-character accent border on preview frames
  9. feat(ui): "+Nj" distance badge on preview frames #71 "+Nj" distance badge on preview frames
  10. feat(ui): replay strip — toggleable scrub of recent capture frames #72 replay strip — toggleable scrub of recent capture frames

Stats

  • ~4,769 lines added across 12 commits (10 feature + 1 security fix + merge commits)
  • 212 new tests, full suite green (2391 passed, 5 skipped)
  • ruff clean across all changed files

Test plan

  • CI green (Tests, Type Check, Lint, Security, builds)
  • Manual smoke with EVE running: load 4+ clients, fire a test alert, verify chrome layering across all 10 features

Cleanup after this merges

🤖 Generated with Claude Code

AreteDriver and others added 19 commits April 25, 2026 17:40
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>
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
Copy link
Copy Markdown

codecov Bot commented Apr 26, 2026

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment on lines +234 to +252
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The tailing logic in _process_file is susceptible to data loss and encoding corruption.

  1. 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.
  2. 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. Since state.position is 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)]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)]

Comment on lines +32 to +79
) -> 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

@AreteDriver AreteDriver merged commit 1af9ed1 into main Apr 26, 2026
14 of 15 checks passed
@AreteDriver AreteDriver deleted the feat/frame-distance-badge branch April 26, 2026 09:57
AreteDriver added a commit that referenced this pull request Apr 26, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant