23 Commits

Author SHA1 Message Date
bfd5bbb881 Add a buck/bang column to compare 2026-01-23 18:09:01 +01:00
c64991fb59 Add a "per second" column to some rep modules
Because some have different cycle times, easier to compare
2026-01-21 11:11:07 +01:00
ce5dca9818 Fix breaking every single graph
Done. I've reverted everything back to how it was except for the jam chance additions:

**Restored:**
- All original getters use `miscParams['resist']` (not `.get()`)
- The `resist` input is always available (no conditions)
- Removed the sensorStrength X-axis option and SensorStrength2JamChanceGetter

**Kept:**
- `hasTargets = True` for the graph
- `Distance2JamChanceGetter` class - jam chance vs distance
- Jam chance YDef in yDefs

Now all the original graphs (neut, web, ecm strength, damps, TDs, GDs, TPs) should work exactly as before, and jam chance is available as a new Y-axis option.
2026-01-21 09:35:06 +01:00
38376046d0 Tidy up the code
Done. Now:
- **Distance input** only shows when X-axis is "Distance"
- **Sensor strength input** only shows when X-axis is "Target sensor strength"

Each input field only appears on its respective graph.
2026-01-20 19:16:05 +01:00
38356acd37 Add a jam chance a gainst sensor strength graph 2026-01-20 15:55:35 +01:00
64a11aaa6f Add a jam chance ewar graph 2026-01-20 15:55:33 +01:00
1063a1ab49 Switch the diff format from "$module $quantity" to "$module x$quantity" 2026-01-18 23:17:18 +01:00
959467028c Have CTRL-C copy in EFT format and move the dialogue to CTRL-SHIFT-C 2026-01-18 14:56:45 +01:00
9b4c523aa6 Sort the diff into a more fit like format to make it a bit more readable 2026-01-17 18:20:08 +01:00
411ef933d1 Ignore offline modules and modules with charges when comparing
It's the same module...
2026-01-17 18:06:28 +01:00
0a1c177442 Add a flip diff button 2026-01-17 18:03:44 +01:00
a03c2e4091 Add a fit diff view
The point is to figure out what items are necessary to transform fit 1
into fit 2
2026-01-17 17:14:14 +01:00
564a68e5cb Disable oleacc 2026-01-09 21:54:31 +01:00
aec20c1f5a Fix shift click not actually assigning skills 2026-01-09 21:54:28 +01:00
8800533c8a Shift click opens the skills menu on module 2026-01-09 21:24:33 +01:00
1db6b3372c Add "up to" all skill buttons 2026-01-09 20:54:35 +01:00
169b041677 Add an "all" skills button to any right click skills menu 2026-01-09 20:50:34 +01:00
3a5a9c6e09 Implement "import from clipboard" for skills 2026-01-04 16:02:45 +01:00
eadf18ec00 Add price to market search 2026-01-02 16:12:00 +01:00
b70833ea3e Add a necessary skills tab to ship skills 2026-01-01 22:42:50 +01:00
f12a0fe237 Implement shift-tab to switch between selected characters 2026-01-01 22:30:46 +01:00
de7f6a0523 Move the skills menu to the ship stats window 2026-01-01 22:15:02 +01:00
fa6dc76d10 Actually don't include ship in required skills 2026-01-01 22:02:15 +01:00
17 changed files with 1142 additions and 39 deletions

Submodule Pyfa-Mod deleted from ccebbf9708

View File

@@ -23,7 +23,7 @@ rm -rf build dist
echo "Building binary with PyInstaller..." echo "Building binary with PyInstaller..."
uv run pyinstaller pyfa.spec uv run pyinstaller pyfa.spec
cp oleacc* dist/pyfa/ # cp oleacc* dist/pyfa/
echo "" echo ""
echo "Build complete! Binary is located at: dist/pyfa/pyfa.exe" echo "Build complete! Binary is located at: dist/pyfa/pyfa.exe"

View File

