-
Notifications
You must be signed in to change notification settings - Fork 80
/
__init__.py
4340 lines (4024 loc) · 197 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
#
# GPL License and Copyright Notice ============================================
# This file is part of Wrye Bash.
#
# Wrye Bash 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.
#
# Wrye Bash 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 Wrye Bash. If not, see <https://www.gnu.org/licenses/>.
#
# Wrye Bash copyright (C) 2005-2009 Wrye, 2010-2024 Wrye Bash Team
# https://github.com/wrye-bash
#
# =============================================================================
"""This package provides the GUI interface for Wrye Bash. (However, the Wrye
Bash application is actually launched by the bash module.)
This module is used to help split basher.py to a package without breaking
the program. basher.py was organized starting with lower level elements,
working up to higher level elements. This was followed by
definition of menus and buttons classes, dialogs, and finally by several
initialization functions. Currently the package structure is:
__init.py__ : this file, basher.py core, must be further split
constants.py : constants, will grow
*_links.py : menus and buttons (app_buttons.py)
links_init.py : the initialization functions for menus, defines menu order
dialogs.py : subclasses of DialogWindow (except patcher dialog)
frames.py : subclasses of wx.Frame (except BashFrame)
gui_patchers.py : the gui patcher classes used by the patcher dialog
patcher_dialog.py : the patcher dialog
The layout is still fluid - there may be a links package, or a package per tab.
A central global variable is balt.Link.Frame, the BashFrame singleton.
Non-GUI objects and functions are provided by the bosh module. Of those, the
primary objects used are the plugins, modInfos and saveInfos singletons -- each
representing external data structures (the plugins.txt file and the Data and
Saves directories respectively). Persistent storage for the app is primarily
provided through the settings singleton (however the modInfos singleton also
has its own data store)."""
from __future__ import annotations
import os
import sys
import time
from collections import OrderedDict, defaultdict, namedtuple
from collections.abc import Iterable
from functools import partial
from itertools import chain, repeat
import wx
# basher-local imports - maybe work towards dropping (some of) these?
from .constants import colorInfo, settingDefaults
from .dialogs import CreateNewPlugin, CreateNewProject, UpdateNotification, \
DependentsAffectedDialog, MastersAffectedDialog, MultiWarningDialog, \
LoadOrderSanitizedDialog
from .frames import DocBrowser
from .gui_patchers import initPatchers
from .. import archives, balt, bass, bolt, bosh, bush, env, initialization, \
load_order
from ..balt import AppendableLink, BashStatusBar, CheckLink, ColorChecks, \
EnabledLink, INIListCtrl, ItemLink, Link, NotebookPanel, Resources, \
SeparatorLink, UIList, colors
from ..bass import Store
from ..bolt import FName, GPath, SubProgress, deprint, dict_sort, \
forward_compat_path_to_fn, os_name, round_size, str_to_sig, \
to_unix_newlines, to_win_newlines, top_level_items, LooseVersion, \
fast_cached_property, attrgetter_cache, top_level_files
from ..bosh import ModInfo, omods, RefrData
from ..bosh.mods_metadata import read_dir_tags, read_loot_tags
from ..exception import BoltError, CancelError, FileError, SkipError, \
UnknownListener
from ..gui import CENTER, BusyCursor, Button, CancelButton, CenteredSplash, \
CheckListBox, Color, CopyOrMovePopup, DateAndTimeDialog, DropDown, \
EventResult, FileOpen, GlobalMenu, HLayout, Label, LayoutOptions, \
ListBox, Links, MultiChoicePopup, PanelWin, Picture, PureImageButton, \
RadioButton, SaveButton, Splitter, Stretch, TabbedPanel, TextArea, \
TextField, VLayout, WindowFrame, WithMouseEvents, get_shift_down, \
read_files_from_clipboard_cb, showError, askYes, showWarning, askWarning, \
showOk, BmpFromStream, init_image_resources, get_image, \
get_installer_color_checks, get_image_dir
from ..localize import format_date
from ..update_checker import LatestVersion, UCThread
# - Make sure that python root directory is in PATH, so can access dll's.
_env_path = os.environ['PATH']
if sys.prefix not in _env_path.split(';'):
os.environ['PATH'] = f'{_env_path};{sys.prefix}'
# Settings --------------------------------------------------------------------
settings: bolt.Settings | None = None
# Links -----------------------------------------------------------------------
#------------------------------------------------------------------------------
class Installers_Link(ItemLink):
"""InstallersData mixin"""
_dialog_title: str
window: 'InstallersList'
@property
def idata(self):
""":rtype: bosh.InstallersData"""
return self._data_store
@property
def iPanel(self):
""":rtype: InstallersPanel"""
return self.window.panel
def _askFilename(self, message, filename, inst_type=bosh.InstallerArchive,
disallow_overwrite=False, no_dir=True, base_dir=None,
allowed_exts=archives.writeExts, use_default_ext=True,
check_exists=True, no_file=False):
""":rtype: bolt.FName"""
result = self._askText(message, title=self._dialog_title,
default=f'{filename}') # accept Path and str
if not result: return
#--Error checking
archive_path, msg = inst_type.validate_filename_str(result,
allowed_exts=allowed_exts, use_default_ext=use_default_ext)
if msg is None:
self._showError(archive_path) # it's an error message in this case
return
if isinstance(msg, tuple):
_root, msg = msg
self._showWarning(msg) # warn on extension change
base_dir = base_dir or self.idata.store_dir
fmt_pf = {'package_filename': archive_path}
if no_dir and base_dir.join(archive_path).is_dir():
self._showError(_('%(package_filename)s is a directory.') % fmt_pf)
return
if no_file and base_dir.join(archive_path).is_file():
self._showError(_('%(package_filename)s is a file.') % fmt_pf)
return
if check_exists and base_dir.join(archive_path).exists():
if disallow_overwrite:
self._showError(_('%(package_filename)s already '
'exists.') % fmt_pf)
return
msg = _('%(package_filename)s already exists. Overwrite it?'
) % fmt_pf
if not self._askYes(msg, self._dialog_title, default_is_yes=False):
return
return archive_path
#--Information about the various Tabs
tabInfo = {
# InternalName: [className, title, instance]
u'Installers': [u'InstallersPanel', _(u'Installers'), None],
u'Mods': [u'ModPanel', _(u'Mods'), None],
u'Saves': [u'SavePanel', _(u'Saves'), None],
u'INI Edits': [u'INIPanel', _(u'INI Edits'), None],
u'Screenshots': [u'ScreensPanel', _(u'Screenshots'), None],
# 'BSAs': ['BSAPanel', 'BSAs', None],
}
#------------------------------------------------------------------------------
# Panels ----------------------------------------------------------------------
#------------------------------------------------------------------------------
_same_file = object() # None already has special meaning, so use this default
class _DetailsViewMixin(NotebookPanel):
"""Mixin to add detailsPanel attribute to a Panel with a details view.
Mix it in to SashUIListPanel so UILists can call SetDetails and
ClearDetails on their panels."""
detailsPanel = None
def _setDetails(self, fileName):
self.detailsPanel.SetFile(fileName=fileName)
def ClearDetails(self): self._setDetails(None)
def SetDetails(self, fileName=_same_file): self._setDetails(fileName)
def RefreshUIColors(self):
super(_DetailsViewMixin, self).RefreshUIColors()
self.detailsPanel.RefreshUIColors()
def ClosePanel(self, destroy=False):
self.detailsPanel.ClosePanel(destroy)
super(_DetailsViewMixin, self).ClosePanel(destroy)
def ShowPanel(self, **kwargs):
super(_DetailsViewMixin, self).ShowPanel()
self.detailsPanel.ShowPanel(**kwargs)
_UIsetting = namedtuple(u'UIsetting', u'default_ get_ set_')
class SashPanel(NotebookPanel):
"""Subclass of Notebook Panel, designed for two pane panel. Overrides
ShowPanel to do some first show initialization."""
defaultSashPos = minimumSize = 256
_ui_settings = {'.sashPos': _UIsetting(lambda self: self.defaultSashPos,
lambda self: self.splitter.get_sash_pos(),
lambda self, sashPos: self.splitter.set_sash_pos(sashPos))}
def __init__(self, parent, isVertical=True):
super(SashPanel, self).__init__(parent)
self.splitter = Splitter(self, allow_split=False,
min_pane_size=self.__class__.minimumSize)
self.left, self.right = self.splitter.make_panes(vertically=isVertical)
self.isVertical = isVertical
VLayout(item_weight=1, item_expand=True,
items=[self.splitter]).apply_to(self)
def ShowPanel(self, **kwargs):
"""Unfortunately can't use EVT_SHOW, as the panel needs to be
populated for position to be set correctly."""
if self._firstShow:
for key, ui_set in self._ui_settings.items():
sashPos = settings.get(self.__class__.keyPrefix + key,
ui_set.default_(self))
ui_set.set_(self, sashPos)
self._firstShow = False
def ClosePanel(self, destroy=False):
if not self._firstShow and destroy: # if the panel was shown
for key, ui_set in self._ui_settings.items():
settings[self.__class__.keyPrefix + key] = ui_set.get_(self)
class SashUIListPanel(SashPanel):
"""SashPanel featuring a UIList and a corresponding listData datasource."""
listData = None
_status_str = 'OVERRIDE: %(status_num)d'
_ui_list_type: type[UIList] = None
def __init__(self, parent, isVertical=True):
super(SashUIListPanel, self).__init__(parent, isVertical)
self.uiList = self._ui_list_type(self.left, listData=self.listData,
keyPrefix=self.keyPrefix, panel=self)
def SelectUIListItem(self, item, deselectOthers=False):
self.uiList.SelectAndShowItem(item, deselectOthers=deselectOthers,
focus=True)
def sb_count_str(self): return self.__class__._status_str % {
'status_num': len(self.listData)}
def RefreshUIColors(self):
self.uiList.RefreshUI(focus_list=False)
def ShowPanel(self, **kwargs):
"""Resize the columns if auto is on and set Status bar text. Also
sets the scroll bar and sash positions on first show. Must be _after_
RefreshUI for scroll bar to be set correctly."""
if self._firstShow:
super(SashUIListPanel, self).ShowPanel()
self.uiList.SetScrollPosition()
self.uiList.autosizeColumns()
self.uiList.Focus()
Link.Frame.set_status_info(self.sb_count_str(), 2, show_panel=True)
self.uiList.setup_global_menu()
def ClosePanel(self, destroy=False):
if not self._firstShow and destroy: # if the panel was shown
super(SashUIListPanel, self).ClosePanel(destroy)
self.uiList.SaveScrollPosition(isVertical=self.isVertical)
self.listData.save_pickle()
class BashTab(_DetailsViewMixin, SashUIListPanel):
"""Wrye Bash Tab, composed of a UIList and a Details panel."""
_details_panel_type = None # type: type
defaultSashPos = 512
minimumSize = 256
def __init__(self, parent, isVertical=True):
super(BashTab, self).__init__(parent, isVertical)
self.detailsPanel = self._details_panel_type(self.right, self)
#--Layout
HLayout(item_expand=True, item_weight=1,
items=[self.detailsPanel]).apply_to(self.right)
HLayout(item_expand=True, item_weight=2,
items=[self.uiList]).apply_to(self.left)
#------------------------------------------------------------------------------
class _ModsUIList(UIList):
_masters_first_cols = UIList.nonReversibleCols
# True if we should highlight masters whose stored size does not match the
# size of the plugin on disk
_do_size_checks = bush.game.Esp.check_master_sizes
def _sort_masters_first(self, items):
"""Conditional sort, performs the actual 'masters-first' sorting if
needed."""
if self.masters_first:
items.sort(key=lambda a: not self.data_store[a].in_master_block())
def _activeModsFirst(self, items):
if self.selectedFirst:
set_active = set(load_order.cached_active_tuple())
set_merged = set(bosh.modInfos.merged)
set_imported = set(bosh.modInfos.imported)
def _sel_sort_key(x):
# First active, then merged, then imported, then inactive
x = self._item_name(x)
if x in set_active: return 0
elif x in set_merged: return 1
elif x in set_imported: return 2
else: return 3
items.sort(key=_sel_sort_key)
_extra_sortings = [_sort_masters_first, _activeModsFirst]
@property
def masters_first(self):
"""Whether or not masters should be sorted before non-masters for the
current sort column."""
return (settings.get(f'{self.keyPrefix}.esmsFirst', True) or
self.masters_first_required)
@masters_first.setter
def masters_first(self, val):
settings[f'{self.keyPrefix}.esmsFirst'] = val
@property
def selectedFirst(self):
return settings.get(f'{self.keyPrefix}.selectedFirst', False)
@selectedFirst.setter
def selectedFirst(self, val):
settings[f'{self.keyPrefix}.selectedFirst'] = val
@property
def masters_first_required(self):
"""Return True if sorting by master status is required for the current
sort column."""
return self.sort_column in self._masters_first_cols
def _item_name(self, x): # hack to centralize some nasty modInfos accesses
return x
def set_item_format(self, item_key, item_format, target_ini_setts):
minf = self.data_store[item_key]
checkMark, mouseText = self._set_status_text(item_format, minf,
item_key)
item_name = self._item_name(item_key)
fileBashTags, mouseText = self._set_color(checkMark, mouseText, minf,
item_name, item_format)
# Text background
if minf.hasActiveTimeConflict():
item_format.back_key = 'mods.bkgd.doubleTime.load'
mouseText += _('Another plugin has the same timestamp.') + ' '
elif minf.hasTimeConflict():
item_format.back_key = 'mods.bkgd.doubleTime.exists'
mouseText += _('Another plugin has the same timestamp.') + ' '
if minf.is_ghost:
item_format.back_key = 'mods.bkgd.ghosted'
mouseText += _('Plugin is ghosted.') + ' '
if msg := minf.has_master_size_mismatch(self._do_size_checks):
item_format.back_key = 'mods.bkgd.size_mismatch'
mouseText += msg + ' '
if settings['bash.mods.scanDirty']:
if msg := minf.getDirtyMessage():
mouseText += msg
item_format.underline = True
self.mouseTexts[item_key] = mouseText
def _set_color(self, checkMark, mouseText, minf, item_name, item_format):
#--Font color
fileBashTags = minf.getBashTags()
# Text foreground - prioritize BP color, then mergeable/NoMerge color
if item_name in bosh.modInfos.bashed_patches:
item_format.text_key = 'mods.text.bashedPatch'
mouseText += _('Bashed Patch.') + ' '
if item_name in bosh.modInfos.mergeable_plugins:
if 'NoMerge' in fileBashTags:
item_format.text_key = 'mods.text.noMerge'
mouseText += _('Technically mergeable, but has NoMerge '
'tag.') + ' '
else:
item_format.text_key = 'mods.text.mergeable'
if checkMark == 2: # Merged plugins won't be in master lists
mouseText += _('Merged into Bashed Patch.') + ' '
else:
mouseText += _('Can be merged into Bashed Patch.') + ' '
if item_name in bosh.modInfos.esl_capable_plugins:
item_format.text_key = 'mods.text.mergeable'
mouseText += _('Can be ESL-flagged.') + ' '
if item_name in bosh.modInfos.overlay_capable_plugins:
item_format.text_key = 'mods.text.mergeable'
mouseText += _('Can be Overlay-flagged.') + ' '
final_text_key = 'mods.text.es'
if minf.is_esl():
final_text_key += 'l'
mouseText += _('Light plugin.') + ' '
if minf.is_overlay(): # Overlay plugins won't be in master lists
final_text_key += 'o'
mouseText += _('Overlay plugin.') + ' '
if minf.in_master_block():
final_text_key += 'm'
mouseText += _('Master plugin.') + ' '
# Check if it's special, leave ESPs alone
if final_text_key != 'mods.text.es':
item_format.text_key = final_text_key
if 'Deactivate' in fileBashTags: # was for mods only
item_format.italics = True
return fileBashTags, mouseText
#------------------------------------------------------------------------------
class MasterList(_ModsUIList):
column_links = Links()
context_links = Links()
# Since there is no global menu for master lists, bypass the global menu
# setting (otherwise the user would never be able to access these links)
_bypass_gm_setting = True
keyPrefix = u'bash.masters' # use for settings shared among the lists (cols)
_editLabels = True
#--Sorting
_default_sort_col = u'Num'
_sort_keys = {
u'Num' : None, # sort by master index, the key itself
u'File' : lambda self, a:
self.data_store[a].curr_name.lower(),
# Missing mods sort last alphabetically
u'Current Order': lambda self, a: self._curr_lo_index[
self.data_store[a].curr_name],
'Indices': lambda self, a: self._save_lo_real_index[
self.data_store[a].curr_name],
'Current Index': lambda self, a: self._curr_real_index[
self.data_store[a].curr_name],
}
def _item_name(self, x):
return self.data_store[x].curr_name
_sunkenBorder, _singleCell = False, True
#--Labels
labels = {
'File': lambda self, mi: bosh.modInfos.masterWithVersion(
self._item_name(mi)),
'Num': lambda self, mi: f'{mi:02X}',
'Current Order': lambda self, mi: load_order.cached_active_index_str(
self._item_name(mi)),
'Indices': lambda self, mi: self._save_lo_hex_string[
self._item_name(mi)],
'Current Index': lambda self, mi: bosh.modInfos.real_indices[
self._item_name(mi)][1],
}
banned_columns = {'Indices', 'Current Index'} # These are Saves-specific
@property
def masters_first(self):
# Flip the default for masters, we want to show the order in the save
# so as to not make renamed/disabled masters 'jump around'
return (settings.get(f'{self.keyPrefix}.esmsFirst', False) or
self.masters_first_required)
# We have to override this, otherwise Mods_MastersFirst breaks
@masters_first.setter
def masters_first(self, val):
settings[f'{self.keyPrefix}.esmsFirst'] = val
@property
def cols(self):
# using self.__class__.keyPrefix for common saves/mods masters settings
return settings[self.__class__.keyPrefix + u'.cols']
def __init__(self, parent, listData=None, keyPrefix=keyPrefix, panel=None,
detailsPanel=None):
#--Data/Items
self.edited = False
self.detailsPanel = detailsPanel
self.fileInfo = None
self._curr_lo_index = {} # cache, orders missing last alphabetically
self._curr_real_index = {}
# Cache based on SaveHeader.masters_regular and masters_esl
self._save_lo_real_index = defaultdict(lambda: sys.maxsize)
self._save_lo_hex_string = defaultdict(lambda: '')
self._allowEditKey = keyPrefix + u'.allowEdit'
self.is_inaccurate = False # Mirrors SaveInfo.has_inaccurate_masters
#--Parent init
super(MasterList, self).__init__(parent,
listData=listData if listData is not None else {},
keyPrefix=keyPrefix, panel=panel)
@property
def allowEdit(self): return bass.settings.get(self._allowEditKey, False)
@allowEdit.setter
def allowEdit(self, val):
if val and (not self.detailsPanel.allowDetailsEdit or not
balt.askContinue(self, _(
'Edit/update the masters list? Note that the update process '
'may automatically rename some files. Be sure to review the '
'changes before saving.'), f'{self.keyPrefix}.update.continue',
_('Update Masters') + ' - ' + _('BETA'))):
return
bass.settings[self._allowEditKey] = val
if val:
self.InitEdit()
else:
self.SetFileInfo(self.fileInfo)
self.detailsPanel.testChanges() # disable buttons if no other edits
def _handle_select(self, item_key): pass
def _handle_key_up(self, wrapped_evt): pass
def OnDClick(self, lb_dex_and_flags):
"""Double click - jump to selected plugin on Mods tab."""
if self.mouse_index is None or self.mouse_index < 0:
return # Nothing was clicked
sel_curr_name = self.data_store[self.mouse_index].curr_name
if sel_curr_name not in bosh.modInfos:
return # Master that is not installed was clicked
balt.Link.Frame.notebook.SelectPage(u'Mods', sel_curr_name)
return EventResult.FINISH
#--Set ModInfo
def SetFileInfo(self,fileInfo):
self.ClearSelected()
self.edited = False
self.fileInfo = fileInfo
self.data_store.clear()
self.DeleteAll()
#--Null fileInfo?
if not fileInfo:
return
#--Fill data and populate
self._update_real_indices(fileInfo)
self.is_inaccurate = fileInfo.has_inaccurate_masters
# info attributes?
can_have_sizes = (is_mod := isinstance(fileInfo, bosh.ModInfo)) and \
bush.game.Esp.check_master_sizes
can_have_esl = (not is_mod) and bush.game.has_esl
all_esl_masters = (set(fileInfo.header.masters_esl) if can_have_esl
else set())
all_master_sizes = (fileInfo.header.master_sizes if can_have_sizes
else repeat(0))
for mi, (ma_name, ma_size) in enumerate(
zip(fileInfo.masterNames, all_master_sizes)):
self.data_store[mi] = bosh.MasterInfo(parent_minf=fileInfo,
master_name=ma_name, master_size=ma_size,
was_esl=ma_name in all_esl_masters)
self._reList()
def set_item_format(self, item_key, item_format, target_ini_setts):
super().set_item_format(item_key, item_format, target_ini_setts)
minf = self.data_store[item_key]
if self.allowEdit:
if minf.old_name in settings['bash.mods.renames']:
item_format.bold = True
def _set_status_text(self, item_format, masterInfo, mi):
mouseText = ''
item_name = self._item_name(mi)
if item_name in bosh.modInfos.activeBad: # if active, it's in LO
item_format.back_key = 'mods.bkgd.doubleTime.load'
mouseText += _('Plugin name incompatible, will not load.') + ' '
if bosh.modInfos.isBadFileName(item_name): # might not be in LO
item_format.back_key = 'mods.bkgd.doubleTime.exists'
mouseText += _('Plugin name incompatible, cannot be '
'activated.') + ' '
status = masterInfo.getStatus()
if status < 30: # 30: does not exist
# current load order of master relative to other masters
loadOrderIndex = self._curr_lo_index[item_name]
ordered = load_order.cached_active_tuple()
if mi != loadOrderIndex: # there are active masters out of order
status = 20 # orange
elif status > 0:
pass # never happens
elif (mi < len(ordered)) and (ordered[mi] == item_name):
status = -10 # Blue else 0, Green
#--Image
oninc = load_order.cached_is_active(item_name) or (
item_name in bosh.modInfos.merged and 2)
on_display = self.detailsPanel.displayed_item
if status == 30: # master is missing
mouseText += _('Missing master of %(child_plugin_name)s.') % {
'child_plugin_name': on_display} + ' '
#--HACK - load order status
elif on_display in bosh.modInfos:
if status == 20:
mouseText += _('Reordered relative to other masters.') + ' '
lo_index = load_order.cached_lo_index
if lo_index(on_display) < lo_index(item_name):
mouseText += _('Loads after %(child_plugin_name)s.') % {
'child_plugin_name': on_display} + ' '
status = 20 # paint orange
item_format.icon_key = status, oninc
return oninc, mouseText
#--Relist
def _reList(self, repopulate=True):
file_order_names = load_order.get_ordered(
[v.curr_name for v in self.data_store.values()])
self._curr_lo_index = {p: i for i, p in enumerate(file_order_names)}
self._curr_real_index = {p: bosh.modInfos.real_indices[p][0] for p in
file_order_names}
if repopulate: self.populate_items()
def _update_real_indices(self, new_file_info):
"""Updates the 'real' indices cache. Does nothing outside of saves."""
#--InitEdit
def InitEdit(self):
#--Pre-clean
edited = False
for mi, masterInfo in self.data_store.items():
newName = settings[u'bash.mods.renames'].get(
masterInfo.curr_name, None)
#--Rename only if corresponding modInfo is present
edited |= bool(masterInfo.rename_if_present(newName))
#--Done
if edited: self.SetMasterlistEdited(repopulate=True)
def SetMasterlistEdited(self, repopulate=False):
self._reList(repopulate)
self.edited = True
self.detailsPanel.SetEdited() # inform the details panel
#--Column Menu
def _pop_menu(self):
return self.fileInfo and super()._pop_menu()
def _handle_left_down(self, wrapped_evt, lb_dex_and_flags):
if self.allowEdit: self.InitEdit()
#--Events: Label Editing
def OnBeginEditLabel(self, evt_label, uilist_ctrl):
if not self.allowEdit: return EventResult.CANCEL
# pass event on (for label editing)
return super(MasterList, self).OnBeginEditLabel(evt_label, uilist_ctrl)
def _check_rename_requirements(self):
"""Check if the operation is allowed and return ModInfo as the item
type of the selected label to be renamed."""
to_rename = self.GetSelected()
if to_rename:
return ModInfo, ''
else:
# I don't see how this would be possible, but just in case...
return None, _('No items selected for renaming.')
def OnLabelEdited(self, is_edit_cancelled, evt_label, evt_index, evt_item):
#--No change?
masterInfo = self.data_store[evt_item]
if masterInfo.rename_if_present(evt_label): # evt_label is the new name
self.SetMasterlistEdited()
bass.settings[u'bash.mods.renames'][
masterInfo.old_name] = masterInfo.curr_name
# populate, refresh must be called last
self.PopulateItem(itemDex=evt_index)
return EventResult.FINISH ##: needed?
elif evt_label == u'':
return EventResult.CANCEL
else:
showError(self, _('File %(selected_file)s does not exist.') % {
'selected_file': evt_label})
return EventResult.CANCEL
#--GetMasters
def GetNewMasters(self):
"""Returns new master list."""
return [v.curr_name for k, v in dict_sort(self.data_store)]
#------------------------------------------------------------------------------
def _ask_info(attr_name, func_args=None, wrap=None):
"""Create a function that takes a UIList and the fn_key to a (present,
otherwise a KeyError is raised) list info and gets some info from the
info (an attribute or instance method result)."""
attget = attrgetter_cache[attr_name]
if func_args is None:
lm = lambda self, p: attget(self.data_store[p])
else:
lm = lambda self, p: attget(self.data_store[p])(*func_args)
if wrap is not None:
return lambda self, p: wrap(lm(self, p))
return lm
class INIList(UIList):
column_links = Links() #--Column menu
context_links = Links() #--Single item menu
global_links = defaultdict(lambda: Links()) # Global menu
_sort_keys = {
'File' : None,
'Installer': _ask_info('get_table_prop', ('installer', '')),
}
def _sortValidFirst(self, items, *, __lm=_ask_info('tweak_status', ())):
if settings[u'bash.ini.sortValid']:
items.sort(key=lambda a: (__lm(self, a) < 0))
_extra_sortings = [_sortValidFirst]
#--Labels
labels = {'File': lambda self, p: p,
'Installer': _ask_info('get_table_prop', ('installer', '')),
}
_target_ini = True # pass the target_ini settings on PopulateItem
@property
def current_ini_name(self): return self.panel.detailsPanel.ini_name
def CountTweakStatus(self):
"""Returns number of each type of tweak, in the
following format:
(applied,mismatched,not_applied,invalid)"""
applied = 0
mismatch = 0
not_applied = 0
invalid = 0
for ini_info in self.data_store.values():
status = ini_info.tweak_status()
if status == -10: invalid += 1
elif status == 0: not_applied += 1
elif status == 10: mismatch += 1
elif status == 20: applied += 1
return applied,mismatch,not_applied,invalid
def ListTweaks(self):
"""Returns text list of tweaks"""
tweaklist = _('Active INI Tweaks:') + '\n'
tweaklist += u'[spoiler]\n'
for tweak, info in dict_sort(self.data_store):
if not info.tweak_status() == 20: continue
tweaklist+= f'{tweak}\n'
tweaklist += u'[/spoiler]\n'
return tweaklist
def set_item_format(self, ini_name, item_format, target_ini_setts):
iniInfo = self.data_store[ini_name]
status = iniInfo.tweak_status(target_ini_setts)
#--Image
checkMark = 0
icon_ = 0 # Ok tweak, not applied
mousetext = ''
if status == 20:
# Valid tweak, applied
checkMark = 1
mousetext = _('Tweak is currently applied.')
elif status == 15:
# Valid tweak, some settings applied, others are
# overwritten by values in another tweak from same installer
checkMark = 3
mousetext = _('Some settings are applied. Some are overwritten '
'by another tweak from the same installer.')
elif status == 10:
# Ok tweak, some parts are applied, others not
icon_ = 10
checkMark = 3
mousetext = _('Some settings are changed.')
elif status < 0:
# Bad tweak
if not iniInfo.is_applicable(status):
icon_ = 20
mousetext = _('Tweak is invalid.')
else:
icon_ = 0
mousetext = _('Tweak adds new settings.')
if iniInfo.is_default_tweak:
def_tweak_text = _('Default Wrye Bash tweak.')
mousetext = (def_tweak_text + f' {mousetext}'
if mousetext else def_tweak_text)
item_format.italics = True
self.mouseTexts[ini_name] = mousetext
item_format.icon_key = icon_, checkMark
#--Font/BG Color
if status < 0:
item_format.back_key = 'ini.bkgd.invalid'
# Events ------------------------------------------------------------------
def _handle_left_down(self, wrapped_evt, lb_dex_and_flags):
tweak_clicked_on_icon = self._get_info_clicked(lb_dex_and_flags,
on_icon=True)
if tweak_clicked_on_icon:
# Left click on icon - Activate tweak
if self.apply_tweaks([tweak_clicked_on_icon]):
self.panel.ShowPanel()
return EventResult.FINISH
else:
tweak_clicked = self._getItemClicked(lb_dex_and_flags)
if wrapped_evt.is_alt_down and tweak_clicked:
# Alt+Left click - jump to source
if self.jump_to_source(tweak_clicked):
return EventResult.FINISH
def OnDClick(self, lb_dex_and_flags):
"""Double click - open selected tweak."""
tweak_clicked = self._get_info_clicked(lb_dex_and_flags)
if tweak_clicked and not tweak_clicked.is_default_tweak:
self.OpenSelected(selected=[tweak_clicked.fn_key])
return EventResult.FINISH
def _handle_key_down(self, wrapped_evt):
kcode = wrapped_evt.key_code
if kcode in balt.wxReturn:
# Enter - open selected tweaks
self.OpenSelected(
self.data_store.filter_essential(self.GetSelected()))
else:
return super()._handle_key_down(wrapped_evt)
# Otherwise we'd jump to a random tweak that starts with the key code
return EventResult.FINISH
# INI-specific methods ----------------------------------------------------
@classmethod
def apply_tweaks(cls, tweak_infos, target_ini=None):
target_ini_file = target_ini or bosh.iniInfos.ini
if not cls.ask_create_target_ini(target_ini_file):
return False
# Default tweaks are tested, so no need to warn about trust and
# crashes, etc.
tweaks_are_trusted = all(t.is_default_tweak for t in tweak_infos)
if (not tweaks_are_trusted and
not cls._warn_tweak_game_ini(target_ini_file.abs_path.stail)):
return False
needsRefresh = False
for ini_info in tweak_infos:
#--No point applying a tweak that's already applied
if target_ini: # if target was given calculate the status for it
stat = ini_info.getStatus(target_ini_file)
ini_info.reset_status() # iniInfos.ini may differ from target
else: stat = ini_info.tweak_status()
if stat == 20 or not ini_info.is_applicable(stat): continue
needsRefresh |= target_ini_file.apply_tweak(ini_info)
return needsRefresh
@staticmethod
@balt.conversation
def ask_create_target_ini(target_ini_file, msg=None):
"""Check if target ini for operation exists - if not and the target is
the game ini ask if the user wants to create it by copying the default
ini"""
msg = target_ini_file.target_ini_exists(msg)
if msg in (True, False): return msg
# Game ini does not exist - try copying the default game ini
default_ini = bass.dirs[u'app'].join(bush.game.Ini.default_ini_file)
if default_ini.exists():
msg += _('Do you want Wrye Bash to create it by copying '
'%(default_ini)s ?') % {'default_ini': default_ini}
if not askYes(None, msg, title=_('Missing Game INI')):
return False
else:
msg += _('Please create it manually to continue.')
showError(None, msg, title=_('Missing Game INI'))
return False
try:
default_ini.copyTo(target_ini_file.abs_path)
if ini_uilist := balt.Link.Frame.all_uilists[Store.INIS]:
ini_uilist.panel.ShowPanel()
else:
bosh.iniInfos.refresh(refresh_infos=False)
return True
except OSError:
target_ini_pth = target_ini_file.abs_path
deprint(f'Failed to copy {default_ini} to {target_ini_pth}',
traceback=True)
msg = _('Failed to copy %(def_ini_path)s to %(target_ini_pth)s.'
) % {'def_ini_path': default_ini,
'target_ini_pth': target_ini_pth}
showError(None, msg, title=_('Missing Game INI'))
return False
@staticmethod
@balt.conversation
def _warn_tweak_game_ini(chosen):
ask = True
if chosen in bush.game.Ini.dropdown_inis:
ask = balt.askContinue(balt.Link.Frame,
_('Apply an INI tweak to %(curr_target_ini)s? Make sure you '
'trust the tweak, as incorrect tweaks can cause '
'crashes.') % {'curr_target_ini': chosen},
'bash.iniTweaks.continue', title=_('INI Tweaks'))
return ask
#------------------------------------------------------------------------------
class INITweakLineCtrl(INIListCtrl):
def __init__(self, parent, iniContents):
super(INITweakLineCtrl, self).__init__(parent)
self.tweakLines = []
self.iniContents = self._contents = iniContents
def _get_selected_line(self, index): return self.tweakLines[index][5]
def refresh_tweak_contents(self, tweakPath):
# Make sure to freeze/thaw, all the InsertItem calls make the GUI lag
self.Freeze()
try:
# Clear the list, then populate it with the new lines
self.DeleteAllItems()
if tweakPath is None:
return
self._RefreshTweakLineCtrl(tweakPath)
finally:
self.Thaw()
def _RefreshTweakLineCtrl(self, tweakPath):
# TODO(ut) avoid if ini tweak did not change
self.tweakLines = bosh.iniInfos.get_tweak_lines_infos(tweakPath)
updated_line_nums = set()
for i, (line, _sec, _sett, _val, status, lineNo, is_del) in enumerate(
self.tweakLines):
#--Line
self.InsertItem(i, line)
#--Line color
if status == -10: color = colors[u'tweak.bkgd.invalid']
elif status == 10: color = colors[u'tweak.bkgd.mismatched']
elif status == 20: color = colors[u'tweak.bkgd.matched']
elif is_del: color = colors['tweak.bkgd.mismatched']
else: color = Color.from_wx(self.GetBackgroundColour())
color = color.to_rgba_tuple()
self.SetItemBackgroundColour(i, color)
#--Set iniContents color
if lineNo != -1:
self.iniContents.SetItemBackgroundColour(lineNo,color)
updated_line_nums.add(lineNo)
#--Reset line color for other iniContents lines
background_color = self.iniContents.GetBackgroundColour()
for i in range(self.iniContents.GetItemCount()):
if i in updated_line_nums: continue
if self.iniContents.GetItemBackgroundColour(i) != background_color:
self.iniContents.SetItemBackgroundColour(i, background_color)
#--Refresh column width
self.fit_column_to_header(0)
#------------------------------------------------------------------------------
class TargetINILineCtrl(INIListCtrl):
def SetTweakLinesCtrl(self, control):
self._contents = control
def _get_selected_line(self, index):
for i, line in enumerate(self._contents.tweakLines):
if index == line[5]: return i
return -1
def refresh_ini_contents(self):
# Make sure to freeze/thaw, all the InsertItem calls make the GUI lag
if bosh.iniInfos.ini.isCorrupted: return
self.Freeze()
try:
# Clear the list, then populate it with the new lines
self.DeleteAllItems()
main_ini_selected = (bush.game.Ini.dropdown_inis[0] ==
bosh.iniInfos.ini.abs_path.stail)
try:
sel_ini_lines = bosh.iniInfos.ini.read_ini_content()
if main_ini_selected: # If we got here, reading the INI worked
Link.Frame.oblivionIniMissing = False
for i, line in enumerate(sel_ini_lines):
self.InsertItem(i, line.rstrip())
except OSError:
if main_ini_selected:
Link.Frame.oblivionIniMissing = True
self.fit_column_to_header(0)
finally:
self.Thaw()
#------------------------------------------------------------------------------
_common_sort_keys = {'File': None, # just sort by name
'Modified': _ask_info('ftime'), 'Size': _ask_info('fsize')}
_common_labels = {'File': lambda self, p: p,
'Modified': _ask_info('ftime', wrap=format_date),
'Size': _ask_info('fsize', wrap=round_size)}
class ModList(_ModsUIList):
#--Class Data
column_links = Links() #--Column menu
context_links = Links() #--Single item menu
global_links = defaultdict(lambda: Links()) # Global menu
_sort_keys = {**_common_sort_keys,
'Author' : lambda self, a: _ask_info('header.author',
wrap=str.lower),
'Rating' : _ask_info('get_table_prop', ('rating', '')),
'Group' : _ask_info('get_table_prop', ('group', '')),
'Installer' : _ask_info('get_table_prop', ('installer', '')),
'Load Order': lambda self, a: load_order.cached_lo_index(a),
'Indices' : lambda self, a: self.data_store.real_indices[a][0],
'Status' : _ask_info('getStatus', ()),
'Mod Status': _ask_info('txt_status', ()),
'CRC' : _ask_info('cached_mod_crc', ()),
}
_dndList, _dndColumns = True, [u'Load Order']
_sunkenBorder = False
#--Labels
labels = {**_common_labels, # File is overwritten below
'File': lambda self, p: self.data_store.masterWithVersion(p),
'Load Order': lambda self, p: load_order.cached_active_index_str(p),
'Indices': lambda self, p: self.data_store.real_indices[p][1],
'Rating': _ask_info('get_table_prop', ('rating', '')),
'Group': _ask_info('get_table_prop', ('group', '')),
'Installer': _ask_info('get_table_prop', ('installer', '')),
'Author': _ask_info('header.author'),
'CRC': _ask_info('crc_string', ()),
'Mod Status': _ask_info('txt_status', ()),
}
_copy_paths = True
#-- Drag and Drop-----------------------------------------------------
def _dropIndexes(self, indexes, newIndex): # will mess with plugins cache !
"""Drop contiguous indexes on newIndex and return True if LO changed"""
if newIndex < 0:
return False # from _handle_key_down() & moving master esm up
count = self.item_count
dropItem = self.GetItem(newIndex if (count > newIndex) else count - 1)
firstItem = self.GetItem(indexes[0])
lastItem = self.GetItem(indexes[-1])
return bosh.modInfos.dropItems(dropItem, firstItem, lastItem)