Add a fit diff view
The point is to figure out what items are necessary to transform fit 1 into fit 2
This commit is contained in:
@@ -18,6 +18,7 @@ from gui.builtinContextMenus import resistMode
|
||||
from gui.builtinContextMenus.targetProfile import editor
|
||||
# Item info
|
||||
from gui.builtinContextMenus import itemStats
|
||||
from gui.builtinContextMenus import fitDiff
|
||||
from gui.builtinContextMenus import itemMarketJump
|
||||
from gui.builtinContextMenus import fitSystemSecurity # Not really an item info but want to keep it here
|
||||
from gui.builtinContextMenus import fitPilotSecurity # Not really an item info but want to keep it here
|
||||
|
||||
48
gui/builtinContextMenus/fitDiff.py
Normal file
48
gui/builtinContextMenus/fitDiff.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# =============================================================================
|
||||
# Copyright (C) 2025
|
||||
#
|
||||
# This file is part of pyfa.
|
||||
#
|
||||
# pyfa is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# pyfa is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with pyfa. If not, see <http://www.gnu.org/licenses/>.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import wx
|
||||
|
||||
import gui.mainFrame
|
||||
from gui.contextMenu import ContextMenuSingle
|
||||
|
||||
_t = wx.GetTranslation
|
||||
|
||||
|
||||
class FitDiff(ContextMenuSingle):
|
||||
def __init__(self):
|
||||
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
|
||||
|
||||
def display(self, callingWindow, srcContext, mainItem):
|
||||
# Only show for fittingShip context (right-click on ship)
|
||||
return srcContext == "fittingShip"
|
||||
|
||||
def getText(self, callingWindow, itmContext, mainItem):
|
||||
return _t("Fit Diff...")
|
||||
|
||||
def activate(self, callingWindow, fullContext, mainItem, i):
|
||||
fitID = self.mainFrame.getActiveFit()
|
||||
if fitID is not None:
|
||||
from gui.fitDiffFrame import FitDiffFrame
|
||||
FitDiffFrame(self.mainFrame, fitID)
|
||||
|
||||
|
||||
FitDiff.register()
|
||||
292
gui/fitDiffFrame.py
Normal file
292
gui/fitDiffFrame.py
Normal file
@@ -0,0 +1,292 @@
|
||||
# =============================================================================
|
||||
# Copyright (C) 2025
|
||||
#
|
||||
# This file is part of pyfa.
|
||||
#
|
||||
# pyfa is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# pyfa is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with pyfa. If not, see <http://www.gnu.org/licenses/>.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import wx
|
||||
from collections import Counter
|
||||
|
||||
from eos.const import FittingSlot, FittingModuleState
|
||||
from service.fit import Fit as svcFit
|
||||
from service.port.eft import exportEft, importEft, _importPrepare
|
||||
from service.const import PortEftOptions
|
||||
|
||||
_t = wx.GetTranslation
|
||||
|
||||
|
||||
class FitDiffFrame(wx.Frame):
|
||||
"""A frame to display differences between two fits."""
|
||||
|
||||
def __init__(self, parent, fitID):
|
||||
super().__init__(
|
||||
parent,
|
||||
title=_t("Fit Diff"),
|
||||
style=wx.DEFAULT_FRAME_STYLE | wx.RESIZE_BORDER,
|
||||
size=(1000, 600)
|
||||
)
|
||||
self.parent = parent
|
||||
self.fitID = fitID
|
||||
self.sFit = svcFit.getInstance()
|
||||
|
||||
# EFT export options (same as CTRL-C)
|
||||
self.eftOptions = {
|
||||
PortEftOptions.LOADED_CHARGES: True,
|
||||
PortEftOptions.MUTATIONS: True,
|
||||
PortEftOptions.IMPLANTS: True,
|
||||
PortEftOptions.BOOSTERS: True,
|
||||
PortEftOptions.CARGO: True,
|
||||
}
|
||||
|
||||
self.initUI()
|
||||
self.Centre()
|
||||
self.Show()
|
||||
|
||||
def initUI(self):
|
||||
panel = wx.Panel(self)
|
||||
mainSizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# Instructions at the top
|
||||
instructions = wx.StaticText(
|
||||
panel,
|
||||
label=_t("Paste fits in EFT format to compare")
|
||||
)
|
||||
mainSizer.Add(instructions, 0, wx.ALL | wx.EXPAND, 5)
|
||||
|
||||
# Three panes: Fit 1 | Diff | Fit 2
|
||||
panesSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
|
||||
# Pane 1: Fit 1 (editable)
|
||||
fit1Box = wx.StaticBox(panel, label=_t("Fit 1"))
|
||||
fit1Sizer = wx.StaticBoxSizer(fit1Box, wx.VERTICAL)
|
||||
self.fit1Text = wx.TextCtrl(
|
||||
panel,
|
||||
style=wx.TE_MULTILINE | wx.TE_DONTWRAP
|
||||
)
|
||||
fit1Sizer.Add(self.fit1Text, 1, wx.EXPAND)
|
||||
panesSizer.Add(fit1Sizer, 1, wx.ALL | wx.EXPAND, 5)
|
||||
|
||||
# Bind text changed event to update diff
|
||||
self.fit1Text.Bind(wx.EVT_TEXT, self.onFitChanged)
|
||||
|
||||
# Pane 2: Diff (simple text format)
|
||||
diffBox = wx.StaticBox(panel, label=_t("Differences"))
|
||||
diffSizer = wx.StaticBoxSizer(diffBox, wx.VERTICAL)
|
||||
self.diffText = wx.TextCtrl(
|
||||
panel,
|
||||
style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_DONTWRAP
|
||||
)
|
||||
diffSizer.Add(self.diffText, 1, wx.EXPAND)
|
||||
panesSizer.Add(diffSizer, 1, wx.ALL | wx.EXPAND, 5)
|
||||
|
||||
# Pane 3: Fit 2 (user input)
|
||||
fit2Box = wx.StaticBox(panel, label=_t("Fit 2"))
|
||||
fit2Sizer = wx.StaticBoxSizer(fit2Box, wx.VERTICAL)
|
||||
self.fit2Text = wx.TextCtrl(
|
||||
panel,
|
||||
style=wx.TE_MULTILINE | wx.TE_DONTWRAP
|
||||
)
|
||||
fit2Sizer.Add(self.fit2Text, 1, wx.EXPAND)
|
||||
|
||||
# Bind text changed event to update diff
|
||||
self.fit2Text.Bind(wx.EVT_TEXT, self.onFitChanged)
|
||||
|
||||
panesSizer.Add(fit2Sizer, 1, wx.ALL | wx.EXPAND, 5)
|
||||
|
||||
mainSizer.Add(panesSizer, 1, wx.EXPAND | wx.ALL, 5)
|
||||
|
||||
panel.SetSizer(mainSizer)
|
||||
|
||||
# Load current fit into pane 1
|
||||
self.loadFit1()
|
||||
|
||||
def loadFit1(self):
|
||||
"""Load the current fit into pane 1 as EFT format."""
|
||||
fit = self.sFit.getFit(self.fitID)
|
||||
if fit:
|
||||
eftText = exportEft(fit, self.eftOptions, callback=None)
|
||||
self.fit1Text.SetValue(eftText)
|
||||
|
||||
def onFitChanged(self, event):
|
||||
"""Handle text change in either fit pane - update diff."""
|
||||
self.updateDiff()
|
||||
event.Skip()
|
||||
|
||||
def updateDiff(self):
|
||||
"""Calculate and display the differences between the two fits."""
|
||||
self.diffText.Clear()
|
||||
|
||||
fit1Text = self.fit1Text.GetValue().strip()
|
||||
fit2Text = self.fit2Text.GetValue().strip()
|
||||
|
||||
if not fit1Text or not fit2Text:
|
||||
return
|
||||
|
||||
# Parse both fits
|
||||
fit1 = self.parsePastedFit(fit1Text)
|
||||
fit2 = self.parsePastedFit(fit2Text)
|
||||
|
||||
if fit1 is None:
|
||||
self.diffText.SetValue(_t("Error: Fit 1 has invalid EFT format"))
|
||||
return
|
||||
if fit2 is None:
|
||||
self.diffText.SetValue(_t("Error: Fit 2 has invalid EFT format"))
|
||||
return
|
||||
|
||||
# Calculate differences and format as simple text list
|
||||
diffLines = self.calculateDiff(fit1, fit2)
|
||||
self.diffText.SetValue('\n'.join(diffLines))
|
||||
|
||||
def parsePastedFit(self, text):
|
||||
"""Parse pasted EFT text into a fit object."""
|
||||
try:
|
||||
lines = _importPrepare(text.splitlines())
|
||||
if not lines:
|
||||
return None
|
||||
return importEft(lines)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def calculateDiff(self, fit1, fit2):
|
||||
"""Calculate items needed to transform fit1 into fit2.
|
||||
|
||||
Returns a list of strings in the format: "<item> <quantity>"
|
||||
Only shows items that need to be added (no negative values).
|
||||
"""
|
||||
diffLines = []
|
||||
|
||||
# Get module counts by type for each fit (grouped by slot type)
|
||||
fit1_modules = self.getModuleCounts(fit1)
|
||||
fit2_modules = self.getModuleCounts(fit2)
|
||||
|
||||
# Diff modules - only show items needed to add
|
||||
all_module_types = set(fit1_modules.keys()) | set(fit2_modules.keys())
|
||||
for module_type in sorted(all_module_types):
|
||||
count1 = fit1_modules.get(module_type, 0)
|
||||
count2 = fit2_modules.get(module_type, 0)
|
||||
if count2 > count1:
|
||||
diffLines.append(f"{module_type} {count2 - count1}")
|
||||
|
||||
# Get drone counts
|
||||
fit1_drones = self.getDroneCounts(fit1)
|
||||
fit2_drones = self.getDroneCounts(fit2)
|
||||
|
||||
all_drone_types = set(fit1_drones.keys()) | set(fit2_drones.keys())
|
||||
for drone_type in sorted(all_drone_types):
|
||||
count1 = fit1_drones.get(drone_type, 0)
|
||||
count2 = fit2_drones.get(drone_type, 0)
|
||||
if count2 > count1:
|
||||
diffLines.append(f"{drone_type} {count2 - count1}")
|
||||
|
||||
# Get fighter counts
|
||||
fit1_fighters = self.getFighterCounts(fit1)
|
||||
fit2_fighters = self.getFighterCounts(fit2)
|
||||
|
||||
all_fighter_types = set(fit1_fighters.keys()) | set(fit2_fighters.keys())
|
||||
for fighter_type in sorted(all_fighter_types):
|
||||
count1 = fit1_fighters.get(fighter_type, 0)
|
||||
count2 = fit2_fighters.get(fighter_type, 0)
|
||||
if count2 > count1:
|
||||
diffLines.append(f"{fighter_type} {count2 - count1}")
|
||||
|
||||
# Get cargo counts
|
||||
fit1_cargo = self.getCargoCounts(fit1)
|
||||
fit2_cargo = self.getCargoCounts(fit2)
|
||||
|
||||
all_cargo_types = set(fit1_cargo.keys()) | set(fit2_cargo.keys())
|
||||
for cargo_type in sorted(all_cargo_types):
|
||||
count1 = fit1_cargo.get(cargo_type, 0)
|
||||
count2 = fit2_cargo.get(cargo_type, 0)
|
||||
if count2 > count1:
|
||||
diffLines.append(f"{cargo_type} {count2 - count1}")
|
||||
|
||||
# Get implants
|
||||
fit1_implants = self.getImplantNames(fit1)
|
||||
fit2_implants = self.getImplantNames(fit2)
|
||||
|
||||
for implant in sorted(fit2_implants - fit1_implants):
|
||||
diffLines.append(f"{implant} 1")
|
||||
|
||||
# Get boosters
|
||||
fit1_boosters = self.getBoosterNames(fit1)
|
||||
fit2_boosters = self.getBoosterNames(fit2)
|
||||
|
||||
for booster in sorted(fit2_boosters - fit1_boosters):
|
||||
diffLines.append(f"{booster} 1")
|
||||
|
||||
return diffLines
|
||||
|
||||
def getModuleCounts(self, fit):
|
||||
"""Get a counter of module types for a fit.
|
||||
|
||||
Position doesn't matter, just counts by module name.
|
||||
"""
|
||||
counts = Counter()
|
||||
for module in fit.modules:
|
||||
if module.isEmpty:
|
||||
continue
|
||||
# Use item type name for comparison
|
||||
name = module.item.typeName if module.item else ""
|
||||
# Add charge info if present
|
||||
if module.charge:
|
||||
name += f", {module.charge.typeName}"
|
||||
# Add offline suffix if offline
|
||||
if module.state == FittingModuleState.OFFLINE:
|
||||
name += " /offline"
|
||||
counts[name] += 1
|
||||
return counts
|
||||
|
||||
def getDroneCounts(self, fit):
|
||||
"""Get a counter of drone types for a fit."""
|
||||
counts = Counter()
|
||||
for drone in fit.drones:
|
||||
if drone.item:
|
||||
counts[drone.item.typeName] += drone.amount
|
||||
return counts
|
||||
|
||||
def getFighterCounts(self, fit):
|
||||
"""Get a counter of fighter types for a fit."""
|
||||
counts = Counter()
|
||||
for fighter in fit.fighters:
|
||||
if fighter.item:
|
||||
counts[fighter.item.typeName] += fighter.amount
|
||||
return counts
|
||||
|
||||
def getCargoCounts(self, fit):
|
||||
"""Get a counter of cargo items for a fit."""
|
||||
counts = Counter()
|
||||
for cargo in fit.cargo:
|
||||
if cargo.item:
|
||||
counts[cargo.item.typeName] += cargo.amount
|
||||
return counts
|
||||
|
||||
def getImplantNames(self, fit):
|
||||
"""Get a set of implant names for a fit."""
|
||||
names = set()
|
||||
for implant in fit.implants:
|
||||
if implant.item:
|
||||
names.add(implant.item.typeName)
|
||||
return names
|
||||
|
||||
def getBoosterNames(self, fit):
|
||||
"""Get a set of booster names for a fit."""
|
||||
names = set()
|
||||
for booster in fit.boosters:
|
||||
if booster.item:
|
||||
names.add(booster.item.typeName)
|
||||
return names
|
||||
Reference in New Issue
Block a user