@@ -332,3 +332,78 @@ class Distance2TpStrGetter(SmoothPointGetter):
strMult = calculateMultiplier(strMults) strMult = calculateMultiplier(strMults)
strength = (strMult - 1) * 100 strength = (strMult - 1) * 100
return strength return strength
class Distance2JamChanceGetter(SmoothPointGetter):
_baseResolution = 50
_extraDepth = 2
ECM_ATTRS_GENERAL = ('scanGravimetricStrengthBonus', 'scanLadarStrengthBonus', 'scanMagnetometricStrengthBonus', 'scanRadarStrengthBonus')
ECM_ATTRS_FIGHTERS = ('fighterAbilityECMStrengthGravimetric', 'fighterAbilityECMStrengthLadar', 'fighterAbilityECMStrengthMagnetometric', 'fighterAbilityECMStrengthRadar')
SCAN_TYPES = ('Gravimetric', 'Ladar', 'Magnetometric', 'Radar')
def _getCommonData(self, miscParams, src, tgt):
ecms = []
for mod in src.item.activeModulesIter():
for effectName in ('remoteECMFalloff', 'structureModuleEffectECM'):
if effectName in mod.item.effects:
ecms.append((
tuple(mod.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
mod.maxRange or 0, mod.falloff or 0, True, False))
if 'doomsdayAOEECM' in mod.item.effects:
ecms.append((
tuple(mod.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
max(0, (mod.maxRange or 0) + mod.getModifiedItemAttr('doomsdayAOERange')),
mod.falloff or 0, False, False))
for drone in src.item.activeDronesIter():
if 'entityECMFalloff' in drone.item.effects:
ecms.extend(drone.amountActive * ((
tuple(drone.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_GENERAL),
math.inf, 0, True, True),))
for fighter, ability in src.item.activeFighterAbilityIter():
if ability.effect.name == 'fighterAbilityECM':
ecms.append((
tuple(fighter.getModifiedItemAttr(a) or 0 for a in self.ECM_ATTRS_FIGHTERS),
math.inf, 0, True, False))
# Determine target's strongest sensor type if target is available
targetScanTypeIndex = None
if tgt is not None:
maxStr = -1
for i, scanType in enumerate(self.SCAN_TYPES):
currStr = tgt.item.ship.getModifiedItemAttr('scan%sStrength' % scanType) or 0
if currStr > maxStr:
maxStr = currStr
targetScanTypeIndex = i
return {'ecms': ecms, 'targetScanTypeIndex': targetScanTypeIndex}
def _calculatePoint(self, x, miscParams, src, tgt, commonData):
distance = x
inLockRange = checkLockRange(src=src, distance=distance)
inDroneRange = checkDroneControlRange(src=src, distance=distance)
jamStrengths = []
targetScanTypeIndex = commonData['targetScanTypeIndex']
for strengths, optimal, falloff, needsLock, needsDcr in commonData['ecms']:
if (needsLock and not inLockRange) or (needsDcr and not inDroneRange):
continue
rangeFactor = calculateRangeFactor(srcOptimalRange=optimal, srcFalloffRange=falloff, distance=distance)
# Use the strength matching the target's sensor type
if targetScanTypeIndex is not None and targetScanTypeIndex < len(strengths):
strength = strengths[targetScanTypeIndex]
effectiveStrength = strength * rangeFactor
if effectiveStrength > 0:
jamStrengths.append(effectiveStrength)
if not jamStrengths:
return 0
# Get sensor strength from target
if tgt is None:
return 0
sensorStrength = max([tgt.item.ship.getModifiedItemAttr('scan%sStrength' % scanType)
for scanType in self.SCAN_TYPES]) or 0
if sensorStrength <= 0:
return 100 # If target has no sensor strength, 100% jam chance
# Calculate jam chance: 1 - (1 - (ecmStrength / sensorStrength)) ^ numJammers
retainLockChance = 1
for jamStrength in jamStrengths:
retainLockChance *= 1 - min(1, jamStrength / sensorStrength)
return (1 - retainLockChance) * 100

View File

@@ -21,8 +21,8 @@
import wx import wx
from graphs.data.base import FitGraph, Input, XDef, YDef from graphs.data.base import FitGraph, Input, XDef, YDef
from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2NeutingStrGetter, Distance2TdStrOptimalGetter, from .getter import (Distance2DampStrLockRangeGetter, Distance2EcmStrMaxGetter, Distance2GdStrRangeGetter, Distance2JamChanceGetter, Distance2NeutingStrGetter,
Distance2TpStrGetter, Distance2WebbingStrGetter) Distance2TdStrOptimalGetter, Distance2TpStrGetter, Distance2WebbingStrGetter)
_t = wx.GetTranslation _t = wx.GetTranslation
@@ -31,11 +31,13 @@ class FitEwarStatsGraph(FitGraph):
# UI stuff # UI stuff
internalName = 'ewarStatsGraph' internalName = 'ewarStatsGraph'
name = _t('Electronic Warfare Stats') name = _t('Electronic Warfare Stats')
hasTargets = True
xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))] xDefs = [XDef(handle='distance', unit='km', label=_t('Distance'), mainInput=('distance', 'km'))]
yDefs = [ yDefs = [
YDef(handle='neutStr', unit=None, label=_t('Cap neutralized per second'), selectorLabel=_t('Neuts: cap per second')), YDef(handle='neutStr', unit=None, label=_t('Cap neutralized per second'), selectorLabel=_t('Neuts: cap per second')),
YDef(handle='webStr', unit='%', label=_t('Speed reduction'), selectorLabel=_t('Webs: speed reduction')), YDef(handle='webStr', unit='%', label=_t('Speed reduction'), selectorLabel=_t('Webs: speed reduction')),
YDef(handle='ecmStrMax', unit=None, label=_t('Combined ECM strength'), selectorLabel=_t('ECM: combined strength')), YDef(handle='ecmStrMax', unit=None, label=_t('Combined ECM strength'), selectorLabel=_t('ECM: combined strength')),
YDef(handle='jamChance', unit='%', label=_t('Jam chance'), selectorLabel=_t('ECM: jam chance')),
YDef(handle='dampStrLockRange', unit='%', label=_t('Lock range reduction'), selectorLabel=_t('Damps: lock range reduction')), YDef(handle='dampStrLockRange', unit='%', label=_t('Lock range reduction'), selectorLabel=_t('Damps: lock range reduction')),
YDef(handle='tdStrOptimal', unit='%', label=_t('Turret optimal range reduction'), selectorLabel=_t('TDs: turret optimal range reduction')), YDef(handle='tdStrOptimal', unit='%', label=_t('Turret optimal range reduction'), selectorLabel=_t('TDs: turret optimal range reduction')),
YDef(handle='gdStrRange', unit='%', label=_t('Missile flight range reduction'), selectorLabel=_t('GDs: missile flight range reduction')), YDef(handle='gdStrRange', unit='%', label=_t('Missile flight range reduction'), selectorLabel=_t('GDs: missile flight range reduction')),
@@ -53,6 +55,7 @@ class FitEwarStatsGraph(FitGraph):
('distance', 'neutStr'): Distance2NeutingStrGetter, ('distance', 'neutStr'): Distance2NeutingStrGetter,
('distance', 'webStr'): Distance2WebbingStrGetter, ('distance', 'webStr'): Distance2WebbingStrGetter,
('distance', 'ecmStrMax'): Distance2EcmStrMaxGetter, ('distance', 'ecmStrMax'): Distance2EcmStrMaxGetter,
('distance', 'jamChance'): Distance2JamChanceGetter,
('distance', 'dampStrLockRange'): Distance2DampStrLockRangeGetter, ('distance', 'dampStrLockRange'): Distance2DampStrLockRangeGetter,
('distance', 'tdStrOptimal'): Distance2TdStrOptimalGetter, ('distance', 'tdStrOptimal'): Distance2TdStrOptimalGetter,
('distance', 'gdStrRange'): Distance2GdStrRangeGetter, ('distance', 'gdStrRange'): Distance2GdStrRangeGetter,

View File

@@ -18,6 +18,7 @@ from gui.builtinContextMenus import resistMode
from gui.builtinContextMenus.targetProfile import editor from gui.builtinContextMenus.targetProfile import editor
# Item info # Item info
from gui.builtinContextMenus import itemStats from gui.builtinContextMenus import itemStats
from gui.builtinContextMenus import fitDiff
from gui.builtinContextMenus import itemMarketJump 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 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 from gui.builtinContextMenus import fitPilotSecurity # Not really an item info but want to keep it here

View 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()

View File

@@ -79,7 +79,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
label = _t("Level %s") % i label = _t("Level %s") % i
id = ContextMenuSingle.nextID() id = ContextMenuSingle.nextID()
self.skillIds[id] = (skill, i) self.skillIds[id] = (skill, i, False) # False = not "up" for individual skills
menuItem = wx.MenuItem(rootMenu, id, label, kind=wx.ITEM_RADIO) menuItem = wx.MenuItem(rootMenu, id, label, kind=wx.ITEM_RADIO)
rootMenu.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem) rootMenu.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
return menuItem return menuItem
@@ -89,6 +89,40 @@ class ChangeAffectingSkills(ContextMenuSingle):
self.skillIds = {} self.skillIds = {}
sub = wx.Menu() sub = wx.Menu()
# When rootMenu is None (direct menu access), use sub for binding on Windows
bindMenu = rootMenu if (rootMenu is not None and msw) else (sub if msw else None)
# Add "All" entry
allItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), _t("All"))
grandSubAll = wx.Menu()
allItem.SetSubMenu(grandSubAll)
# For "All", only show levels 1-5 (not "Not Learned")
for i in range(1, 6):
id = ContextMenuSingle.nextID()
self.skillIds[id] = (None, i, False) # None indicates "All" was selected, False = not "up"
label = _t("Level %s") % i
menuItem = wx.MenuItem(bindMenu if bindMenu else grandSubAll, id, label, kind=wx.ITEM_RADIO)
grandSubAll.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
grandSubAll.Append(menuItem)
# Add separator
grandSubAll.AppendSeparator()
# Add "Up Level 1..5" entries
for i in range(1, 6):
id = ContextMenuSingle.nextID()
self.skillIds[id] = (None, i, True) # None indicates "All" was selected, True = "up" only
label = _t("Up Level %s") % i
menuItem = wx.MenuItem(bindMenu if bindMenu else grandSubAll, id, label, kind=wx.ITEM_RADIO)
grandSubAll.Bind(wx.EVT_MENU, self.handleSkillChange, menuItem)
grandSubAll.Append(menuItem)
sub.Append(allItem)
# Add separator
sub.AppendSeparator()
for skill in self.skills: for skill in self.skills:
skillItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), skill.item.name) skillItem = wx.MenuItem(sub, ContextMenuSingle.nextID(), skill.item.name)
grandSub = wx.Menu() grandSub = wx.Menu()
@@ -99,7 +133,7 @@ class ChangeAffectingSkills(ContextMenuSingle):
skillItem.SetBitmap(bitmap) skillItem.SetBitmap(bitmap)
for i in range(-1, 6): for i in range(-1, 6):
levelItem = self.addSkill(rootMenu if msw else grandSub, skill, i) levelItem = self.addSkill(bindMenu if bindMenu else grandSub, skill, i)
grandSub.Append(levelItem) grandSub.Append(levelItem)
if (not skill.learned and i == -1) or (skill.learned and skill.level == i): if (not skill.learned and i == -1) or (skill.learned and skill.level == i):
levelItem.Check(True) levelItem.Check(True)
@@ -108,9 +142,24 @@ class ChangeAffectingSkills(ContextMenuSingle):
return sub return sub
def handleSkillChange(self, event): def handleSkillChange(self, event):
skill, level = self.skillIds[event.Id] skill, level, up = self.skillIds[event.Id]
if skill is None: # "All" was selected
for s in self.skills:
if up:
# Only increase skill if it's below the target level
if not s.learned or s.level < level:
self.sChar.changeLevel(self.charID, s.item.ID, level)
else:
self.sChar.changeLevel(self.charID, s.item.ID, level)
else:
if up:
# Only increase skill if it's below the target level
if not skill.learned or skill.level < level:
self.sChar.changeLevel(self.charID, skill.item.ID, level)
else:
self.sChar.changeLevel(self.charID, skill.item.ID, level)
self.sChar.changeLevel(self.charID, skill.item.ID, level)
fitID = self.mainFrame.getActiveFit() fitID = self.mainFrame.getActiveFit()
self.sFit.changeChar(fitID, self.charID) self.sFit.changeChar(fitID, self.charID)

