From a03c2e4091072656fbbec2e0773ef3492589b01d Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Sat, 17 Jan 2026 16:57:09 +0100 Subject: [PATCH] Add a fit diff view The point is to figure out what items are necessary to transform fit 1 into fit 2 --- Pyfa-Mod | 1 - gui/builtinContextMenus/__init__.py | 1 + gui/builtinContextMenus/fitDiff.py | 48 +++++ gui/fitDiffFrame.py | 292 ++++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 1 deletion(-) delete mode 160000 Pyfa-Mod create mode 100644 gui/builtinContextMenus/fitDiff.py create mode 100644 gui/fitDiffFrame.py diff --git a/Pyfa-Mod b/Pyfa-Mod deleted file mode 160000 index ccebbf970..000000000 --- a/Pyfa-Mod +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ccebbf9708861791bf53b5bfbf15cd2447966a1a diff --git a/gui/builtinContextMenus/__init__.py b/gui/builtinContextMenus/__init__.py index a1a26e591..809c38c0f 100644 --- a/gui/builtinContextMenus/__init__.py +++ b/gui/builtinContextMenus/__init__.py @@ -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 diff --git a/gui/builtinContextMenus/fitDiff.py b/gui/builtinContextMenus/fitDiff.py new file mode 100644 index 000000000..47f1b17bc --- /dev/null +++ b/gui/builtinContextMenus/fitDiff.py @@ -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 . +# ============================================================================= + + +# 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() diff --git a/gui/fitDiffFrame.py b/gui/fitDiffFrame.py new file mode 100644 index 000000000..d8c2528c5 --- /dev/null +++ b/gui/fitDiffFrame.py @@ -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 . +# ============================================================================= + + +# 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: " " + 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