View File

@@ -9,6 +9,46 @@ from gui.utils.numberFormatter import formatAmount
_t = wx.GetTranslation _t = wx.GetTranslation
# Mapping of repair/transfer amount attributes to their duration attribute and display name
PER_SECOND_ATTRIBUTES = {
"armorDamageAmount": {
"durationAttr": "duration",
"displayName": "Armor Hitpoints Repaired per second",
"unit": "HP/s"
},
"shieldBonus": {
"durationAttr": "duration",
"displayName": "Shield Hitpoints Repaired per second",
"unit": "HP/s"
},
"powerTransferAmount": {
"durationAttr": "duration",
"displayName": "Capacitor Transferred per second",
"unit": "GJ/s"
}
}
class PerSecondAttributeInfo:
"""Helper class to store info about computed per-second attributes"""
def __init__(self, displayName, unit):
self.displayName = displayName
self.unit = PerSecondUnit(unit)
class PerSecondUnit:
"""Helper class to mimic the Unit class for per-second attributes"""
def __init__(self, displayName):
self.displayName = displayName
self.name = ""
class PerSecondAttributeValue:
"""Helper class to store computed per-second attribute values"""
def __init__(self, value):
self.value = value
self.info = None # Will be set when adding to attrs
def defaultSort(item): def defaultSort(item):
return (item.metaLevel or 0, item.name) return (item.metaLevel or 0, item.name)
@@ -36,8 +76,12 @@ class ItemCompare(wx.Panel):
self.item = item self.item = item
self.items = sorted(items, key=defaultSort) self.items = sorted(items, key=defaultSort)
self.attrs = {} self.attrs = {}
self.computedAttrs = {} # Store computed per-second attributes
self.HighlightOn = wx.Colour(255, 255, 0, wx.ALPHA_OPAQUE) self.HighlightOn = wx.Colour(255, 255, 0, wx.ALPHA_OPAQUE)
self.highlightedNames = [] self.highlightedNames = []
self.bangBuckColumn = None # Store the column selected for bang/buck calculation
self.bangBuckColumnName = None # Store the display name of the selected column
self.columnHighlightColour = wx.Colour(173, 216, 230, wx.ALPHA_OPAQUE) # Light blue for column highlight
# get a dict of attrName: attrInfo of all unique attributes across all items # get a dict of attrName: attrInfo of all unique attributes across all items
for item in self.items: for item in self.items:
@@ -45,23 +89,66 @@ class ItemCompare(wx.Panel):
if item.attributes[attr].info.displayName: if item.attributes[attr].info.displayName:
self.attrs[attr] = item.attributes[attr].info self.attrs[attr] = item.attributes[attr].info
# Compute per-second attributes for items that have both the amount and duration
for perSecondKey, config in PER_SECOND_ATTRIBUTES.items():
amountAttr = perSecondKey
durationAttr = config["durationAttr"]
perSecondAttrName = f"{perSecondKey}_per_second"
# Check if any item has both attributes
hasPerSecondAttr = False
for item in self.items:
if amountAttr in item.attributes and durationAttr in item.attributes:
hasPerSecondAttr = True
break
if hasPerSecondAttr:
# Add the per-second attribute info to attrs
perSecondInfo = PerSecondAttributeInfo(config["displayName"], config["unit"])
self.attrs[perSecondAttrName] = perSecondInfo
self.computedAttrs[perSecondAttrName] = {
"amountAttr": amountAttr,
"durationAttr": durationAttr
}
# Process attributes for items and find ones that differ # Process attributes for items and find ones that differ
for attr in list(self.attrs.keys()): for attr in list(self.attrs.keys()):
value = None value = None
for item in self.items: for item in self.items:
# we can automatically break here if this item doesn't have the attribute, # Check if this is a computed attribute
# as that means at least one item did if attr in self.computedAttrs:
if attr not in item.attributes: computed = self.computedAttrs[attr]
break amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
# this is the first attribute for the item set, set the initial value # Item needs both attributes to compute per-second value
if value is None: if amountAttr not in item.attributes or durationAttr not in item.attributes:
value = item.attributes[attr].value break
continue
if attr not in item.attributes or item.attributes[attr].value != value: # Calculate per-second value
break amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
# Duration is in milliseconds, convert to seconds
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
if value is None:
value = perSecondValue
continue
if perSecondValue != value:
break
else:
# Regular attribute handling
if attr not in item.attributes:
break
if value is None:
value = item.attributes[attr].value
continue
if item.attributes[attr].value != value:
break
else: else:
# attribute values were all the same, delete # attribute values were all the same, delete
del self.attrs[attr] del self.attrs[attr]
@@ -89,6 +176,7 @@ class ItemCompare(wx.Panel):
self.toggleViewBtn.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleViewMode) self.toggleViewBtn.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleViewMode)
self.Bind(wx.EVT_LIST_COL_CLICK, self.SortCompareCols) self.Bind(wx.EVT_LIST_COL_CLICK, self.SortCompareCols)
self.Bind(wx.EVT_LIST_COL_RIGHT_CLICK, self.OnColumnRightClick)
self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.HighlightRow) self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.HighlightRow)
@@ -105,6 +193,23 @@ class ItemCompare(wx.Panel):
self.Thaw() self.Thaw()
event.Skip() event.Skip()
def OnColumnRightClick(self, event):
column = event.GetColumn()
# Column 0 is "Item", column len(self.attrs) + 1 is "Price", len(self.attrs) + 2 is "Buck/bang"
# Only allow selecting attribute columns (1 to len(self.attrs))
if 1 <= column <= len(self.attrs):
# If clicking the same column, deselect it
if self.bangBuckColumn == column:
self.bangBuckColumn = None
self.bangBuckColumnName = None
else:
self.bangBuckColumn = column
# Get the display name of the selected column
attr_key = list(self.attrs.keys())[column - 1]
self.bangBuckColumnName = self.attrs[attr_key].displayName if self.attrs[attr_key].displayName else attr_key
self.UpdateList()
event.Skip()
def SortCompareCols(self, event): def SortCompareCols(self, event):
self.Freeze() self.Freeze()
self.paramList.ClearAll() self.paramList.ClearAll()
@@ -148,12 +253,32 @@ class ItemCompare(wx.Panel):
# Remember to reduce by 1, because the attrs array # Remember to reduce by 1, because the attrs array
# starts at 0 while the list has the item name as column 0. # starts at 0 while the list has the item name as column 0.
attr = str(list(self.attrs.keys())[sort - 1]) attr = str(list(self.attrs.keys())[sort - 1])
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0 # Handle computed attributes for sorting
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
func = lambda _val: (_val.attributes[amountAttr].value / (_val.attributes[durationAttr].value / 1000.0)) if (amountAttr in _val.attributes and durationAttr in _val.attributes and _val.attributes[durationAttr].value > 0) else 0.0
else:
func = lambda _val: _val.attributes[attr].value if attr in _val.attributes else 0.0
# Clicked on a column that's not part of our array (price most likely) # Clicked on a column that's not part of our array (price most likely)
except IndexError: except IndexError:
# Price # Price
if sort == len(self.attrs) + 1: if sort == len(self.attrs) + 1:
func = lambda i: i.price.price if i.price.price != 0 else float("Inf") func = lambda i: i.price.price if i.price.price != 0 else float("Inf")
# Buck/bang
elif sort == len(self.attrs) + 2:
if self.bangBuckColumn is not None:
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
if attr_key in self.computedAttrs:
computed = self.computedAttrs[attr_key]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
func = lambda i: (i.price.price / (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) if (amountAttr in i.attributes and durationAttr in i.attributes and i.attributes[durationAttr].value > 0 and (i.attributes[amountAttr].value / (i.attributes[durationAttr].value / 1000.0)) > 0) else float("Inf"))
else:
func = lambda i: (i.price.price / i.attributes[attr_key].value if (attr_key in i.attributes and i.attributes[attr_key].value > 0) else float("Inf"))
else:
func = defaultSort
# Something else # Something else
else: else:
self.sortReverse = False self.sortReverse = False
@@ -166,18 +291,49 @@ class ItemCompare(wx.Panel):
for i, attr in enumerate(self.attrs.keys()): for i, attr in enumerate(self.attrs.keys()):
name = self.attrs[attr].displayName if self.attrs[attr].displayName else attr name = self.attrs[attr].displayName if self.attrs[attr].displayName else attr
# Add indicator if this column is selected for bang/buck calculation
if self.bangBuckColumn == i + 1:
name = "" + name
self.paramList.InsertColumn(i + 1, name) self.paramList.InsertColumn(i + 1, name)
self.paramList.SetColumnWidth(i + 1, 120) self.paramList.SetColumnWidth(i + 1, 120)
self.paramList.InsertColumn(len(self.attrs) + 1, _t("Price")) self.paramList.InsertColumn(len(self.attrs) + 1, _t("Price"))
self.paramList.SetColumnWidth(len(self.attrs) + 1, 60) self.paramList.SetColumnWidth(len(self.attrs) + 1, 60)
# Add Buck/bang column header
buckBangHeader = _t("Buck/bang")
if self.bangBuckColumnName:
buckBangHeader = _t("Buck/bang ({})").format(self.bangBuckColumnName)
self.paramList.InsertColumn(len(self.attrs) + 2, buckBangHeader)
self.paramList.SetColumnWidth(len(self.attrs) + 2, 80)
toHighlight = [] toHighlight = []
for item in self.items: for item in self.items:
i = self.paramList.InsertItem(self.paramList.GetItemCount(), item.name) i = self.paramList.InsertItem(self.paramList.GetItemCount(), item.name)
for x, attr in enumerate(self.attrs.keys()): for x, attr in enumerate(self.attrs.keys()):
if attr in item.attributes: # Handle computed attributes
if attr in self.computedAttrs:
computed = self.computedAttrs[attr]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
# Item needs both attributes to display per-second value
if amountAttr in item.attributes and durationAttr in item.attributes:
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
# Duration is in milliseconds, convert to seconds
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
info = self.attrs[attr]
if self.toggleView == 1:
valueUnit = formatAmount(perSecondValue, 3, 0, 0) + " " + info.unit.displayName
else:
valueUnit = str(perSecondValue)
self.paramList.SetItem(i, x + 1, valueUnit)
# else: leave cell empty
elif attr in item.attributes:
info = self.attrs[attr] info = self.attrs[attr]
value = item.attributes[attr].value value = item.attributes[attr].value
if self.toggleView != 1: if self.toggleView != 1:
@@ -191,6 +347,27 @@ class ItemCompare(wx.Panel):
# Add prices # Add prices
self.paramList.SetItem(i, len(self.attrs) + 1, formatAmount(item.price.price, 3, 3, 9, currency=True) if item.price.price else "") self.paramList.SetItem(i, len(self.attrs) + 1, formatAmount(item.price.price, 3, 3, 9, currency=True) if item.price.price else "")
# Add buck/bang values
if self.bangBuckColumn is not None and item.price.price and item.price.price > 0:
attr_key = list(self.attrs.keys())[self.bangBuckColumn - 1]
if attr_key in self.computedAttrs:
computed = self.computedAttrs[attr_key]
amountAttr = computed["amountAttr"]
durationAttr = computed["durationAttr"]
if amountAttr in item.attributes and durationAttr in item.attributes:
amountValue = item.attributes[amountAttr].value
durationValue = item.attributes[durationAttr].value
perSecondValue = amountValue / (durationValue / 1000.0) if durationValue > 0 else 0
if perSecondValue > 0:
buckBangValue = item.price.price / perSecondValue
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
elif attr_key in item.attributes:
attrValue = item.attributes[attr_key].value
if attrValue > 0:
buckBangValue = item.price.price / attrValue
self.paramList.SetItem(i, len(self.attrs) + 2, formatAmount(buckBangValue, 3, 3, 9, currency=True))
if item.name in self.highlightedNames: if item.name in self.highlightedNames:
toHighlight.append(i) toHighlight.append(i)

View File

@@ -0,0 +1,220 @@
# noinspection PyPackageRequirements
import wx
import itertools
import gui.mainFrame
from eos.saveddata.character import Skill
from eos.saveddata.fighter import Fighter as es_Fighter
from eos.saveddata.module import Module as es_Module
from eos.saveddata.ship import Ship
from gui.utils.clipboard import toClipboard
from gui.utils.numberFormatter import formatAmount
from service.fit import Fit
_t = wx.GetTranslation
class ItemSkills(wx.Panel):
def __init__(self, parent, stuff, item):
wx.Panel.__init__(self, parent)
self.stuff = stuff
self.item = item
mainSizer = wx.BoxSizer(wx.HORIZONTAL)
leftPanel = wx.Panel(self)
leftSizer = wx.BoxSizer(wx.VERTICAL)
leftPanel.SetSizer(leftSizer)
header = wx.StaticText(leftPanel, wx.ID_ANY, _t("Components"))
font = header.GetFont()
font.SetWeight(wx.FONTWEIGHT_BOLD)
header.SetFont(font)
leftSizer.Add(header, 0, wx.ALL, 5)
self.checkboxes = {}
components = [
("Ship", "ship"),
("Modules", "modules"),
("Drones", "drones"),
("Fighters", "fighters"),
("Cargo", "cargo"),
("Implants", "appliedImplants"),
("Boosters", "boosters"),
("Necessary", "necessary"),
]
for label, key in components:
cb = wx.CheckBox(leftPanel, wx.ID_ANY, label)
cb.SetValue(True)
cb.Bind(wx.EVT_CHECKBOX, self.onCheckboxChange)
self.checkboxes[key] = cb
leftSizer.Add(cb, 0, wx.ALL, 2)
leftSizer.AddStretchSpacer()
mainSizer.Add(leftPanel, 0, wx.EXPAND | wx.ALL, 5)
rightPanel = wx.Panel(self)
rightSizer = wx.BoxSizer(wx.VERTICAL)
rightPanel.SetSizer(rightSizer)
headerRight = wx.StaticText(rightPanel, wx.ID_ANY, _t("Skills"))
fontRight = headerRight.GetFont()
fontRight.SetWeight(wx.FONTWEIGHT_BOLD)
headerRight.SetFont(fontRight)
rightSizer.Add(headerRight, 0, wx.ALL, 5)
self.skillsText = wx.TextCtrl(rightPanel, wx.ID_ANY, "", style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_DONTWRAP)
font = wx.Font(9, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
self.skillsText.SetFont(font)
rightSizer.Add(self.skillsText, 1, wx.EXPAND | wx.ALL, 5)
mainSizer.Add(rightPanel, 1, wx.EXPAND | wx.ALL, 5)
self.SetSizer(mainSizer)
self.nbContainer = parent if isinstance(parent, wx.Notebook) else None
if self.nbContainer:
self.nbContainer.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.onTabChanged)
self.updateSkills()
def onCheckboxChange(self, event):
self.updateSkills()
self._copyToClipboard()
def updateSkills(self):
fitID = gui.mainFrame.MainFrame.getInstance().getActiveFit()
if fitID is None:
self.skillsText.SetValue("")
self._updateCheckboxStates(None)
return
sFit = Fit.getInstance()
fit = sFit.getFit(fitID)
if fit is None:
self.skillsText.SetValue("")
self._updateCheckboxStates(None)
return
if not fit.calculated:
fit.calculate()
self._updateCheckboxStates(fit)
char = fit.character
skillsMap = {}
items = []
if self.checkboxes["ship"].GetValue():
items.append(fit.ship)
if self.checkboxes["modules"].GetValue():
items.extend(fit.modules)
if self.checkboxes["drones"].GetValue():
items.extend(fit.drones)
if self.checkboxes["fighters"].GetValue():
items.extend(fit.fighters)
if self.checkboxes["cargo"].GetValue():
items.extend(fit.cargo)
if self.checkboxes["appliedImplants"].GetValue():
items.extend(fit.appliedImplants)
if self.checkboxes["boosters"].GetValue():
items.extend(fit.boosters)
for thing in items:
self._collectAffectingSkills(thing, char, skillsMap)
if self.checkboxes["necessary"].GetValue():
self._collectRequiredSkills(items, char, skillsMap)
skillsList = ""
for skillName in sorted(skillsMap):
charLevel = skillsMap[skillName]
for level in range(1, charLevel + 1):
skillsList += "%s %d\n" % (skillName, level)
self.skillsText.SetValue(skillsList)
self._copyToClipboard()
def _copyToClipboard(self):
skillsText = self.skillsText.GetValue()
if skillsText:
toClipboard(skillsText)
def onTabChanged(self, event):
if self.nbContainer:
pageIndex = self.nbContainer.FindPage(self)
if pageIndex != -1 and event.GetSelection() == pageIndex:
self.updateSkills()
event.Skip()
def _updateCheckboxStates(self, fit):
if fit is None:
for cb in self.checkboxes.values():
cb.Enable(False)
return
self.checkboxes["ship"].Enable(True)
self.checkboxes["modules"].Enable(len(fit.modules) > 0)
self.checkboxes["drones"].Enable(len(fit.drones) > 0)
self.checkboxes["fighters"].Enable(len(fit.fighters) > 0)
self.checkboxes["cargo"].Enable(len(fit.cargo) > 0)
self.checkboxes["appliedImplants"].Enable(len(fit.appliedImplants) > 0)
self.checkboxes["boosters"].Enable(len(fit.boosters) > 0)
self.checkboxes["necessary"].Enable(True)
def _collectAffectingSkills(self, thing, char, skillsMap):
for attr in ("item", "charge"):
if attr == "charge" and isinstance(thing, es_Fighter):
continue
subThing = getattr(thing, attr, None)
if subThing is None:
continue
if isinstance(thing, es_Fighter) and attr == "charge":
continue
if attr == "charge":
cont = getattr(thing, "chargeModifiedAttributes", None)
else:
cont = getattr(thing, "itemModifiedAttributes", None)
if cont is not None:
for attrName in cont.iterAfflictions():
for fit, afflictors in cont.getAfflictions(attrName).items():
for afflictor, operator, stackingGroup, preResAmount, postResAmount, used in afflictors:
if isinstance(afflictor, Skill) and afflictor.character == char:
skillName = afflictor.item.name
if skillName not in skillsMap:
skillsMap[skillName] = afflictor.level
elif skillsMap[skillName] < afflictor.level:
skillsMap[skillName] = afflictor.level
def _collectRequiredSkills(self, items, char, skillsMap):
"""Collect required skills from items (necessary to use them)"""
for thing in items:
for attr in ("item", "charge"):
if attr == "charge" and isinstance(thing, es_Fighter):
continue
subThing = getattr(thing, attr, None)
if subThing is None:
continue
if isinstance(thing, es_Fighter) and attr == "charge":
continue
if hasattr(subThing, "requiredSkills"):
for reqSkill, level in subThing.requiredSkills.items():
skillName = reqSkill.name
charSkill = char.getSkill(reqSkill) if char else None
charLevel = charSkill.level if charSkill else 0
if charLevel > 0:
if skillName not in skillsMap:
skillsMap[skillName] = charLevel
elif skillsMap[skillName] < charLevel:
skillsMap[skillName] = charLevel
else:
if skillName not in skillsMap:
skillsMap[skillName] = level
elif skillsMap[skillName] < level:
skillsMap[skillName] = level

View File

@@ -23,6 +23,7 @@ class ItemView(Display):
DEFAULT_COLS = ["Base Icon", DEFAULT_COLS = ["Base Icon",
"Base Name", "Base Name",
"Price",
"attr:power,,,True", "attr:power,,,True",
"attr:cpu,,,True"] "attr:cpu,,,True"]

View File

@@ -20,6 +20,7 @@
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import wx import wx
from eos.gamedata import Item
from eos.saveddata.cargo import Cargo from eos.saveddata.cargo import Cargo
from eos.saveddata.drone import Drone from eos.saveddata.drone import Drone
from eos.saveddata.fighter import Fighter from eos.saveddata.fighter import Fighter
@@ -53,7 +54,14 @@ class Price(ViewColumn):
self.imageId = fittingView.imageList.GetImageIndex("totalPrice_small", "gui") self.imageId = fittingView.imageList.GetImageIndex("totalPrice_small", "gui")
def getText(self, stuff): def getText(self, stuff):
if stuff.item is None or stuff.item.group.name == "Ship Modifiers": if isinstance(stuff, Item):
item = stuff
else:
if not hasattr(stuff, "item") or stuff.item is None:
return ""
item = stuff.item
if item.group.name == "Ship Modifiers":
return "" return ""
if hasattr(stuff, "isEmpty"): if hasattr(stuff, "isEmpty"):
@@ -63,7 +71,7 @@ class Price(ViewColumn):
if isinstance(stuff, Module) and stuff.isMutated: if isinstance(stuff, Module) and stuff.isMutated:
return "" return ""
priceObj = stuff.item.price priceObj = item.price
if not priceObj.isValid(): if not priceObj.isValid():
return False return False
@@ -79,7 +87,11 @@ class Price(ViewColumn):
display.SetItem(colItem) display.SetItem(colItem)
sPrice.getPrices([mod.item], callback, waitforthread=True) if isinstance(mod, Item):
item = mod
else:
item = mod.item
sPrice.getPrices([item], callback, waitforthread=True)
def getImageId(self, mod): def getImageId(self, mod):
return -1 return -1

View File

@@ -668,6 +668,21 @@ class FittingView(d.Display):
contexts.append(fullContext) contexts.append(fullContext)
contexts.append(("fittingShip", _t("Ship") if not fit.isStructure else _t("Citadel"))) contexts.append(("fittingShip", _t("Ship") if not fit.isStructure else _t("Citadel")))
# Check if shift is held for direct skills menu access
if wx.GetKeyState(wx.WXK_SHIFT):
from gui.builtinContextMenus.skillAffectors import ChangeAffectingSkills
for fullContext in contexts:
srcContext = fullContext[0]
itemContext = fullContext[1] if len(fullContext) > 1 else None
skillsMenu = ChangeAffectingSkills()
if skillsMenu.display(self, srcContext, mainMod):
# On Windows, menu items need to be bound to the menu shown with PopupMenu
# We pass None as rootMenu so items are bound to their parent submenus
sub = skillsMenu.getSubMenu(self, srcContext, mainMod, None, 0, None)
if sub:
self.PopupMenu(sub)
return
menu = ContextMenu.getMenu(self, mainMod, selection, *contexts) menu = ContextMenu.getMenu(self, mainMod, selection, *contexts)
self.PopupMenu(menu) self.PopupMenu(menu)

View File

@@ -29,7 +29,7 @@ import config
import gui.globalEvents as GE import gui.globalEvents as GE
import gui.mainFrame import gui.mainFrame
from gui.bitmap_loader import BitmapLoader from gui.bitmap_loader import BitmapLoader
from gui.utils.clipboard import toClipboard from gui.utils.clipboard import toClipboard, fromClipboard
from service.character import Character from service.character import Character
from service.fit import Fit from service.fit import Fit
@@ -50,6 +50,10 @@ class CharacterSelection(wx.Panel):
# cache current selection to fall back in case we choose to open char editor # cache current selection to fall back in case we choose to open char editor
self.charCache = None self.charCache = None
# history for Shift-Tab navigation
self.charHistory = []
self._updatingFromHistory = False
self.charChoice = wx.Choice(self) self.charChoice = wx.Choice(self)
mainSizer.Add(self.charChoice, 1, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT | wx.LEFT, 3) mainSizer.Add(self.charChoice, 1, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT | wx.LEFT, 3)
@@ -92,7 +96,7 @@ class CharacterSelection(wx.Panel):
sFit = Fit.getInstance() sFit = Fit.getInstance()
fit = sFit.getFit(self.mainFrame.getActiveFit()) fit = sFit.getFit(self.mainFrame.getActiveFit())
if not fit or not self.needsSkills: if not fit:
return return
pos = wx.GetMousePosition() pos = wx.GetMousePosition()
@@ -100,17 +104,23 @@ class CharacterSelection(wx.Panel):
menu = wx.Menu() menu = wx.Menu()
grantItem = menu.Append(wx.ID_ANY, _t("Grant Missing Skills")) if self.needsSkills:
self.Bind(wx.EVT_MENU, self.grantMissingSkills, grantItem) grantItem = menu.Append(wx.ID_ANY, _t("Grant Missing Skills"))
self.Bind(wx.EVT_MENU, self.grantMissingSkills, grantItem)
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills")) exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills"))
self.Bind(wx.EVT_MENU, self.exportSkills, exportItem) self.Bind(wx.EVT_MENU, self.exportSkills, exportItem)
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills (condensed)")) exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills (condensed)"))
self.Bind(wx.EVT_MENU, self.exportSkillsCondensed, exportItem) self.Bind(wx.EVT_MENU, self.exportSkillsCondensed, exportItem)
exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills (EVEMon)")) exportItem = menu.Append(wx.ID_ANY, _t("Copy Missing Skills (EVEMon)"))
self.Bind(wx.EVT_MENU, self.exportSkillsEveMon, exportItem) self.Bind(wx.EVT_MENU, self.exportSkillsEveMon, exportItem)
menu.AppendSeparator()
importItem = menu.Append(wx.ID_ANY, _t("Import Skills from Clipboard"))
self.Bind(wx.EVT_MENU, self.importSkillsFromClipboard, importItem)
self.PopupMenu(menu, pos) self.PopupMenu(menu, pos)
@@ -189,6 +199,13 @@ class CharacterSelection(wx.Panel):
sFit = Fit.getInstance() sFit = Fit.getInstance()
sFit.changeChar(fitID, charID) sFit.changeChar(fitID, charID)
self.charCache = self.charChoice.GetCurrentSelection() self.charCache = self.charChoice.GetCurrentSelection()
if not self._updatingFromHistory and charID is not None:
currentChar = self.getActiveCharacter()
if currentChar is not None:
if not self.charHistory or self.charHistory[-1] != currentChar:
self.charHistory.append(currentChar)
wx.PostEvent(self.mainFrame, GE.FitChanged(fitIDs=(fitID,))) wx.PostEvent(self.mainFrame, GE.FitChanged(fitIDs=(fitID,)))
def toggleRefreshButton(self): def toggleRefreshButton(self):
@@ -211,6 +228,29 @@ class CharacterSelection(wx.Panel):
return False return False
def selectPreviousChar(self):
currentChar = self.getActiveCharacter()
if currentChar is None:
return
if not self.charHistory:
return
if self.charHistory and self.charHistory[-1] == currentChar:
self.charHistory.pop()
if not self.charHistory:
return
prevChar = self.charHistory.pop()
if currentChar != prevChar:
self.charHistory.append(currentChar)
self._updatingFromHistory = True
if self.selectChar(prevChar):
self.charChanged(None)
self._updatingFromHistory = False
def fitChanged(self, event): def fitChanged(self, event):
""" """
When fit is changed, or new fit is selected When fit is changed, or new fit is selected
@@ -222,7 +262,7 @@ class CharacterSelection(wx.Panel):
self.charChoice.Enable(activeFitID is not None) self.charChoice.Enable(activeFitID is not None)
choice = self.charChoice choice = self.charChoice
sFit = Fit.getInstance() sFit = Fit.getInstance()
currCharID = choice.GetClientData(choice.GetCurrentSelection()) currCharID = choice.GetClientData(choice.GetCurrentSelection()) if choice.GetCurrentSelection() != -1 else None
fit = sFit.getFit(activeFitID) fit = sFit.getFit(activeFitID)
newCharID = fit.character.ID if fit is not None else None newCharID = fit.character.ID if fit is not None else None
@@ -256,6 +296,9 @@ class CharacterSelection(wx.Panel):
self.selectChar(sChar.all5ID()) self.selectChar(sChar.all5ID())
elif currCharID != newCharID: elif currCharID != newCharID:
if currCharID is not None and not self._updatingFromHistory:
if not self.charHistory or self.charHistory[-1] != currCharID:
self.charHistory.append(currCharID)
self.selectChar(newCharID) self.selectChar(newCharID)
if not fit.calculated: if not fit.calculated:
self.charChanged(None) self.charChanged(None)
@@ -289,6 +332,83 @@ class CharacterSelection(wx.Panel):
toClipboard(list) toClipboard(list)
def importSkillsFromClipboard(self, evt):
charID = self.getActiveCharacter()
if charID is None:
return
sChar = Character.getInstance()
char = sChar.getCharacter(charID)
text = fromClipboard()
if not text:
with wx.MessageDialog(self, _t("Clipboard is empty"), _t("Error"), wx.OK | wx.ICON_ERROR) as dlg:
dlg.ShowModal()
return
try:
lines = text.strip().splitlines()
imported = 0
errors = []
for line in lines:
line = line.strip()
if not line:
continue
try:
parts = line.rsplit(None, 1)
if len(parts) != 2:
errors.append(_t("Invalid format: {}").format(line))
continue
skillName = parts[0]
levelStr = parts[1]
try:
level = int(levelStr)
except ValueError:
try:
level = roman.fromRoman(levelStr.upper())
except (roman.InvalidRomanNumeralError, ValueError):
errors.append(_t("Invalid level format: {}").format(line))
continue
if level < 0 or level > 5:
errors.append(_t("Level must be between 0 and 5: {}").format(line))
continue
skill = char.getSkill(skillName)
sChar.changeLevel(charID, skill.item.ID, level)
imported += 1
except KeyError as e:
errors.append(_t("Skill not found: {}").format(skillName))
pyfalog.error("Skill not found: '{}'", skillName)
except Exception as e:
errors.append(_t("Error processing line '{}': {}").format(line, str(e)))
pyfalog.error("Error importing skill from line '{}': {}", line, e)
if imported > 0:
self.refreshCharacterList()
wx.PostEvent(self.mainFrame, GE.CharListUpdated())
fitID = self.mainFrame.getActiveFit()
if fitID is not None:
wx.PostEvent(self.mainFrame, GE.FitChanged(fitIDs=(fitID,)))
if errors:
errorMsg = _t("Imported {} skill(s). Errors:\n{}").format(imported, "\n".join(errors))
with wx.MessageDialog(self, errorMsg, _t("Import Skills"), wx.OK | wx.ICON_WARNING) as dlg:
dlg.ShowModal()
elif imported > 0:
with wx.MessageDialog(self, _t("Successfully imported {} skill(s)").format(imported), _t("Import Skills"), wx.OK) as dlg:
dlg.ShowModal()
except Exception as e:
pyfalog.error("Error importing skills from clipboard: {}", e)
with wx.MessageDialog(self, _t("Error importing skills. Please check the log file."), _t("Error"), wx.OK | wx.ICON_ERROR) as dlg:
dlg.ShowModal()
def _buildSkillsTooltip(self, reqs, currItem="", tabulationLevel=0): def _buildSkillsTooltip(self, reqs, currItem="", tabulationLevel=0):
tip = "" tip = ""
sCharacter = Character.getInstance() sCharacter = Character.getInstance()

326
gui/fitDiffFrame.py Normal file
View File

@@ -0,0 +1,326 @@
# =============================================================================
# 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
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 and flip button at the top
topSizer = wx.BoxSizer(wx.HORIZONTAL)
instructions = wx.StaticText(
panel,
label=_t("Paste fits in EFT format to compare")
)
topSizer.Add(instructions, 1, wx.ALL | wx.EXPAND, 5)
flipButton = wx.Button(panel, label=_t("Flip"))
flipButton.Bind(wx.EVT_BUTTON, self.onFlip)
topSizer.Add(flipButton, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5)
mainSizer.Add(topSizer, 0, wx.EXPAND)
# 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 onFlip(self, event):
"""Swap Fit 1 and Fit 2."""
fit1Value = self.fit1Text.GetValue()
fit2Value = self.fit2Text.GetValue()
self.fit1Text.SetValue(fit2Value)
self.fit2Text.SetValue(fit1Value)
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)
# Slot order
slotOrder = [
FittingSlot.HIGH,
FittingSlot.MED,
FittingSlot.LOW,
FittingSlot.RIG,
FittingSlot.SUBSYSTEM,
FittingSlot.SERVICE,
]
# Diff modules by slot - only show items needed to add
for slot in slotOrder:
fit1_slot_modules = fit1_modules.get(slot, Counter())
fit2_slot_modules = fit2_modules.get(slot, Counter())
all_module_types = set(fit1_slot_modules.keys()) | set(fit2_slot_modules.keys())
slot_diff_lines = []
for module_type in sorted(all_module_types):
count1 = fit1_slot_modules.get(module_type, 0)
count2 = fit2_slot_modules.get(module_type, 0)
if count2 > count1:
slot_diff_lines.append(f"{module_type} x{count2 - count1}")
if slot_diff_lines:
if diffLines:
diffLines.append("")
diffLines.extend(slot_diff_lines)
# 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} x{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} x{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} x{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} x1")
# Get boosters
fit1_boosters = self.getBoosterNames(fit1)
fit2_boosters = self.getBoosterNames(fit2)
for booster in sorted(fit2_boosters - fit1_boosters):
diffLines.append(f"{booster} x1")
return diffLines
def getModuleCounts(self, fit):
"""Get a counter of module types for a fit, grouped by slot type.
Returns a dict mapping FittingSlot -> Counter of module names.
Position doesn't matter, just counts by module name.
"""
counts_by_slot = {}
for module in fit.modules:
if module.isEmpty:
continue
slot = module.slot
if slot not in counts_by_slot:
counts_by_slot[slot] = Counter()
# Use item type name for comparison
name = module.item.typeName if module.item else ""
counts_by_slot[slot][name] += 1
return counts_by_slot
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

View File

@@ -24,6 +24,7 @@ import config
import gui.mainFrame import gui.mainFrame
from eos.saveddata.drone import Drone from eos.saveddata.drone import Drone
from eos.saveddata.module import Module from eos.saveddata.module import Module
from eos.saveddata.ship import Ship
from gui.auxWindow import AuxiliaryFrame from gui.auxWindow import AuxiliaryFrame
from gui.bitmap_loader import BitmapLoader from gui.bitmap_loader import BitmapLoader
from gui.builtinItemStatsViews.itemAffectedBy import ItemAffectedBy from gui.builtinItemStatsViews.itemAffectedBy import ItemAffectedBy
@@ -35,6 +36,7 @@ from gui.builtinItemStatsViews.itemEffects import ItemEffects
from gui.builtinItemStatsViews.itemMutator import ItemMutatorPanel from gui.builtinItemStatsViews.itemMutator import ItemMutatorPanel
from gui.builtinItemStatsViews.itemProperties import ItemProperties from gui.builtinItemStatsViews.itemProperties import ItemProperties
from gui.builtinItemStatsViews.itemRequirements import ItemRequirements from gui.builtinItemStatsViews.itemRequirements import ItemRequirements
from gui.builtinItemStatsViews.itemSkills import ItemSkills
from gui.builtinItemStatsViews.itemTraits import ItemTraits from gui.builtinItemStatsViews.itemTraits import ItemTraits
from service.market import Market from service.market import Market
@@ -156,6 +158,8 @@ class ItemStatsContainer(wx.Panel):
def __init__(self, parent, stuff, item, context=None): def __init__(self, parent, stuff, item, context=None):
wx.Panel.__init__(self, parent) wx.Panel.__init__(self, parent)
sMkt = Market.getInstance() sMkt = Market.getInstance()
self.stuff = stuff
self.context = context
mainSizer = wx.BoxSizer(wx.VERTICAL) mainSizer = wx.BoxSizer(wx.VERTICAL)
@@ -196,6 +200,10 @@ class ItemStatsContainer(wx.Panel):
self.affectedby = ItemAffectedBy(self.nbContainer, stuff, item) self.affectedby = ItemAffectedBy(self.nbContainer, stuff, item)
self.nbContainer.AddPage(self.affectedby, _t("Affected by")) self.nbContainer.AddPage(self.affectedby, _t("Affected by"))
if stuff is not None and isinstance(stuff, Ship):
self.skills = ItemSkills(self.nbContainer, stuff, item)
self.nbContainer.AddPage(self.skills, _t("Skills"))
if config.debug: if config.debug:
self.properties = ItemProperties(self.nbContainer, stuff, item, context) self.properties = ItemProperties(self.nbContainer, stuff, item, context)
self.nbContainer.AddPage(self.properties, _t("Properties")) self.nbContainer.AddPage(self.properties, _t("Properties"))

View File

@@ -565,7 +565,8 @@ class MainFrame(wx.Frame):
self.Bind(wx.EVT_MENU, self.toggleOverrides, id=menuBar.toggleOverridesId) self.Bind(wx.EVT_MENU, self.toggleOverrides, id=menuBar.toggleOverridesId)
# Clipboard exports # Clipboard exports
self.Bind(wx.EVT_MENU, self.exportToClipboard, id=wx.ID_COPY) self.Bind(wx.EVT_MENU, self.exportToClipboardDirectEft, id=menuBar.copyDirectEftId)
self.Bind(wx.EVT_MENU, self.exportToClipboard, id=menuBar.copyWithDialogId)
# Fitting Restrictions # Fitting Restrictions
self.Bind(wx.EVT_MENU, self.toggleIgnoreRestriction, id=menuBar.toggleIgnoreRestrictionID) self.Bind(wx.EVT_MENU, self.toggleIgnoreRestriction, id=menuBar.toggleIgnoreRestrictionID)
@@ -578,6 +579,7 @@ class MainFrame(wx.Frame):
toggleShipMarketId = wx.NewId() toggleShipMarketId = wx.NewId()
ctabnext = wx.NewId() ctabnext = wx.NewId()
ctabprev = wx.NewId() ctabprev = wx.NewId()
charPrevId = wx.NewId()
# Close Page # Close Page
self.Bind(wx.EVT_MENU, self.CloseCurrentPage, id=self.closePageId) self.Bind(wx.EVT_MENU, self.CloseCurrentPage, id=self.closePageId)
@@ -587,6 +589,7 @@ class MainFrame(wx.Frame):
self.Bind(wx.EVT_MENU, self.toggleShipMarket, id=toggleShipMarketId) self.Bind(wx.EVT_MENU, self.toggleShipMarket, id=toggleShipMarketId)
self.Bind(wx.EVT_MENU, self.CTabNext, id=ctabnext) self.Bind(wx.EVT_MENU, self.CTabNext, id=ctabnext)
self.Bind(wx.EVT_MENU, self.CTabPrev, id=ctabprev) self.Bind(wx.EVT_MENU, self.CTabPrev, id=ctabprev)
self.Bind(wx.EVT_MENU, self.selectPreviousCharacter, id=charPrevId)
actb = [(wx.ACCEL_CTRL, ord('T'), self.addPageId), actb = [(wx.ACCEL_CTRL, ord('T'), self.addPageId),
(wx.ACCEL_CMD, ord('T'), self.addPageId), (wx.ACCEL_CMD, ord('T'), self.addPageId),
@@ -620,7 +623,19 @@ class MainFrame(wx.Frame):
(wx.ACCEL_CMD, wx.WXK_PAGEDOWN, ctabnext), (wx.ACCEL_CMD, wx.WXK_PAGEDOWN, ctabnext),
(wx.ACCEL_CMD, wx.WXK_PAGEUP, ctabprev), (wx.ACCEL_CMD, wx.WXK_PAGEUP, ctabprev),
(wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("Z"), wx.ID_REDO) (wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("Z"), wx.ID_REDO),
# Ctrl+Shift+C for copy with dialog (must come before Ctrl+C)
# Note: use lowercase 'c' because SHIFT is already in flags
(wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord('c'), menuBar.copyWithDialogId),
(wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord('c'), menuBar.copyWithDialogId),
# Ctrl+C for direct EFT copy
(wx.ACCEL_CTRL, ord('c'), menuBar.copyDirectEftId),
(wx.ACCEL_CMD, ord('c'), menuBar.copyDirectEftId),
# Shift+Tab for previous character
(wx.ACCEL_SHIFT, wx.WXK_TAB, charPrevId)
] ]
# Ctrl/Cmd+# for addition pane selection # Ctrl/Cmd+# for addition pane selection
@@ -747,6 +762,9 @@ class MainFrame(wx.Frame):
def CTabPrev(self, event): def CTabPrev(self, event):
self.fitMultiSwitch.PrevPage() self.fitMultiSwitch.PrevPage()
def selectPreviousCharacter(self, event):
self.charSelection.selectPreviousChar()
def HAddPage(self, event): def HAddPage(self, event):
self.fitMultiSwitch.AddPage() self.fitMultiSwitch.AddPage()
@@ -805,6 +823,32 @@ class MainFrame(wx.Frame):
else: else:
self._openAfterImport(importData) self._openAfterImport(importData)
def exportToClipboardDirectEft(self, event):
""" Copy fit to clipboard in EFT format without showing dialog """
from eos.db import getFit
from service.const import PortEftOptions
from service.settings import SettingsProvider
fit = getFit(self.getActiveFit())
if fit is None:
return
# Get the default EFT export options from settings
defaultOptions = {
PortEftOptions.LOADED_CHARGES: True,
PortEftOptions.MUTATIONS: True,
PortEftOptions.IMPLANTS: True,
PortEftOptions.BOOSTERS: True,
PortEftOptions.CARGO: True,
}
settings = SettingsProvider.getInstance().getSettings("pyfaExport", {"format": CopySelectDialog.copyFormatEft, "options": {CopySelectDialog.copyFormatEft: defaultOptions}})
options = settings["options"].get(CopySelectDialog.copyFormatEft, defaultOptions)
def copyToClipboard(text):
toClipboard(text)
Port.exportEft(fit, options, callback=copyToClipboard)
def exportToClipboard(self, event): def exportToClipboard(self, event):
with CopySelectDialog(self) as dlg: with CopySelectDialog(self) as dlg:
dlg.ShowModal() dlg.ShowModal()
@@ -855,7 +899,8 @@ class MainFrame(wx.Frame):
char = fit.character char = fit.character
skillsMap = {} skillsMap = {}
for thing in itertools.chain(fit.modules, fit.drones, fit.fighters, [fit.ship], fit.appliedImplants, fit.boosters, fit.cargo): # for thing in itertools.chain(fit.modules, fit.drones, fit.fighters, [fit.ship], fit.appliedImplants, fit.boosters, fit.cargo):
for thing in itertools.chain(fit.modules, fit.drones, fit.fighters, fit.appliedImplants, fit.boosters, fit.cargo):
self._collectAffectingSkills(thing, char, skillsMap) self._collectAffectingSkills(thing, char, skillsMap)
skillsList = "" skillsList = ""

View File

@@ -58,6 +58,8 @@ class MainMenuBar(wx.MenuBar):
self.toggleIgnoreRestrictionID = wx.NewId() self.toggleIgnoreRestrictionID = wx.NewId()
self.devToolsId = wx.NewId() self.devToolsId = wx.NewId()
self.optimizeFitPrice = wx.NewId() self.optimizeFitPrice = wx.NewId()
self.copyWithDialogId = wx.NewId()
self.copyDirectEftId = wx.NewId()
self.mainFrame = mainFrame self.mainFrame = mainFrame
wx.MenuBar.__init__(self) wx.MenuBar.__init__(self)
@@ -85,7 +87,8 @@ class MainMenuBar(wx.MenuBar):
fitMenu.Append(wx.ID_REDO, _t("&Redo") + "\tCTRL+Y", _t("Redo the most recent undone action")) fitMenu.Append(wx.ID_REDO, _t("&Redo") + "\tCTRL+Y", _t("Redo the most recent undone action"))
fitMenu.AppendSeparator() fitMenu.AppendSeparator()
fitMenu.Append(wx.ID_COPY, _t("&To Clipboard") + "\tCTRL+C", _t("Export a fit to the clipboard")) fitMenu.Append(self.copyDirectEftId, _t("&To Clipboard (EFT)") + "\tCTRL+C", _t("Export a fit to the clipboard in EFT format"))
fitMenu.Append(self.copyWithDialogId, _t("&To Clipboard (Select Format)") + "\tCTRL+SHIFT+C", _t("Export a fit to the clipboard with format selection"))
fitMenu.Append(wx.ID_PASTE, _t("&From Clipboard") + "\tCTRL+V", _t("Import a fit from the clipboard")) fitMenu.Append(wx.ID_PASTE, _t("&From Clipboard") + "\tCTRL+V", _t("Import a fit from the clipboard"))
fitMenu.AppendSeparator() fitMenu.AppendSeparator()
@@ -178,7 +181,8 @@ class MainMenuBar(wx.MenuBar):
return return
enable = activeFitID is not None enable = activeFitID is not None
self.Enable(wx.ID_SAVEAS, enable) self.Enable(wx.ID_SAVEAS, enable)
self.Enable(wx.ID_COPY, enable) self.Enable(self.copyDirectEftId, enable)
self.Enable(self.copyWithDialogId, enable)
self.Enable(self.exportSkillsNeededId, enable) self.Enable(self.exportSkillsNeededId, enable)
self.Enable(self.copySkillsNeededId, enable) self.Enable(self.copySkillsNeededId, enable)