├── .gitattributes
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .gitmodules
├── Bethini-Pie-Performance-INI-Editor.pyproj
├── Bethini-Pie-Performance-INI-Editor.sln
├── Bethini.pyw
├── Bethini.spec
├── Icon.ico
├── LICENSE.txt
├── README.md
├── bethini_onefile.spec
├── changelog.txt
├── fonts
└── Comfortaa
│ ├── Comfortaa-Bold.ttf
│ └── OFL.txt
├── icons
├── Advanced.png
├── Basic.png
├── Blank.png
├── Environment.png
├── Gameplay.png
├── General.png
├── Icon.png
├── Interface.png
├── Log.png
├── Shadows.png
├── View Distance.png
└── Visuals.png
├── lib
├── AutoScrollbar.py
├── ModifyINI.py
├── advanced_edit_menu.py
├── alphaColorPicker.py
├── app.py
├── choose_game.py
├── customConfigParser.py
├── customFunctions.py
├── dev.py
├── menu_bar.py
├── preferences.py
├── restore_backup_window.py
├── save_changes_dialog.py
├── scalar.py
├── simple_dialog_windows.py
├── tableview_scrollable.py
├── tooltips.py
└── type_helpers.py
└── requirements.txt
/.gitattributes:
--------------------------------------------------------------------------------
1 | ###############################################################################
2 | # Set default behavior to automatically normalize line endings.
3 | ###############################################################################
4 | * text=auto
5 |
6 | ###############################################################################
7 | # Set default behavior for command prompt diff.
8 | #
9 | # This is need for earlier builds of msysgit that does not have it on by
10 | # default for csharp files.
11 | # Note: This is only used by command line
12 | ###############################################################################
13 | #*.cs diff=csharp
14 |
15 | ###############################################################################
16 | # Set the merge driver for project and solution files
17 | #
18 | # Merging from the command prompt will add diff markers to the files if there
19 | # are conflicts (Merging from VS is not affected by the settings below, in VS
20 | # the diff markers are never inserted). Diff markers may cause the following
21 | # file extensions to fail to load in VS. An alternative would be to treat
22 | # these files as binary and thus will always conflict and require user
23 | # intervention with every merge. To do so, just uncomment the entries below
24 | ###############################################################################
25 | #*.sln merge=binary
26 | #*.csproj merge=binary
27 | #*.vbproj merge=binary
28 | #*.vcxproj merge=binary
29 | #*.vcproj merge=binary
30 | #*.dbproj merge=binary
31 | #*.fsproj merge=binary
32 | #*.lsproj merge=binary
33 | #*.wixproj merge=binary
34 | #*.modelproj merge=binary
35 | #*.sqlproj merge=binary
36 | #*.wwaproj merge=binary
37 |
38 | ###############################################################################
39 | # behavior for image files
40 | #
41 | # image files are treated as binary by default.
42 | ###############################################################################
43 | #*.jpg binary
44 | #*.png binary
45 | #*.gif binary
46 |
47 | ###############################################################################
48 | # diff behavior for common document formats
49 | #
50 | # Convert binary document formats to text before diffing them. This feature
51 | # is only available from the command line. Turn it on by uncommenting the
52 | # entries below.
53 | ###############################################################################
54 | #*.doc diff=astextplain
55 | #*.DOC diff=astextplain
56 | #*.docx diff=astextplain
57 | #*.DOCX diff=astextplain
58 | #*.dot diff=astextplain
59 | #*.DOT diff=astextplain
60 | #*.pdf diff=astextplain
61 | #*.PDF diff=astextplain
62 | #*.rtf diff=astextplain
63 | #*.RTF diff=astextplain
64 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Build with PyInstaller
2 |
3 | on:
4 | push:
5 | branches:
6 | - release
7 |
8 | jobs:
9 | pyinstaller-build-linux:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v4.2.2
15 |
16 | - name: Setup Python
17 | uses: actions/setup-python@v5.4.0
18 | with:
19 | python-version: '3.11.11'
20 |
21 | - name: Install dependencies
22 | run: |
23 | python -m pip install --upgrade pip
24 | pip install -r requirements.txt
25 |
26 | - name: Install PyInstaller
27 | run: pip install pyinstaller
28 |
29 | - name: Build with PyInstaller
30 | run: pyinstaller bethini_onefile.spec
31 |
32 | - name: Upload a Build Artifact
33 | uses: actions/upload-artifact@v4.6.1
34 | with:
35 | name: Bethini-linux
36 | path: dist
37 |
38 | pyinstaller-build-windows:
39 | runs-on: windows-latest
40 |
41 | steps:
42 | - name: Checkout
43 | uses: actions/checkout@v4.2.2
44 |
45 | - name: Setup Python
46 | uses: actions/setup-python@v5.4.0
47 | with:
48 | python-version: '3.11.9'
49 |
50 | - name: Install dependencies
51 | run: |
52 | python -m pip install --upgrade pip
53 | pip install -r requirements.txt
54 |
55 | - name: Install PyInstaller
56 | run: pip install pyinstaller
57 |
58 | - name: Build with PyInstaller
59 | run: pyinstaller bethini_onefile.spec
60 |
61 | - name: Copy additional files
62 | shell: powershell
63 | run: |
64 | $workspace = $env:GITHUB_WORKSPACE
65 |
66 | # Copy top level files
67 | $src = Join-Path $workspace 'LICENSE.txt'
68 | $dst = Join-Path $workspace 'dist\LICENSE.txt'
69 | Copy-Item -LiteralPath $src -Destination $dst -Force
70 |
71 | $src = Join-Path $workspace 'README.md'
72 | $dst = Join-Path $workspace 'dist\README.md'
73 | Copy-Item -LiteralPath $src -Destination $dst -Force
74 |
75 | $src = Join-Path $workspace 'changelog.txt'
76 | $dst = Join-Path $workspace 'dist\changelog.txt'
77 | Copy-Item -LiteralPath $src -Destination $dst -Force
78 |
79 | # Icons and fonts
80 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\icons') -Force | Out-Null
81 | xcopy /e /i (Join-Path $workspace 'icons') (Join-Path $workspace 'dist\icons') | Out-Null
82 |
83 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\fonts\Comfortaa') -Force | Out-Null
84 | xcopy /e /i (Join-Path $workspace 'fonts\Comfortaa') (Join-Path $workspace 'dist\fonts\Comfortaa') | Out-Null
85 |
86 | # Apps directories and files
87 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps') -Force | Out-Null
88 |
89 | # Fallout 4 files
90 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps\Fallout 4') -Force | Out-Null
91 | $src = Join-Path $workspace 'apps\Fallout 4\Bethini.json'
92 | $dst = Join-Path $workspace 'dist\apps\Fallout 4\Bethini.json'
93 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
94 |
95 | $src = Join-Path $workspace 'apps\Fallout 4\settings.json'
96 | $dst = Join-Path $workspace 'dist\apps\Fallout 4\settings.json'
97 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
98 |
99 | # Fallout New Vegas files
100 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps\Fallout New Vegas') -Force | Out-Null
101 | $src = Join-Path $workspace 'apps\Fallout New Vegas\Bethini.json'
102 | $dst = Join-Path $workspace 'dist\apps\Fallout New Vegas\Bethini.json'
103 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
104 |
105 | $src = Join-Path $workspace 'apps\Fallout New Vegas\settings.json'
106 | $dst = Join-Path $workspace 'dist\apps\Fallout New Vegas\settings.json'
107 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
108 |
109 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps\Fallout New Vegas\images') -Force | Out-Null
110 | $src = Join-Path $workspace 'apps\Fallout New Vegas\images'
111 | $dst = Join-Path $workspace 'dist\apps\Fallout New Vegas\images'
112 | Copy-Item -LiteralPath $src -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue
113 | Remove-Item -Recurse -Force (Join-Path $workspace 'dist\apps\Fallout New Vegas\images\src') -ErrorAction SilentlyContinue
114 |
115 | # Skyrim Special Edition files
116 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps\Skyrim Special Edition') -Force | Out-Null
117 | $src = Join-Path $workspace 'apps\Skyrim Special Edition\Bethini.json'
118 | $dst = Join-Path $workspace 'dist\apps\Skyrim Special Edition\Bethini.json'
119 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
120 |
121 | $src = Join-Path $workspace 'apps\Skyrim Special Edition\settings.json'
122 | $dst = Join-Path $workspace 'dist\apps\Skyrim Special Edition\settings.json'
123 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
124 |
125 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps\Skyrim Special Edition\images') -Force | Out-Null
126 | $src = Join-Path $workspace 'apps\Skyrim Special Edition\images'
127 | $dst = Join-Path $workspace 'dist\apps\Skyrim Special Edition\images'
128 | Copy-Item -LiteralPath $src -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue
129 | Remove-Item -Recurse -Force (Join-Path $workspace 'dist\apps\Skyrim Special Edition\images\src') -ErrorAction SilentlyContinue
130 |
131 | # Starfield files
132 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps\Starfield') -Force | Out-Null
133 | $src = Join-Path $workspace 'apps\Starfield\Bethini.json'
134 | $dst = Join-Path $workspace 'dist\apps\Starfield\Bethini.json'
135 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
136 |
137 | $src = Join-Path $workspace 'apps\Starfield\settings.json'
138 | $dst = Join-Path $workspace 'dist\apps\Starfield\settings.json'
139 | Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
140 |
141 | New-Item -ItemType Directory -Path (Join-Path $workspace 'dist\apps\Starfield\images') -Force | Out-Null
142 | $src = Join-Path $workspace 'apps\Starfield\images'
143 | $dst = Join-Path $workspace 'dist\apps\Starfield\images'
144 | Copy-Item -LiteralPath $src -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue
145 | Remove-Item -Recurse -Force (Join-Path $workspace 'dist\apps\Starfield\images\src') -ErrorAction SilentlyContinue
146 |
147 |
148 | - name: Upload a Build Artifact
149 | uses: actions/upload-artifact@v4.6.1
150 | with:
151 | name: Bethini-windows
152 | path: dist
153 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 | paradigm.txt
13 | Bethini.ini
14 | Build with pyinstaller.bat
15 |
16 | # User-specific files (MonoDevelop/Xamarin Studio)
17 | *.userprefs
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Aa][Rr][Mm]/
27 | [Aa][Rr][Mm]64/
28 | bld/
29 | [Bb]in/
30 | [Oo]bj/
31 | [Ll]og/
32 | [Bb]uild/
33 | [Dd]ist/
34 |
35 | # Visual Studio 2015/2017 cache/options directory
36 | .vs/
37 | # Uncomment if you have tasks that create the project's static files in wwwroot
38 | #wwwroot/
39 |
40 | # Visual Studio 2017 auto generated files
41 | Generated\ Files/
42 |
43 | # MSTest test Results
44 | [Tt]est[Rr]esult*/
45 | [Bb]uild[Ll]og.*
46 |
47 | # NUNIT
48 | *.VisualState.xml
49 | TestResult.xml
50 |
51 | # Build Results of an ATL Project
52 | [Dd]ebugPS/
53 | [Rr]eleasePS/
54 | dlldata.c
55 |
56 | # Benchmark Results
57 | BenchmarkDotNet.Artifacts/
58 |
59 | # .NET Core
60 | project.lock.json
61 | project.fragment.lock.json
62 | artifacts/
63 |
64 | # StyleCop
65 | StyleCopReport.xml
66 |
67 | # Files built by Visual Studio
68 | *_i.c
69 | *_p.c
70 | *_h.h
71 | *.ilk
72 | *.meta
73 | *.obj
74 | *.iobj
75 | *.pch
76 | *.pdb
77 | *.ipdb
78 | *.pgc
79 | *.pgd
80 | *.rsp
81 | *.sbr
82 | *.tlb
83 | *.tli
84 | *.tlh
85 | *.tmp
86 | *.tmp_proj
87 | *_wpftmp.csproj
88 | *.log
89 | *.vspscc
90 | *.vssscc
91 | .builds
92 | *.pidb
93 | *.svclog
94 | *.scc
95 |
96 | # Chutzpah Test files
97 | _Chutzpah*
98 |
99 | # Visual C++ cache files
100 | ipch/
101 | *.aps
102 | *.ncb
103 | *.opendb
104 | *.opensdf
105 | *.sdf
106 | *.cachefile
107 | *.VC.db
108 | *.VC.VC.opendb
109 |
110 | # Visual Studio profiler
111 | *.psess
112 | *.vsp
113 | *.vspx
114 | *.sap
115 |
116 | # Visual Studio Trace Files
117 | *.e2e
118 |
119 | # TFS 2012 Local Workspace
120 | $tf/
121 |
122 | # Guidance Automation Toolkit
123 | *.gpState
124 |
125 | # ReSharper is a .NET coding add-in
126 | _ReSharper*/
127 | *.[Rr]e[Ss]harper
128 | *.DotSettings.user
129 |
130 | # JustCode is a .NET coding add-in
131 | .JustCode
132 |
133 | # TeamCity is a build add-in
134 | _TeamCity*
135 |
136 | # DotCover is a Code Coverage Tool
137 | *.dotCover
138 |
139 | # AxoCover is a Code Coverage Tool
140 | .axoCover/*
141 | !.axoCover/settings.json
142 |
143 | # Visual Studio code coverage results
144 | *.coverage
145 | *.coveragexml
146 |
147 | # NCrunch
148 | _NCrunch_*
149 | .*crunch*.local.xml
150 | nCrunchTemp_*
151 |
152 | # MightyMoose
153 | *.mm.*
154 | AutoTest.Net/
155 |
156 | # Web workbench (sass)
157 | .sass-cache/
158 |
159 | # Installshield output folder
160 | [Ee]xpress/
161 |
162 | # DocProject is a documentation generator add-in
163 | DocProject/buildhelp/
164 | DocProject/Help/*.HxT
165 | DocProject/Help/*.HxC
166 | DocProject/Help/*.hhc
167 | DocProject/Help/*.hhk
168 | DocProject/Help/*.hhp
169 | DocProject/Help/Html2
170 | DocProject/Help/html
171 |
172 | # Click-Once directory
173 | publish/
174 |
175 | # Publish Web Output
176 | *.[Pp]ublish.xml
177 | *.azurePubxml
178 | # Note: Comment the next line if you want to checkin your web deploy settings,
179 | # but database connection strings (with potential passwords) will be unencrypted
180 | *.pubxml
181 | *.publishproj
182 |
183 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
184 | # checkin your Azure Web App publish settings, but sensitive information contained
185 | # in these scripts will be unencrypted
186 | PublishScripts/
187 |
188 | # NuGet Packages
189 | *.nupkg
190 | # The packages folder can be ignored because of Package Restore
191 | **/[Pp]ackages/*
192 | # except build/, which is used as an MSBuild target.
193 | !**/[Pp]ackages/build/
194 | # Uncomment if necessary however generally it will be regenerated when needed
195 | #!**/[Pp]ackages/repositories.config
196 | # NuGet v3's project.json files produces more ignorable files
197 | *.nuget.props
198 | *.nuget.targets
199 |
200 | # Microsoft Azure Build Output
201 | csx/
202 | *.build.csdef
203 |
204 | # Microsoft Azure Emulator
205 | ecf/
206 | rcf/
207 |
208 | # Windows Store app package directories and files
209 | AppPackages/
210 | BundleArtifacts/
211 | Package.StoreAssociation.xml
212 | _pkginfo.txt
213 | *.appx
214 |
215 | # Visual Studio cache files
216 | # files ending in .cache can be ignored
217 | *.[Cc]ache
218 | # but keep track of directories ending in .cache
219 | !?*.[Cc]ache/
220 |
221 | # Others
222 | ClientBin/
223 | ~$*
224 | *~
225 | *.dbmdl
226 | *.dbproj.schemaview
227 | *.jfm
228 | *.pfx
229 | *.publishsettings
230 | orleans.codegen.cs
231 |
232 | # Including strong name files can present a security risk
233 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
234 | #*.snk
235 |
236 | # Since there are multiple workflows, uncomment next line to ignore bower_components
237 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
238 | #bower_components/
239 |
240 | # RIA/Silverlight projects
241 | Generated_Code/
242 |
243 | # Backup & report files from converting an old project file
244 | # to a newer Visual Studio version. Backup files are not needed,
245 | # because we have git ;-)
246 | _UpgradeReport_Files/
247 | Backup*/
248 | UpgradeLog*.XML
249 | UpgradeLog*.htm
250 | ServiceFabricBackup/
251 | *.rptproj.bak
252 |
253 | # SQL Server files
254 | *.mdf
255 | *.ldf
256 | *.ndf
257 |
258 | # Business Intelligence projects
259 | *.rdl.data
260 | *.bim.layout
261 | *.bim_*.settings
262 | *.rptproj.rsuser
263 | *- Backup*.rdl
264 |
265 | # Microsoft Fakes
266 | FakesAssemblies/
267 |
268 | # GhostDoc plugin setting file
269 | *.GhostDoc.xml
270 |
271 | # Node.js Tools for Visual Studio
272 | .ntvs_analysis.dat
273 | node_modules/
274 |
275 | # Visual Studio 6 build log
276 | *.plg
277 |
278 | # Visual Studio 6 workspace options file
279 | *.opt
280 |
281 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
282 | *.vbw
283 |
284 | # Visual Studio LightSwitch build output
285 | **/*.HTMLClient/GeneratedArtifacts
286 | **/*.DesktopClient/GeneratedArtifacts
287 | **/*.DesktopClient/ModelManifest.xml
288 | **/*.Server/GeneratedArtifacts
289 | **/*.Server/ModelManifest.xml
290 | _Pvt_Extensions
291 |
292 | # Paket dependency manager
293 | .paket/paket.exe
294 | paket-files/
295 |
296 | # FAKE - F# Make
297 | .fake/
298 |
299 | # JetBrains Rider
300 | .idea/
301 | *.sln.iml
302 |
303 | # CodeRush personal settings
304 | .cr/personal
305 |
306 | # Python Tools for Visual Studio (PTVS)
307 | __pycache__/
308 | *.pyc
309 |
310 | # Cake - Uncomment if you are using it
311 | # tools/**
312 | # !tools/packages.config
313 |
314 | # Tabs Studio
315 | *.tss
316 |
317 | # Telerik's JustMock configuration file
318 | *.jmconfig
319 |
320 | # BizTalk build output
321 | *.btp.cs
322 | *.btm.cs
323 | *.odx.cs
324 | *.xsd.cs
325 |
326 | # OpenCover UI analysis results
327 | OpenCover/
328 |
329 | # Azure Stream Analytics local run output
330 | ASALocalRun/
331 |
332 | # MSBuild Binary and Structured Log
333 | *.binlog
334 |
335 | # NVidia Nsight GPU debugger configuration file
336 | *.nvuser
337 |
338 | # MFractors (Xamarin productivity tool) working folder
339 | .mfractor/
340 |
341 | # Local History for Visual Studio
342 | .localhistory/
343 |
344 | # BeatPulse healthcheck temp database
345 | healthchecksdb
346 | /.vscode/.ropeproject
347 | /env
348 | /media
349 | /freezeit.py
350 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "apps/Fallout 4"]
2 | path = apps/Fallout 4
3 | url = https://github.com/DoubleYouC/Bethini-Pie-Fallout-4-Plugin.git
4 | [submodule "apps/Skyrim Special Edition"]
5 | path = apps/Skyrim Special Edition
6 | url = https://github.com/DoubleYouC/Bethini-Pie-Skyrim-Special-Edition-Plugin.git
7 | [submodule "apps/Starfield"]
8 | path = apps/Starfield
9 | url = https://github.com/DoubleYouC/Bethini-Pie-Starfield-Plugin.git
10 | [submodule "apps/Fallout New Vegas"]
11 | path = apps/Fallout New Vegas
12 | url = https://github.com/DoubleYouC/Bethini-Pie-Fallout-New-Vegas-Plugin.git
13 |
--------------------------------------------------------------------------------
/Bethini-Pie-Performance-INI-Editor.pyproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Debug
5 | 2.0
6 | {21a397b2-4031-43ae-b811-b5804731bdd0}
7 |
8 | Bethini.pyw
9 |
10 | .
11 | .
12 | {888888a0-9f3d-457c-b088-3a5042f75d52}
13 | Standard Python launcher
14 | Global|PythonCore|3.11
15 |
16 |
17 |
18 |
19 | 10.0
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 | env
117 | 3.11
118 | env (Python 3.11 (64-bit))
119 | Scripts\python.exe
120 | Scripts\pythonw.exe
121 | PYTHONPATH
122 | X64
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/Bethini-Pie-Performance-INI-Editor.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 16
4 | VisualStudioVersion = 16.0.31025.194
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "Bethini-Pie-Performance-INI-Editor", "Bethini-Pie-Performance-INI-Editor.pyproj", "{21A397B2-4031-43AE-B811-B5804731BDD0}"
7 | EndProject
8 | Global
9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | Debug|Any CPU = Debug|Any CPU
11 | Release|Any CPU = Release|Any CPU
12 | EndGlobalSection
13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
14 | {21A397B2-4031-43AE-B811-B5804731BDD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15 | {21A397B2-4031-43AE-B811-B5804731BDD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
16 | EndGlobalSection
17 | GlobalSection(SolutionProperties) = preSolution
18 | HideSolutionNode = FALSE
19 | EndGlobalSection
20 | GlobalSection(ExtensibilityGlobals) = postSolution
21 | SolutionGuid = {5F07800D-C675-47D6-9A00-4C658108BE05}
22 | EndGlobalSection
23 | EndGlobal
24 |
--------------------------------------------------------------------------------
/Bethini.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 |
3 | block_cipher = None
4 |
5 | #This function allows us to add files to directories that need to be included in
6 | #the application's directory in order to function.
7 | def recurseDirs(directory):
8 | cwd = os.getcwd()
9 | data = []
10 | for thedir in directory:
11 | root_dir = f'{cwd}\\{thedir}'
12 | for dir_, _, files in os.walk(root_dir):
13 | for file_name in files:
14 | rel_dir = os.path.relpath(dir_, root_dir)
15 | rel_file = os.path.join(rel_dir, file_name)
16 | data.append((f'{thedir}\\{rel_file}', f'{thedir}\\{rel_dir}'))
17 | #We need to add the icon to the root folder as well.
18 | data.append(('Icon.ico','.'))
19 | return data
20 |
21 |
22 | a = Analysis(['Bethini.pyw'],
23 | pathex=['S:\\Source\\Repos\\Bethini-Pie-Performance-INI-Editor'],
24 | binaries=[],
25 | datas=recurseDirs(['apps', 'icons']),
26 | hiddenimports=[],
27 | hookspath=[],
28 | runtime_hooks=[],
29 | excludes=[],
30 | win_no_prefer_redirects=False,
31 | win_private_assemblies=False,
32 | cipher=block_cipher,
33 | noarchive=False)
34 | pyz = PYZ(a.pure, a.zipped_data,
35 | cipher=block_cipher)
36 | exe = EXE(pyz,
37 | a.scripts,
38 | [],
39 | exclude_binaries=True,
40 | name='Bethini',
41 | debug=True,
42 | bootloader_ignore_signals=False,
43 | strip=False,
44 | upx=True,
45 | console=False,
46 | icon='Icon.ico')
47 | coll = COLLECT(exe,
48 | a.binaries,
49 | a.zipfiles,
50 | a.datas,
51 | strip=False,
52 | upx=True,
53 | upx_exclude=[],
54 | name='Bethini')
55 |
56 |
57 |
--------------------------------------------------------------------------------
/Icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/Icon.ico
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Attribution-NonCommercial-ShareAlike 4.0 International
2 |
3 | =======================================================================
4 |
5 | Creative Commons Corporation ("Creative Commons") is not a law firm and
6 | does not provide legal services or legal advice. Distribution of
7 | Creative Commons public licenses does not create a lawyer-client or
8 | other relationship. Creative Commons makes its licenses and related
9 | information available on an "as-is" basis. Creative Commons gives no
10 | warranties regarding its licenses, any material licensed under their
11 | terms and conditions, or any related information. Creative Commons
12 | disclaims all liability for damages resulting from their use to the
13 | fullest extent possible.
14 |
15 | Using Creative Commons Public Licenses
16 |
17 | Creative Commons public licenses provide a standard set of terms and
18 | conditions that creators and other rights holders may use to share
19 | original works of authorship and other material subject to copyright
20 | and certain other rights specified in the public license below. The
21 | following considerations are for informational purposes only, are not
22 | exhaustive, and do not form part of our licenses.
23 |
24 | Considerations for licensors: Our public licenses are
25 | intended for use by those authorized to give the public
26 | permission to use material in ways otherwise restricted by
27 | copyright and certain other rights. Our licenses are
28 | irrevocable. Licensors should read and understand the terms
29 | and conditions of the license they choose before applying it.
30 | Licensors should also secure all rights necessary before
31 | applying our licenses so that the public can reuse the
32 | material as expected. Licensors should clearly mark any
33 | material not subject to the license. This includes other CC-
34 | licensed material, or material used under an exception or
35 | limitation to copyright. More considerations for licensors:
36 | wiki.creativecommons.org/Considerations_for_licensors
37 |
38 | Considerations for the public: By using one of our public
39 | licenses, a licensor grants the public permission to use the
40 | licensed material under specified terms and conditions. If
41 | the licensor's permission is not necessary for any reason--for
42 | example, because of any applicable exception or limitation to
43 | copyright--then that use is not regulated by the license. Our
44 | licenses grant only permissions under copyright and certain
45 | other rights that a licensor has authority to grant. Use of
46 | the licensed material may still be restricted for other
47 | reasons, including because others have copyright or other
48 | rights in the material. A licensor may make special requests,
49 | such as asking that all changes be marked or described.
50 | Although not required by our licenses, you are encouraged to
51 | respect those requests where reasonable. More considerations
52 | for the public:
53 | wiki.creativecommons.org/Considerations_for_licensees
54 |
55 | =======================================================================
56 |
57 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
58 | Public License
59 |
60 | By exercising the Licensed Rights (defined below), You accept and agree
61 | to be bound by the terms and conditions of this Creative Commons
62 | Attribution-NonCommercial-ShareAlike 4.0 International Public License
63 | ("Public License"). To the extent this Public License may be
64 | interpreted as a contract, You are granted the Licensed Rights in
65 | consideration of Your acceptance of these terms and conditions, and the
66 | Licensor grants You such rights in consideration of benefits the
67 | Licensor receives from making the Licensed Material available under
68 | these terms and conditions.
69 |
70 |
71 | Section 1 -- Definitions.
72 |
73 | a. Adapted Material means material subject to Copyright and Similar
74 | Rights that is derived from or based upon the Licensed Material
75 | and in which the Licensed Material is translated, altered,
76 | arranged, transformed, or otherwise modified in a manner requiring
77 | permission under the Copyright and Similar Rights held by the
78 | Licensor. For purposes of this Public License, where the Licensed
79 | Material is a musical work, performance, or sound recording,
80 | Adapted Material is always produced where the Licensed Material is
81 | synched in timed relation with a moving image.
82 |
83 | b. Adapter's License means the license You apply to Your Copyright
84 | and Similar Rights in Your contributions to Adapted Material in
85 | accordance with the terms and conditions of this Public License.
86 |
87 | c. BY-NC-SA Compatible License means a license listed at
88 | creativecommons.org/compatiblelicenses, approved by Creative
89 | Commons as essentially the equivalent of this Public License.
90 |
91 | d. Copyright and Similar Rights means copyright and/or similar rights
92 | closely related to copyright including, without limitation,
93 | performance, broadcast, sound recording, and Sui Generis Database
94 | Rights, without regard to how the rights are labeled or
95 | categorized. For purposes of this Public License, the rights
96 | specified in Section 2(b)(1)-(2) are not Copyright and Similar
97 | Rights.
98 |
99 | e. Effective Technological Measures means those measures that, in the
100 | absence of proper authority, may not be circumvented under laws
101 | fulfilling obligations under Article 11 of the WIPO Copyright
102 | Treaty adopted on December 20, 1996, and/or similar international
103 | agreements.
104 |
105 | f. Exceptions and Limitations means fair use, fair dealing, and/or
106 | any other exception or limitation to Copyright and Similar Rights
107 | that applies to Your use of the Licensed Material.
108 |
109 | g. License Elements means the license attributes listed in the name
110 | of a Creative Commons Public License. The License Elements of this
111 | Public License are Attribution, NonCommercial, and ShareAlike.
112 |
113 | h. Licensed Material means the artistic or literary work, database,
114 | or other material to which the Licensor applied this Public
115 | License.
116 |
117 | i. Licensed Rights means the rights granted to You subject to the
118 | terms and conditions of this Public License, which are limited to
119 | all Copyright and Similar Rights that apply to Your use of the
120 | Licensed Material and that the Licensor has authority to license.
121 |
122 | j. Licensor means the individual(s) or entity(ies) granting rights
123 | under this Public License.
124 |
125 | k. NonCommercial means not primarily intended for or directed towards
126 | commercial advantage or monetary compensation. For purposes of
127 | this Public License, the exchange of the Licensed Material for
128 | other material subject to Copyright and Similar Rights by digital
129 | file-sharing or similar means is NonCommercial provided there is
130 | no payment of monetary compensation in connection with the
131 | exchange.
132 |
133 | l. Share means to provide material to the public by any means or
134 | process that requires permission under the Licensed Rights, such
135 | as reproduction, public display, public performance, distribution,
136 | dissemination, communication, or importation, and to make material
137 | available to the public including in ways that members of the
138 | public may access the material from a place and at a time
139 | individually chosen by them.
140 |
141 | m. Sui Generis Database Rights means rights other than copyright
142 | resulting from Directive 96/9/EC of the European Parliament and of
143 | the Council of 11 March 1996 on the legal protection of databases,
144 | as amended and/or succeeded, as well as other essentially
145 | equivalent rights anywhere in the world.
146 |
147 | n. You means the individual or entity exercising the Licensed Rights
148 | under this Public License. Your has a corresponding meaning.
149 |
150 |
151 | Section 2 -- Scope.
152 |
153 | a. License grant.
154 |
155 | 1. Subject to the terms and conditions of this Public License,
156 | the Licensor hereby grants You a worldwide, royalty-free,
157 | non-sublicensable, non-exclusive, irrevocable license to
158 | exercise the Licensed Rights in the Licensed Material to:
159 |
160 | a. reproduce and Share the Licensed Material, in whole or
161 | in part, for NonCommercial purposes only; and
162 |
163 | b. produce, reproduce, and Share Adapted Material for
164 | NonCommercial purposes only.
165 |
166 | 2. Exceptions and Limitations. For the avoidance of doubt, where
167 | Exceptions and Limitations apply to Your use, this Public
168 | License does not apply, and You do not need to comply with
169 | its terms and conditions.
170 |
171 | 3. Term. The term of this Public License is specified in Section
172 | 6(a).
173 |
174 | 4. Media and formats; technical modifications allowed. The
175 | Licensor authorizes You to exercise the Licensed Rights in
176 | all media and formats whether now known or hereafter created,
177 | and to make technical modifications necessary to do so. The
178 | Licensor waives and/or agrees not to assert any right or
179 | authority to forbid You from making technical modifications
180 | necessary to exercise the Licensed Rights, including
181 | technical modifications necessary to circumvent Effective
182 | Technological Measures. For purposes of this Public License,
183 | simply making modifications authorized by this Section 2(a)
184 | (4) never produces Adapted Material.
185 |
186 | 5. Downstream recipients.
187 |
188 | a. Offer from the Licensor -- Licensed Material. Every
189 | recipient of the Licensed Material automatically
190 | receives an offer from the Licensor to exercise the
191 | Licensed Rights under the terms and conditions of this
192 | Public License.
193 |
194 | b. Additional offer from the Licensor -- Adapted Material.
195 | Every recipient of Adapted Material from You
196 | automatically receives an offer from the Licensor to
197 | exercise the Licensed Rights in the Adapted Material
198 | under the conditions of the Adapter's License You apply.
199 |
200 | c. No downstream restrictions. You may not offer or impose
201 | any additional or different terms or conditions on, or
202 | apply any Effective Technological Measures to, the
203 | Licensed Material if doing so restricts exercise of the
204 | Licensed Rights by any recipient of the Licensed
205 | Material.
206 |
207 | 6. No endorsement. Nothing in this Public License constitutes or
208 | may be construed as permission to assert or imply that You
209 | are, or that Your use of the Licensed Material is, connected
210 | with, or sponsored, endorsed, or granted official status by,
211 | the Licensor or others designated to receive attribution as
212 | provided in Section 3(a)(1)(A)(i).
213 |
214 | b. Other rights.
215 |
216 | 1. Moral rights, such as the right of integrity, are not
217 | licensed under this Public License, nor are publicity,
218 | privacy, and/or other similar personality rights; however, to
219 | the extent possible, the Licensor waives and/or agrees not to
220 | assert any such rights held by the Licensor to the limited
221 | extent necessary to allow You to exercise the Licensed
222 | Rights, but not otherwise.
223 |
224 | 2. Patent and trademark rights are not licensed under this
225 | Public License.
226 |
227 | 3. To the extent possible, the Licensor waives any right to
228 | collect royalties from You for the exercise of the Licensed
229 | Rights, whether directly or through a collecting society
230 | under any voluntary or waivable statutory or compulsory
231 | licensing scheme. In all other cases the Licensor expressly
232 | reserves any right to collect such royalties, including when
233 | the Licensed Material is used other than for NonCommercial
234 | purposes.
235 |
236 |
237 | Section 3 -- License Conditions.
238 |
239 | Your exercise of the Licensed Rights is expressly made subject to the
240 | following conditions.
241 |
242 | a. Attribution.
243 |
244 | 1. If You Share the Licensed Material (including in modified
245 | form), You must:
246 |
247 | a. retain the following if it is supplied by the Licensor
248 | with the Licensed Material:
249 |
250 | i. identification of the creator(s) of the Licensed
251 | Material and any others designated to receive
252 | attribution, in any reasonable manner requested by
253 | the Licensor (including by pseudonym if
254 | designated);
255 |
256 | ii. a copyright notice;
257 |
258 | iii. a notice that refers to this Public License;
259 |
260 | iv. a notice that refers to the disclaimer of
261 | warranties;
262 |
263 | v. a URI or hyperlink to the Licensed Material to the
264 | extent reasonably practicable;
265 |
266 | b. indicate if You modified the Licensed Material and
267 | retain an indication of any previous modifications; and
268 |
269 | c. indicate the Licensed Material is licensed under this
270 | Public License, and include the text of, or the URI or
271 | hyperlink to, this Public License.
272 |
273 | 2. You may satisfy the conditions in Section 3(a)(1) in any
274 | reasonable manner based on the medium, means, and context in
275 | which You Share the Licensed Material. For example, it may be
276 | reasonable to satisfy the conditions by providing a URI or
277 | hyperlink to a resource that includes the required
278 | information.
279 | 3. If requested by the Licensor, You must remove any of the
280 | information required by Section 3(a)(1)(A) to the extent
281 | reasonably practicable.
282 |
283 | b. ShareAlike.
284 |
285 | In addition to the conditions in Section 3(a), if You Share
286 | Adapted Material You produce, the following conditions also apply.
287 |
288 | 1. The Adapter's License You apply must be a Creative Commons
289 | license with the same License Elements, this version or
290 | later, or a BY-NC-SA Compatible License.
291 |
292 | 2. You must include the text of, or the URI or hyperlink to, the
293 | Adapter's License You apply. You may satisfy this condition
294 | in any reasonable manner based on the medium, means, and
295 | context in which You Share Adapted Material.
296 |
297 | 3. You may not offer or impose any additional or different terms
298 | or conditions on, or apply any Effective Technological
299 | Measures to, Adapted Material that restrict exercise of the
300 | rights granted under the Adapter's License You apply.
301 |
302 |
303 | Section 4 -- Sui Generis Database Rights.
304 |
305 | Where the Licensed Rights include Sui Generis Database Rights that
306 | apply to Your use of the Licensed Material:
307 |
308 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right
309 | to extract, reuse, reproduce, and Share all or a substantial
310 | portion of the contents of the database for NonCommercial purposes
311 | only;
312 |
313 | b. if You include all or a substantial portion of the database
314 | contents in a database in which You have Sui Generis Database
315 | Rights, then the database in which You have Sui Generis Database
316 | Rights (but not its individual contents) is Adapted Material,
317 | including for purposes of Section 3(b); and
318 |
319 | c. You must comply with the conditions in Section 3(a) if You Share
320 | all or a substantial portion of the contents of the database.
321 |
322 | For the avoidance of doubt, this Section 4 supplements and does not
323 | replace Your obligations under this Public License where the Licensed
324 | Rights include other Copyright and Similar Rights.
325 |
326 |
327 | Section 5 -- Disclaimer of Warranties and Limitation of Liability.
328 |
329 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
330 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
331 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
332 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
333 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
334 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
335 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
336 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
337 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
338 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
339 |
340 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
341 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
342 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
343 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
344 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
345 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
346 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
347 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
348 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
349 |
350 | c. The disclaimer of warranties and limitation of liability provided
351 | above shall be interpreted in a manner that, to the extent
352 | possible, most closely approximates an absolute disclaimer and
353 | waiver of all liability.
354 |
355 |
356 | Section 6 -- Term and Termination.
357 |
358 | a. This Public License applies for the term of the Copyright and
359 | Similar Rights licensed here. However, if You fail to comply with
360 | this Public License, then Your rights under this Public License
361 | terminate automatically.
362 |
363 | b. Where Your right to use the Licensed Material has terminated under
364 | Section 6(a), it reinstates:
365 |
366 | 1. automatically as of the date the violation is cured, provided
367 | it is cured within 30 days of Your discovery of the
368 | violation; or
369 |
370 | 2. upon express reinstatement by the Licensor.
371 |
372 | For the avoidance of doubt, this Section 6(b) does not affect any
373 | right the Licensor may have to seek remedies for Your violations
374 | of this Public License.
375 |
376 | c. For the avoidance of doubt, the Licensor may also offer the
377 | Licensed Material under separate terms or conditions or stop
378 | distributing the Licensed Material at any time; however, doing so
379 | will not terminate this Public License.
380 |
381 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
382 | License.
383 |
384 |
385 | Section 7 -- Other Terms and Conditions.
386 |
387 | a. The Licensor shall not be bound by any additional or different
388 | terms or conditions communicated by You unless expressly agreed.
389 |
390 | b. Any arrangements, understandings, or agreements regarding the
391 | Licensed Material not stated herein are separate from and
392 | independent of the terms and conditions of this Public License.
393 |
394 |
395 | Section 8 -- Interpretation.
396 |
397 | a. For the avoidance of doubt, this Public License does not, and
398 | shall not be interpreted to, reduce, limit, restrict, or impose
399 | conditions on any use of the Licensed Material that could lawfully
400 | be made without permission under this Public License.
401 |
402 | b. To the extent possible, if any provision of this Public License is
403 | deemed unenforceable, it shall be automatically reformed to the
404 | minimum extent necessary to make it enforceable. If the provision
405 | cannot be reformed, it shall be severed from this Public License
406 | without affecting the enforceability of the remaining terms and
407 | conditions.
408 |
409 | c. No term or condition of this Public License will be waived and no
410 | failure to comply consented to unless expressly agreed to by the
411 | Licensor.
412 |
413 | d. Nothing in this Public License constitutes or may be interpreted
414 | as a limitation upon, or waiver of, any privileges and immunities
415 | that apply to the Licensor or You, including from the legal
416 | processes of any jurisdiction or authority.
417 |
418 | =======================================================================
419 |
420 | Creative Commons is not a party to its public
421 | licenses. Notwithstanding, Creative Commons may elect to apply one of
422 | its public licenses to material it publishes and in those instances
423 | will be considered the “Licensor.” The text of the Creative Commons
424 | public licenses is dedicated to the public domain under the CC0 Public
425 | Domain Dedication. Except for the limited purpose of indicating that
426 | material is shared under a Creative Commons public license or as
427 | otherwise permitted by the Creative Commons policies published at
428 | creativecommons.org/policies, Creative Commons does not authorize the
429 | use of the trademark "Creative Commons" or any other trademark or logo
430 | of Creative Commons without its prior written consent including,
431 | without limitation, in connection with any unauthorized modifications
432 | to any of its public licenses or any other arrangements,
433 | understandings, or agreements concerning use of licensed material. For
434 | the avoidance of doubt, this paragraph does not form part of the
435 | public licenses.
436 |
437 | Creative Commons may be contacted at creativecommons.org.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bethini Pie
2 |
3 | ## About
4 | Bethini Pie is an INI editor designed to allow advanced customization of game configuration settings.
5 |
6 | ## Ressources
7 | - Official Download Page on Nexus Mods: https://www.nexusmods.com/site/mods/631/
8 | - Bethini Support on STEP Forums: https://stepmodifications.org/forum/forum/200-bethini-support/
9 |
10 | ## Development
11 | - This project requires Python >= 3.11
12 | - For required pip packages, see `requirements.txt`
--------------------------------------------------------------------------------
/bethini_onefile.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 |
3 | block_cipher = None
4 |
5 |
6 | a = Analysis(['Bethini.pyw'],
7 | pathex=[],
8 | binaries=[],
9 | datas=[],
10 | hiddenimports=[],
11 | hookspath=[],
12 | runtime_hooks=[],
13 | excludes=[],
14 | win_no_prefer_redirects=False,
15 | win_private_assemblies=False,
16 | cipher=block_cipher,
17 | noarchive=False)
18 | pyz = PYZ(a.pure, a.zipped_data,
19 | cipher=block_cipher)
20 | exe = EXE(pyz,
21 | a.scripts,
22 | a.binaries,
23 | a.zipfiles,
24 | a.datas,
25 | [],
26 | name='Bethini',
27 | debug=True,
28 | bootloader_ignore_signals=False,
29 | strip=False,
30 | upx=True,
31 | upx_exclude=[],
32 | runtime_tmpdir=None,
33 | console=False,
34 | icon='Icon.ico')
35 |
--------------------------------------------------------------------------------
/changelog.txt:
--------------------------------------------------------------------------------
1 | v4.15
2 | Added Fallout New Vegas plugin.
3 |
4 | Bethini Pie update notes:
5 | -Changed Preset button appearance.
6 | -Changed how the Preview window displays ini settings.
7 | -Added Notes to Advanced edit menu popups.
8 | -Added option to remove unknown settings.
9 | -Bugfix: The exit menu prompt could sometimes hang.
10 | -Bugfix: Saving ini files could fail if an ini file was read-only. Now it prompts the user if they want to override the read-only flag.
11 | -Bugfix: Restore Backup could fail if the file was read-only. Now it prompts the user if they want to override the read-only flag.
12 | -Bugfix: Unhandled exception when starting directory was not the application directory.
13 | -Bugfix: Unhandled exception when logging changes made when a section was removed.
14 |
15 | Skyrim Special Edition plugin update notes:
16 | -Setup tab notes added.
17 | -Broke up the Disable Grass toggle into its individual ini settings (Draw Grass, Create Grass, and Load Grass GID).
18 | -Bugfix: Display Mode sometimes saying Custom instead of Fullscreen.
19 | -Bugfix: Disable Kill Cam was pointing to the wrong section.
20 |
21 | Fallout 4 plugin update notes:
22 | -Setup tab notes added.
23 | -Bugfix: Display Mode sometimes saying Custom instead of Fullscreen.
24 |
25 | Starfield plugin update notes:
26 | -Setup tab notes updated.
27 | -Directional Shadow LOD now saves to the Ultra.ini.
28 |
29 | v4.14
30 | Bethini Pie update notes:
31 | -Bugfix: The program could hang while attempting to autodetect paths.
32 | -Bugfix: The program could hang while attempting to close the Choose Game menu without ever having selected a game.
33 | -Bugfix: Color setting float precision issues.
34 | -Bugfix: Window height could be less than optimal, allowing the vertical scrollbar to show when undesired.
35 | -Added the ability to edit the Custom ini files when they are the winning override.
36 | -Excessive logging was significantly slowing down operations. Log level now defaults to Info level instead of Debug level. This can be changed in Preferences if more verbose logging is required for diagnosing issues with the program.
37 |
38 | v4.13
39 | Bethini Pie update notes:
40 | -Bugfix: Fixed UnicodeDecodeError when reading some ini files
41 | -Bugfix: Always Select Game preferences option was not working (thanks to wxMichael) (#12, #25)
42 | -Added Advanced tab
43 | -Added Log tab
44 | -Added table of changes made to the Save dialog
45 | -Preferences are now hardcoded instead of being maintained inside the game plugins
46 | -Restore Backups function has been removed from the Setup window and moved to the File Menu. It now allows selecting and restoring backups per individual ini file.
47 | -Menu bar was changed so it matches the theme
48 | -Closing the program now asks you if you are sure you want to quit to prevent accidental exit
49 | -Linux improvements courtesy of ddbb07 (#8, #28)
50 | -Logging improvements and under-the-hood changes courtesy of thraindk (#20, #24)
51 | -Miscellaneous under-the-hood improvements and bug fixes courtesy of wxMichael (#1, #20, #25)
52 |
53 | Fallout 4 plugin update notes:
54 | -Bugfix: Console Screen Percentage arrow buttons did not increment the value.
55 | -Bugfix: Console Text Size arrow buttons did not increment the value.
56 | -Added Console Buffer Size
57 | -Added Console Selection Color
58 |
59 | Skyrim Special Edition plugin update notes:
60 | -Bugfix: Console Screen Percentage arrow buttons did not increment the value.
61 | -Bugfix: Console Text Size arrow buttons did not increment the value.
62 | -Added Disable Kill Cam (thanks to tamerxkilinc)
63 | -Added Console Buffer Size
64 |
65 | Starfield plugin update notes:
66 | -Bugfix: Console Screen Percentage arrow buttons did not increment the value.
67 | -Bugfix: Console Text Size arrow buttons did not increment the value.
68 |
69 | v4.12
70 | Bethini Pie update notes:
71 | -Bugfix: Fix missing font for Linux users. (#8, #9, #10)
72 |
73 | Fallout 4 plugin update notes:
74 | -Bugfix: Survival difficulty incorrectly used 5 instead of 6.
75 |
76 | Skyrim Special Edition plugin update notes:
77 | -Bugfix: Difficulty settings used Fallout 4 names instead of their Skyrim equivalents.
78 | -Presets ignore bFreebiesSeen
79 |
80 | Starfield plugin update notes:
81 | -Updated for official update 1.14.74
82 | -Bugfix: Fixed incorrect sResourceIndexFileList default value (#16)
83 | -Don't add Starfield.ini settings to StarfieldCustom.ini by default
84 | -Removed Aspect Ratio dropdown, since the official default value is now expanded enough to not really need it
85 | -Updated Upscaling dropdown
86 | -Updated Gamma slider to also set the UI Gamma as well, since that is the same as the in-game settings menu behavior
87 |
88 | v4.11
89 | Skyrim Special Edition plugin update notes:
90 | -Bugfix: Remove Map Blur description was wrong
91 | -Subtitle Language changed to Text Language
92 | Fallout 4 plugin update notes:
93 | -Bugfix: Pip-Boy FX settings on and off values were switched
94 | -Bugfix: Poor preset's shadows were fixed
95 | -Added Text Language
96 | -Added Dialogue Camera
97 | -Added Crosshair
98 | -Added HUD Active Effects
99 | -Added Companion App
100 | -Added Constraint Width
101 | -Added Constraint Height
102 | -Added Constraint TLX
103 | -Added Constraint TLY
104 | -Added Controller
105 | -Added Controller Vibration
106 | -Added Controller Sensitivity
107 | -Added Controller Pip-Boy Cursor Speed
108 | -Improved Nvflex description
109 | -Corrected spelling of Pip-Boy
110 | -Renamed Show Quest Markers to Quest Markers
111 | -Renamed Show Floating Quest Markers to Floating Quest Markers
112 | -Renamed Show Compass to Compass
113 | -Shadow Splits now has the option of 1 in its dropdown
114 | -Settings definitions updated to include the Next Gen update changes
115 |
116 | v4.10
117 | Bethini Pie update notes:
118 | -Bugfix: Locale and date/time issues
119 |
120 | Starfield plugin update notes:
121 | -Corrected rgba default values.
122 | -Setup window now has a small explanation for Mod Organizer users.
123 | -FOV slider updated for latest game version.
124 | -Added Max Buffer Size (for the console) combobox.
125 | -Photo Mode Folder is now a combobox.
126 | -Photo Mode Folder is no longer set to "Photos" via the "Apply Recommended Tweaks" button, as Mod Organizer users will prefer to leave it at default.
127 |
128 | v4.9.1
129 | Bethini Pie update notes:
130 | -Bugfix: Fixed bad padding value.
131 |
132 | v4.9
133 | Bethini Pie update notes:
134 | -Bugfix: Exiting without selecting a game could cause it to hang instead of exiting.
135 | -Improved exception handling.
136 | -New startup "Choose Game" window.
137 | -New theme system.
138 | -Added version number identification.
139 |
140 | Fallout 4 plugin update notes:
141 | -Bugfix: Load Loose Files was supposed to be applied when you use the Apply Recommended Tweaks button.
142 | -Bugfix: Fade In On Load wasn't being set properly.
143 | -Removed Default FOV and Default 1st Person FOV sliders, as they only cause issues.
144 | -Added Enable File Selection.
145 | -Added 3rd Person Aim FOV.
146 | -Added Force Update Diffuse Only toggle.
147 | -Added Texture Upgrade/Degrade distance settings.
148 | -Added Precombines toggle.
149 | -Added Previs toggle.
150 | -Added Starting Console Command entry.
151 | -Added Console Hotkeys toggle.
152 | -Added Console INI entry.
153 | -Added Intro Music File entry.
154 | -Added Pipboy FX toggle.
155 | -Added Radial Blur toggle.
156 | -Added Focus Shadows Dialogue slider.
157 | -Changed Diable Combat Dialogue to simply Combat Dialogue to make it more clear.
158 | -Changed Over-Encumbered Reminder from and entry to a combobox widget.
159 | -Changed Intro Music toggle to set bPlayMainMenuMusic:General instead of sMainMenuMusic:General.
160 |
161 | Starfield plugin update notes:
162 | -Remember game path when switching between games.
163 | -Removed Anisotropic Filtering. The setting no longer works. If you want to use higher AF, force it in your graphics driver, but delete the %LocalAppData%\Starfield\Pipeline.cache file if you do so, or it will cause rendering bugs.
164 | -Removed Mipmap Bias. The game now manages this automatically.
165 |
166 | v4.8
167 | Bethini Pie update notes:
168 | -Added Ctrl+S hotkey for saving.
169 | -Added support for '#' as a comment character.
170 | -Overhauled appearance and made DPI Aware.
171 |
172 | Starfield plugin update notes:
173 | -Added Photo Mode Folder entry.
174 | -Expanded Far Distance slider to 12000.
175 | -Major overhaul of the settings.json to account for the full dump of valid inis and their values.
176 | -Updated presets.
177 |
178 | v4.7
179 | Bethini Pie update notes:
180 | -Upgraded sliders. They will look better, function more accurately, and no longer cause issues when manually editing the values.
181 | -Add 'fixedDefault' values if they are missing entirely from the user's ini files (if the user feeds Bethini a blank ini).
182 | -Prevent blank Ultra.ini file being created in some cases.
183 |
184 | Starfield plugin update notes:
185 | -Added Reflection settings
186 | -Added Console Hotkeys toggle.
187 | -Added Console INI entry.
188 | -Added Crowd Density dropdown.
189 | -Updated presets
190 |
191 | v4.6
192 | Starfield plugin update notes:
193 | -Moved Motion Blur to Basic
194 | -Moved Film Grain to Basic
195 | -Moved Depth of Field to Basic
196 | -Added Starting Console Command entry
197 | -Added Variable Rate Shading toggle
198 | -Added VRS Variance Cutoff dropdown
199 | -Added multiple Terrain settings
200 | -Added multiple Ambient Occlusion settings
201 | -Added multiple Indirect Lighting settings
202 | -Added multiple Particle Lighting settings
203 | -Updated Presets
204 | -Made Dynamic Shadow Map Count slider have a minimum of 12 due to report of lower values causing CTD.
205 |
206 | v4.5
207 | Skyrim Special Edition plugin update notes:
208 | -Bugfix: Neverfade Distance tooltip description was wrong.
209 | -Increase Bloom Boost slider min/max to -10/10.
210 |
211 | Starfield plugin update notes:
212 | -Bugfix: Removed fMinDynamicResolutionScale errantly being set when adjusting Render Resolution Scale.
213 | -bSaveGameOnQuitToMainMenu:General actually entirely disables exit saves for Starfield, so it has been renamed from "Save on Quit to Main Menu" to Exit Saves and the description updated.
214 | -Changed Over-Encumbered Reminder to a combobox.
215 | -Changed "Disable Combat Dialogue" to "Combat Dialogue"
216 | -Removed Sprint Fix. Doesn't appear to do anything.
217 | -Added Mipmap Bias dropdown.
218 | -Added Autosaves toggle.
219 | -Added Save on Pause toggle.
220 | -Added Missing Content Warning toggle.
221 | -Added Scripted Autosaves toggle.
222 | -Added Scripted Force Saves toggle.
223 | -Added Controller Vibration toggle.
224 | -Added Crosshair toggle.
225 | -Added HUD Opacity slider.
226 | -Added Disable Grass toggle.
227 | -Added Terrain Tint toggle.
228 | -Added Grass Fade Start slider.
229 | -Added Grass Fade Range slider.
230 | -Added Random Cull Factor slider.
231 | -Added Random Cull Start Distance slider.
232 | -Added Culling Footprint slider.
233 | -Added Volumetric Lighting dropdown.
234 | -Added Phase Function dropdown.
235 | -Added Half Resolution Fog Map Blur toggle.
236 | -Added Volumetric Indirect Fallback toggle.
237 | -Added Level 1 Block Distance slider.
238 | -Added Level 2 Block Distance slider.
239 | -Added Level 4 Block Distance slider.
240 | -Added Far Distance slider.
241 |
242 | v4.4
243 | Skyrim Special Edition plugin initial release.
244 |
245 | Fallout 4 plugin update notes:
246 | -Added Field of View settings.
247 | -Fixed nonfunctional Pipboy Flashlight Color Fix
248 |
249 | Starfield plugin update notes:
250 | -Added Gamma slider.
251 | -Presets were updated.
252 |
253 | v4.3
254 | Fallout 4 plugin update notes:
255 | -Added Flickering Light Distance.
256 |
257 | Starfield plugin update notes:
258 | -Bugfix: English Voices toggle was non-functional.
259 | -Bugfix: Dynamic Resolution Scale slider only adjusted the minimum.
260 | -Bugfix: Add Xbox uPersistentUuidData settings.
261 | -Presets were updated.
262 |
263 | v4.2
264 | Bethini Pie update notes:
265 | -Bugfix: Added exception handling for missing files.
266 | -Bugfix: Restoring backups to multiple ini files locations now works.
267 | -Bugfix: Now fixes the errors in corrupt INI files from many Starfield Nexus "mods."
268 | -Themes: All themes have a No Tab Images version.
269 | -Themes: All themes now have a Shadows tab image available.
270 |
271 | Starfield plugin update notes:
272 | -Bugfix: Boost Shaking description was wrong.
273 | -Bugfix: Duplicate "Dynamic Resolution" setting causing the toggle to not work.
274 | -Bugfix: Couldn't type in custom FOV.
275 | -Updated color settings to proper rgb format.
276 | -Added Ultra.ini to the ini list.
277 | -Selection of game path changed to the directory folders themselves to avoid issues for Gamepass users.
278 | -Added preset ini file settings.
279 | -Added Dynamic Resolution Scale slider.
280 | -Added Message of the Day toggle.
281 | -Added Language dropdown.
282 | -Added English Voices toggle.
283 | -Added Remove Borders toggle.
284 | -Added Sprint Fix toggle.
285 | -Added Save on Quit to Main Menu toggle.
286 | -Added Disable Combat Dialogue toggle.
287 | -Added NPCs Use Ammo toggle.
288 | -Add Tutorials toggle.
289 | -Added Over-Encumbered Reminder timer edit box.
290 | -Added Papyrus settings.
291 | -Rearranged Interface to look better.
292 | -Removed all the quality settings from the Visuals tabs. These are now internalized within the preset system, and the individual settings exposed.
293 | -Added Volumetric Lighting toggle.
294 | -Added revised Motion Blur dropdown.
295 | -Added 9 Decal settings.
296 | -Added 26 individual shadow settings.
297 |
298 |
299 |
300 | v4.1
301 | Bethini Pie update notes:
302 | -Bugfix: Duplicate settings in different sections could cause some settings to not be applied during preset creation.
303 | -Bugfix: Create necessary files/directories if they are missing.
304 | -Enhancement: New setting type for color settings with alpha.
305 |
306 | Starfield plugin update notes:
307 | -Fixed positioning of some elements where words were cut off.
308 | -Added Anisotropic Filtering dropdown under Basic.
309 | -Added Boost Shaking toggle under General.
310 | -Added Selection Color under Interface.
311 | -Added Decals quality dropdown under Visuals.
312 | -Added Geometry quality dropdown under Visuals.
313 | -Added Terrain quality dropdown under Visuals.
314 | -Added Transparency quality dropdown under Visuals.
315 | -Added View Distance quality dropdown under Visuals.
316 | -Added Atmospheric Scattering quality dropdown under Visuals.
317 | -Added Dynamic Resolution quality dropdown under Visuals.
318 | -Added Post Effects quality dropdown under Visuals.
319 | -Changed Variable Rate Shading toggle into Variable Rate Shading quality dropdown under Visuals.
--------------------------------------------------------------------------------
/fonts/Comfortaa/Comfortaa-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/fonts/Comfortaa/Comfortaa-Bold.ttf
--------------------------------------------------------------------------------
/fonts/Comfortaa/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright 2011 The Comfortaa Project Authors (https://github.com/alexeiva/comfortaa), with Reserved Font Name "Comfortaa".
2 |
3 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 | This license is copied below, and is also available with a FAQ at:
5 | https://openfontlicense.org
6 |
7 |
8 | -----------------------------------------------------------
9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 | -----------------------------------------------------------
11 |
12 | PREAMBLE
13 | The goals of the Open Font License (OFL) are to stimulate worldwide
14 | development of collaborative font projects, to support the font creation
15 | efforts of academic and linguistic communities, and to provide a free and
16 | open framework in which fonts may be shared and improved in partnership
17 | with others.
18 |
19 | The OFL allows the licensed fonts to be used, studied, modified and
20 | redistributed freely as long as they are not sold by themselves. The
21 | fonts, including any derivative works, can be bundled, embedded,
22 | redistributed and/or sold with any software provided that any reserved
23 | names are not used by derivative works. The fonts and derivatives,
24 | however, cannot be released under any other type of license. The
25 | requirement for fonts to remain under this license does not apply
26 | to any document created using the fonts or their derivatives.
27 |
28 | DEFINITIONS
29 | "Font Software" refers to the set of files released by the Copyright
30 | Holder(s) under this license and clearly marked as such. This may
31 | include source files, build scripts and documentation.
32 |
33 | "Reserved Font Name" refers to any names specified as such after the
34 | copyright statement(s).
35 |
36 | "Original Version" refers to the collection of Font Software components as
37 | distributed by the Copyright Holder(s).
38 |
39 | "Modified Version" refers to any derivative made by adding to, deleting,
40 | or substituting -- in part or in whole -- any of the components of the
41 | Original Version, by changing formats or by porting the Font Software to a
42 | new environment.
43 |
44 | "Author" refers to any designer, engineer, programmer, technical
45 | writer or other person who contributed to the Font Software.
46 |
47 | PERMISSION & CONDITIONS
48 | Permission is hereby granted, free of charge, to any person obtaining
49 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 | redistribute, and sell modified and unmodified copies of the Font
51 | Software, subject to the following conditions:
52 |
53 | 1) Neither the Font Software nor any of its individual components,
54 | in Original or Modified Versions, may be sold by itself.
55 |
56 | 2) Original or Modified Versions of the Font Software may be bundled,
57 | redistributed and/or sold with any software, provided that each copy
58 | contains the above copyright notice and this license. These can be
59 | included either as stand-alone text files, human-readable headers or
60 | in the appropriate machine-readable metadata fields within text or
61 | binary files as long as those fields can be easily viewed by the user.
62 |
63 | 3) No Modified Version of the Font Software may use the Reserved Font
64 | Name(s) unless explicit written permission is granted by the corresponding
65 | Copyright Holder. This restriction only applies to the primary font name as
66 | presented to the users.
67 |
68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 | Software shall not be used to promote, endorse or advertise any
70 | Modified Version, except to acknowledge the contribution(s) of the
71 | Copyright Holder(s) and the Author(s) or with their explicit written
72 | permission.
73 |
74 | 5) The Font Software, modified or unmodified, in part or in whole,
75 | must be distributed entirely under this license, and must not be
76 | distributed under any other license. The requirement for fonts to
77 | remain under this license does not apply to any document created
78 | using the Font Software.
79 |
80 | TERMINATION
81 | This license becomes null and void if any of the above conditions are
82 | not met.
83 |
84 | DISCLAIMER
85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 | OTHER DEALINGS IN THE FONT SOFTWARE.
94 |
--------------------------------------------------------------------------------
/icons/Advanced.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Advanced.png
--------------------------------------------------------------------------------
/icons/Basic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Basic.png
--------------------------------------------------------------------------------
/icons/Blank.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Blank.png
--------------------------------------------------------------------------------
/icons/Environment.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Environment.png
--------------------------------------------------------------------------------
/icons/Gameplay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Gameplay.png
--------------------------------------------------------------------------------
/icons/General.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/General.png
--------------------------------------------------------------------------------
/icons/Icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Icon.png
--------------------------------------------------------------------------------
/icons/Interface.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Interface.png
--------------------------------------------------------------------------------
/icons/Log.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Log.png
--------------------------------------------------------------------------------
/icons/Shadows.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Shadows.png
--------------------------------------------------------------------------------
/icons/View Distance.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/View Distance.png
--------------------------------------------------------------------------------
/icons/Visuals.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DoubleYouC/Bethini-Pie-Performance-INI-Editor/7a5891bbd71aa3b14aa1fa2d8eef7a361209d537/icons/Visuals.png
--------------------------------------------------------------------------------
/lib/AutoScrollbar.py:
--------------------------------------------------------------------------------
1 | """This is the AutoScrollbar module."""
2 |
3 | import sys
4 | import ttkbootstrap as ttk
5 | from ttkbootstrap.constants import *
6 |
7 | if __name__ == "__main__":
8 | sys.exit(1)
9 |
10 |
11 | class AutoScrollbar(ttk.Scrollbar):
12 | """This creates a scrollbar if necessary."""
13 |
14 | def set(self, first: float | str, last: float | str) -> None:
15 | if float(first) <= 0.0 and float(last) >= 1.0:
16 | self.pack_forget()
17 | elif self.cget("orient") == HORIZONTAL:
18 | self.pack(side=BOTTOM, fill=X)
19 | else:
20 | self.pack(side=RIGHT, fill=Y)
21 | ttk.Scrollbar.set(self, first, last)
22 |
--------------------------------------------------------------------------------
/lib/ModifyINI.py:
--------------------------------------------------------------------------------
1 | #
2 | # This work is licensed under the
3 | # Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
4 | # To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/
5 | # or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
6 | #
7 |
8 | import configparser
9 | import logging
10 | import sys
11 | from pathlib import Path
12 | from typing import ClassVar
13 |
14 | if __name__ == "__main__":
15 | sys.exit(1)
16 |
17 | from lib.customConfigParser import customConfigParser
18 | from lib.type_helpers import *
19 |
20 | logger = logging.getLogger(__name__)
21 |
22 |
23 | class ModifyINI:
24 | """This class gives us an easy way to modify the various INI files in a more
25 | readable way than calling the configparser every time.
26 | It also modifies the configparser to work in the way that we desire.
27 | This by nature allows us to make the changes in how we use the confiparser
28 | apply to every instance of modifying the INI files.
29 | """
30 |
31 | app_config_name: ClassVar[ININame] = "Bethini.ini"
32 | open_inis: ClassVar[dict[ININame, dict[Path, "ModifyINI"]]] = {}
33 | _open_app_config: ClassVar["ModifyINI | None"] = None
34 |
35 | @staticmethod
36 | def app_config() -> "ModifyINI":
37 | """Access Bethini's config INI."""
38 |
39 | if not ModifyINI._open_app_config:
40 | ModifyINI._open_app_config = ModifyINI.open(
41 | name=ModifyINI.app_config_name, location=Path.cwd(), sortable=True)
42 | return ModifyINI._open_app_config
43 |
44 | @staticmethod
45 | def open(name: ININame, location: Path, sortable: bool, *, preserve_case: bool = True) -> "ModifyINI":
46 | """Open an INI file.
47 |
48 | If the file is already open, the existing ModifyINI instance will be returned.
49 | """
50 |
51 | existing_object = ModifyINI.open_inis.setdefault(name, {}).get(location)
52 | if existing_object:
53 | if preserve_case != existing_object.preserve_case:
54 | msg = f"{location.name} opened twice with different settings."
55 | raise NotImplementedError(msg)
56 | return existing_object
57 |
58 | new_object = ModifyINI(name, location, sortable, preserve_case=preserve_case)
59 | ModifyINI.open_inis.setdefault(name, {})[location] = new_object
60 | return new_object
61 |
62 | def __init__(self, name: ININame, location: Path, sortable: bool, *, preserve_case: bool = True) -> None:
63 | self.ini_path = Path(location, name)
64 | self.preserve_case = preserve_case
65 | self.sortable = sortable
66 |
67 | self.config = customConfigParser()
68 | if preserve_case:
69 | self.config.optionxform = lambda optionstr: optionstr
70 | logger.info(f"Successfully read {self.config.read(self.ini_path, encoding='utf-8')}")
71 |
72 | self.case_insensitive_config = customConfigParser()
73 | logger.info(f"Successfully read {self.case_insensitive_config.read(self.ini_path, encoding='utf-8')} (case insensitive)")
74 |
75 | self.original_config = customConfigParser()
76 | logger.info(f"Successfully read {self.original_config.read(self.ini_path, encoding='utf-8')} (read-only)")
77 |
78 | self.has_been_modified = False
79 | self.modifications: dict[str, dict[str, str]] = {}
80 |
81 | def get_existing_section(self, section: str) -> str:
82 | """Searches for and returns an existing case version of the given section."""
83 |
84 | if self.config.has_section(section):
85 | return section
86 |
87 | lowercase_section = section.lower()
88 |
89 | for existing_section in self.get_sections():
90 | lowercase_existing_section = existing_section.lower()
91 | if lowercase_existing_section == lowercase_section:
92 | section = existing_section
93 | break
94 | return section
95 |
96 | def get_original_value(self, section: str, setting: str) -> str | None:
97 | """Retrieves the original value of a given setting, if it exists."""
98 | section = self.get_existing_section(section)
99 | # Even though we are checking the case_insensitive_config, sections ARE case sensitive.
100 | if self.original_config.has_section(section):
101 | return self.original_config.get(section, setting, fallback=None)
102 | return None
103 |
104 | def get_existing_setting(self, section: str, setting: str) -> str:
105 | """Searches for and returns an existing case version of the given setting."""
106 |
107 | section = self.get_existing_section(section)
108 |
109 | lowercase_setting = setting.lower()
110 |
111 | for existing_setting in self.get_settings(section, original_case=True):
112 | lowercase_existing_setting = existing_setting.lower()
113 | if lowercase_existing_setting == lowercase_setting:
114 | setting = existing_setting
115 | break
116 | return setting
117 |
118 | def get_value(self, section: str, setting: str, default: str | None = None) -> str | None:
119 | """Retrieves the value of a given setting, if it exists."""
120 |
121 | section = self.get_existing_section(section)
122 | # Even though we are checking the case_insensitive_config, sections ARE case sensitive.
123 | if self.case_insensitive_config.has_section(section):
124 | return self.case_insensitive_config.get(section, setting, fallback=default)
125 | return default
126 |
127 | def get_sections(self) -> list[str]:
128 | """Retrieves all sections."""
129 |
130 | return self.case_insensitive_config.sections()
131 |
132 | def get_settings(self, section: str, *, original_case: bool = False) -> list[str]:
133 | """Retrieves all settings within the given section."""
134 |
135 | section = self.get_existing_section(section)
136 | try:
137 | settings = self.config.options(section) if original_case else self.case_insensitive_config.options(section)
138 | except configparser.NoSectionError:
139 | settings = []
140 | return settings
141 |
142 | def assign_setting_value(self, section: str, setting: str, value: str) -> bool:
143 | """Assigns the specified value to the specified setting only if
144 | different. Returns true if the value was changed.
145 | """
146 |
147 | # Preserves existing case for section
148 | section = self.get_existing_section(section)
149 |
150 | # If section not in self.config, make the section.
151 | if not self.config.has_section(section):
152 | self.config.add_section(section)
153 | self.case_insensitive_config.add_section(section)
154 |
155 | # Preserves existing case for setting
156 | setting = self.get_existing_setting(section, setting)
157 |
158 | current_value = self.get_value(section, setting)
159 | if current_value != value:
160 | self.config[section][setting] = value
161 | self.case_insensitive_config[section][setting] = value
162 | original_value = self.get_original_value(section, setting)
163 | if original_value != value:
164 | self.has_been_modified = True
165 | if section not in self.modifications:
166 | self.modifications[section] = {}
167 | self.modifications[section][setting] = f"Changed from {original_value} to {value}"
168 | else:
169 | if self.modifications.get(section):
170 | self.modifications[section].pop(setting, None)
171 | if not self.modifications.get(section):
172 | self.modifications.pop(section, None)
173 | if self.modifications == {}:
174 | self.has_been_modified = False
175 | return True
176 | return False
177 |
178 | def remove_setting(self, section: str, setting: str) -> bool:
179 | """Remove the specified setting.
180 |
181 | Returns True if the section exists, False otherwise.
182 | """
183 |
184 | existing_section = self.get_existing_section(section)
185 | existing_setting = self.get_existing_setting(existing_section, setting)
186 | try:
187 | self.config.remove_option(existing_section, existing_setting)
188 | self.case_insensitive_config.remove_option(existing_section, existing_setting)
189 | if self.original_config.has_option(existing_section, existing_setting):
190 | self.has_been_modified = True
191 | if existing_section not in self.modifications:
192 | self.modifications[existing_section] = {}
193 | self.modifications[existing_section][existing_setting] = f"Removed setting"
194 | else:
195 | if self.modifications.get(existing_section):
196 | existing_setting_value = self.modifications[existing_section].get(setting)
197 | if existing_setting_value and "Changed from None to " in existing_setting_value:
198 | self.modifications[existing_section].pop(
199 | existing_setting, None)
200 | if not self.modifications.get(existing_section):
201 | self.modifications.pop(existing_section, None)
202 | if self.modifications == {}:
203 | self.has_been_modified = False
204 | except configparser.NoSectionError:
205 | return False
206 | return True
207 |
208 | def remove_section(self, section: str) -> None:
209 | """Removes the specified section."""
210 |
211 | existing_section = self.get_existing_section(section)
212 | self.config.remove_section(existing_section)
213 | self.case_insensitive_config.remove_section(existing_section)
214 | if self.original_config.has_section(existing_section):
215 | self.has_been_modified = True
216 | if existing_section not in self.modifications:
217 | self.modifications[existing_section] = {}
218 | self.modifications[existing_section][existing_section] = f"Removed section"
219 |
220 | def sort(self) -> None:
221 | """Sorts all sections and settings."""
222 |
223 | for section in self.config._sections: # noqa: SLF001
224 | self.config._sections[section] = dict(sorted(self.config._sections[section].items())) # noqa: SLF001
225 | self.config._sections = dict(sorted(self.config._sections.items())) # noqa: SLF001
226 | self.has_been_modified = True
227 | logger.debug(f"Sorted {self.ini_path.name}")
228 |
229 | def save_ini_file(self, *, sort: bool = False) -> None:
230 | """Writes the file."""
231 |
232 | if sort:
233 | self.sort()
234 | with self.ini_path.open("w", encoding="utf-8") as config_file:
235 | self.config.write(config_file, space_around_delimiters=False)
236 | self.has_been_modified = False
237 |
--------------------------------------------------------------------------------
/lib/advanced_edit_menu.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import logging
3 | import tkinter as tk
4 | import ttkbootstrap as ttk
5 | from ttkbootstrap.constants import *
6 |
7 | if __name__ == "__main__":
8 | sys.exit(1)
9 |
10 | from lib.customFunctions import set_titlebar_style
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 | class AdvancedEditMenuPopup(ttk.Toplevel):
15 | def __init__(self, master, row_data: tuple, **kwargs):
16 | super().__init__(master, **kwargs)
17 | self.title("Advanced Edit Menu")
18 | set_titlebar_style(self)
19 | self.grab_set()
20 | self.focus_set()
21 | self.result = None # Will store the result from current_value_entry
22 | self.row_data = row_data
23 |
24 | # Set a minimum window size (width=500, height=300)
25 | self.minsize(500, 300)
26 |
27 | # Position the Toplevel window near the master window
28 | x = master.winfo_x()
29 | y = master.winfo_y()
30 | self.geometry(f"+{x + 100}+{y + 100}")
31 |
32 | main_frame = ttk.Frame(self)
33 | main_frame.pack(fill=BOTH, expand=YES, padx=5, pady=5)
34 |
35 | info_frame = ttk.Frame(main_frame)
36 | info_frame.pack(side=LEFT, fill=BOTH, expand=YES, padx=5, pady=5)
37 |
38 |
39 | ini_file_frame = ttk.Frame(info_frame)
40 | ini_file_frame.pack(fill=BOTH, expand=YES, padx=5, pady=5)
41 | ini_file_label = ttk.Label(ini_file_frame, text="INI File:")
42 | ini_file_label.pack(fill=tk.X, expand=NO, anchor=W, pady=3)
43 | ini_file_entry = ttk.Entry(ini_file_frame)
44 | ini_file_entry.insert(0, row_data[0])
45 | ini_file_entry.pack(fill=tk.X, expand=YES, anchor=W)
46 | ini_file_entry.bind(
47 | "", lambda e: self.on_focus_out(e, row_data[0]))
48 | ini_file_entry.configure(style="secondary.TEntry")
49 |
50 | section_frame = ttk.Frame(info_frame)
51 | section_frame.pack(fill=BOTH, expand=YES, padx=5, pady=5)
52 | section_label = ttk.Label(section_frame, text="Section:")
53 | section_label.pack(fill=tk.X, expand=NO, anchor=W, pady=3)
54 | section_entry = ttk.Entry(section_frame)
55 | section_entry.insert(0, row_data[1])
56 | section_entry.pack(fill=tk.X, expand=YES, anchor=W)
57 | section_entry.bind(
58 | "", lambda e: self.on_focus_out(e, row_data[1]))
59 | section_entry.configure(style="secondary.TEntry")
60 |
61 | setting_frame = ttk.Frame(info_frame)
62 | setting_frame.pack(fill=BOTH, expand=YES, padx=5, pady=5)
63 | setting_label = ttk.Label(setting_frame, text="Setting:")
64 | setting_label.pack(fill=tk.X, expand=NO, anchor=W, pady=3)
65 | setting_entry = ttk.Entry(setting_frame)
66 | setting_entry.insert(0, row_data[2])
67 | setting_entry.pack(fill=tk.X, expand=YES, anchor=W)
68 | setting_entry.bind(
69 | "", lambda e: self.on_focus_out(e, row_data[2]))
70 | setting_entry.configure(style="secondary.TEntry")
71 |
72 | default_value_frame = ttk.Frame(info_frame)
73 | default_value_frame.pack(fill=BOTH, expand=YES, padx=5, pady=5)
74 | default_value_label = ttk.Label(
75 | default_value_frame, text="Default Value:")
76 | default_value_label.pack(fill=tk.X, expand=NO, anchor=W, pady=3)
77 | default_value_entry = ttk.Entry(default_value_frame)
78 | default_value_entry.insert(0, row_data[3])
79 | default_value_entry.pack(fill=tk.X, expand=YES, anchor=W)
80 | default_value_entry.bind(
81 | "", lambda e: self.on_focus_out(e, row_data[3]))
82 | default_value_entry.configure(style="secondary.TEntry")
83 |
84 | notes_frame = ttk.Frame(main_frame)
85 | notes_frame.pack(fill=BOTH, expand=YES, padx=5, pady=5)
86 | notes_label = ttk.Label(notes_frame, text="Notes:")
87 | notes_label.pack(fill=tk.X, expand=NO, anchor=W, pady=3)
88 | self.notes_text = ttk.Text(notes_frame, height=14, wrap=WORD)
89 | self.notes_data = ""
90 | self.main_ini = master.app.get_main_ini_from_pecking_order(row_data[0])
91 | if master.app.does_setting_exist(ini=self.main_ini, section=row_data[1], setting=row_data[2]):
92 | self.notes_data = master.app.get_setting_notes(setting=row_data[2], section=row_data[1])
93 | self.notes_text.insert("1.0", self.notes_data)
94 | self.notes_text.pack(fill=tk.X, expand=YES, anchor=W)
95 |
96 | ttk.Separator(self).pack(fill=tk.X, expand=YES, pady=5)
97 |
98 | current_value_frame = ttk.Frame(self)
99 | current_value_frame.pack(fill=BOTH, expand=YES, padx=5, pady=5)
100 | current_value_label = ttk.Label(
101 | current_value_frame, text="Current Value:")
102 | current_value_label.pack(fill=tk.X, expand=NO, anchor=W, pady=3)
103 | self.current_value_entry = ttk.Entry(current_value_frame)
104 | self.current_value_entry.insert(0, row_data[4])
105 | self.current_value_entry.pack(fill=tk.X, expand=YES, anchor=W)
106 | self.current_value_entry.configure(style="primary.TEntry")
107 |
108 | self.save_button = ttk.Button(
109 | self, text="Save", style="success.TButton", command=self.on_save)
110 | self.save_button.pack(side=RIGHT, padx=5, pady=5)
111 | self.cancel_button = ttk.Button(
112 | self, text="Cancel", style="danger.TButton", command=self.on_cancel)
113 | self.cancel_button.pack(side=RIGHT, padx=5, pady=5)
114 |
115 | def on_save(self):
116 | # Retrieve the current value from the entry widget
117 | current_val = self.current_value_entry.get()
118 | if current_val != self.row_data[4]:
119 | logger.debug("Saved new value: " + str(self.row_data[0:3]) + " " + str(current_val))
120 | # Store the result so parent code can access it after wait_window
121 | self.result = current_val
122 |
123 | # Retrieve notes text
124 | notes = self.notes_text.get("1.0", tk.END).strip()
125 | if notes != self.notes_data:
126 | logger.info(f"New notes for {self.row_data[2]}:{self.row_data[1]}: {notes}")
127 | # Save the notes to the setting
128 | if self.master.app.update_setting_notes(
129 | setting=self.row_data[2],
130 | section=self.row_data[1],
131 | notes=notes
132 | ):
133 | self.master.app.save_data()
134 | logger.info("Notes saved successfully.")
135 |
136 | self.destroy()
137 |
138 | def on_cancel(self):
139 | logger.debug("Cancel")
140 | self.destroy()
141 |
142 | def on_focus_out(self, event, default_value):
143 | widget = event.widget
144 | widget.delete(0, tk.END)
145 | widget.insert(0, default_value)
--------------------------------------------------------------------------------
/lib/alphaColorPicker.py:
--------------------------------------------------------------------------------
1 | """This is the alpha_color_picker module."""
2 |
3 | import sys
4 | import ttkbootstrap as ttk
5 | from collections import namedtuple
6 | from ttkbootstrap.constants import *
7 | from ttkbootstrap.dialogs import Querybox
8 | from ttkbootstrap.dialogs.colorchooser import (
9 | ColorChooser,
10 | ColorChooserDialog,
11 | )
12 |
13 | if __name__ == "__main__":
14 | sys.exit(1)
15 |
16 | from lib.scalar import Scalar
17 |
18 | ColorChoice = namedtuple("ColorChoice", "rgb hsl hex alpha")
19 |
20 |
21 | class AlphaColorChooserDialog(ColorChooserDialog):
22 | """This class creates a color chooser dialog with an alpha slider."""
23 |
24 | def __init__(
25 | self, parent=None, title="Color Chooser", initialcolor=None, initialalpha=None
26 | ):
27 | super().__init__(parent, title, initialcolor)
28 | self.alpha = initialalpha
29 |
30 | def create_body(self, master):
31 | self.colorchooser = ColorChooser(master, self.initialcolor)
32 | self.colorchooser.pack(fill=BOTH, expand=YES)
33 |
34 | self.alpha_frame = ttk.Frame(master)
35 | self.alpha_label = ttk.Label(self.alpha_frame, text="Alpha:")
36 | self.alpha_var = ttk.IntVar(self)
37 | self.alpha_slider = Scalar(
38 | self.alpha_frame, from_=0, to=255, orient=HORIZONTAL, variable=self.alpha_var
39 | )
40 | self.alpha_spinbox = ttk.Spinbox(
41 | self.alpha_frame, from_=0, to=255, textvariable=self.alpha_var)
42 | self.alpha_var.set(self.alpha if self.alpha is not None else 255)
43 | if self.alpha is not None:
44 | self.alpha_frame.pack(fill=BOTH, expand=YES, padx=9)
45 | self.alpha_label.pack(fill=BOTH, expand=NO, side=LEFT)
46 | self.alpha_slider.pack(fill=BOTH, expand=YES, side=LEFT, padx=5)
47 | self.alpha_spinbox.pack(fill=BOTH, expand=NO, side=LEFT)
48 |
49 | def on_button_press(self, button):
50 | if button.cget("text") == "OK":
51 | values = self.colorchooser.get_variables()
52 | self.alpha = self.alpha_var.get()
53 | self._result = ColorChoice(
54 | rgb=(values.r, values.g, values.b),
55 | hsl=(values.h, values.s, values.l),
56 | hex=values.hex,
57 | alpha=self.alpha,
58 | )
59 | self._toplevel.destroy()
60 | self._toplevel.destroy()
61 |
62 |
63 | class AlphaColorPicker(Querybox):
64 | """This class creates a query box with an alpha color picker."""
65 |
66 | def get_color(
67 | parent=None,
68 | title="Color Chooser",
69 | initialcolor=None,
70 | initialalpha=None,
71 | **kwargs
72 | ):
73 | """Show a color picker and return the select color when the
74 | user pressed OK.
75 |
76 | 
77 |
78 | Parameters:
79 |
80 | parent (Widget):
81 | The parent widget.
82 |
83 | title (str):
84 | Optional text that appears on the titlebar.
85 |
86 | initialcolor (str):
87 | The initial color to display in the 'Current' color
88 | frame.
89 |
90 | Returns:
91 |
92 | Tuple[rgb, hsl, hex, alpha]:
93 | The selected color in various colors models.
94 | """
95 |
96 | dialog = AlphaColorChooserDialog(
97 | parent, title, initialcolor, initialalpha)
98 | if "position" in kwargs:
99 | position = kwargs.pop("position")
100 | else:
101 | position = None
102 | dialog.show(position)
103 | return dialog.result
--------------------------------------------------------------------------------
/lib/app.py:
--------------------------------------------------------------------------------
1 | #
2 | # This work is licensed under the
3 | # Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
4 | # To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/
5 | # or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
6 | #
7 |
8 | import logging
9 | import json
10 | import sys
11 | import tkinter as tk
12 | from pathlib import Path
13 | from typing import cast
14 |
15 | if __name__ == "__main__":
16 | sys.exit(1)
17 |
18 | from lib.ModifyINI import ModifyINI
19 | from lib.type_helpers import *
20 |
21 | logger = logging.getLogger(__name__)
22 |
23 |
24 | class AppName:
25 | """This class handles the different apps/games supported, which are placed in the apps folder."""
26 |
27 | def __init__(self, appname: str, exedir: Path) -> None:
28 | with (exedir / "apps" / appname / "settings.json").open(encoding="utf-8") as app_json:
29 | self.data: AppSettingsJSON = json.load(app_json)
30 | with (exedir / "apps" / appname / "Bethini.json").open(encoding="utf-8") as bethini:
31 | self.bethini: AppBethiniJSON = json.load(bethini)
32 |
33 | self.appname = appname
34 | self.exedir = exedir
35 | self.default_ini: ININame = list(self.bethini["INIs"])[1]
36 | self.setting_values = self.get_setting_values()
37 | self.ini_section_setting_dict = self.get_ini_section_setting_dict()
38 | self.setting_type_dict = self.get_setting_type_dict()
39 | self.setting_notes_dict = self.get_setting_notes_dict()
40 | self.can_remove_dict = self.can_remove()
41 | self.preset_values_default = self.preset_values("default")
42 | self.preset_values_fixedDefault = self.preset_values("fixedDefault")
43 | self.preset_values_recommended = self.preset_values("recommended")
44 | self.valid_inis = cast("list[str]", self.bethini["INI_pecking_order"].keys())
45 |
46 | def what_ini_files_are_used(self) -> list[ININame]:
47 | """Returns a list of INI files used, with Bethini.ini removed from the list."""
48 |
49 | return [ini for ini in self.bethini["INIs"] if ini != "Bethini.ini"]
50 |
51 | def get_winning_ini_for_setting(self, ini: str, section:str, setting: str) -> str:
52 | """An application sometimes has the ability to read multiple ini files in a particular
53 | order of priority in which a setting can be overridden. We call this the INI_pecking_order.
54 | Defining the ini for the setting in settings.json, we place a dictionary in Bethini.json,
55 | from which we define the INI_pecking_order for that setting. This function iterates over
56 | those ini files and returns the current ini that is providing the value for the setting.
57 | """
58 | # If Bethini.ini
59 | if ini == ModifyINI.app_config_name:
60 | return ini
61 | test_inis = self.bethini["INI_pecking_order"].get(ini)
62 | # If not a key in the INI_pecking_order
63 | if not test_inis:
64 | return ini
65 | for test_ini in reversed(test_inis):
66 | # If test_ini is ini, then ini is the winning ini
67 | if test_ini == ini:
68 | return ini
69 | ini_location_setting = self.get_ini_setting_name(test_ini)
70 | if not ini_location_setting:
71 | msg = f"Unknown INI: {test_ini}\nini_location_setting: {ini_location_setting}"
72 | logger.error(msg)
73 | raise NotImplementedError(msg)
74 | ini_location = ModifyINI.app_config().get_value("Directories", ini_location_setting)
75 | # If no location exists, return the input ini
76 | if not ini_location:
77 | return ini
78 | allow_sorting: bool = test_ini in self.bethini.get("Allow Sorted INIs", [])
79 | the_target_ini = ModifyINI.open(
80 | name=test_ini, location=Path(ini_location), sortable=allow_sorting)
81 | if the_target_ini.case_insensitive_config.has_option(section, setting):
82 | return test_ini
83 | return ini
84 |
85 | def get_main_ini_from_pecking_order(self, ini: str) -> str:
86 | """Returns the main ini file from the pecking order for the given ini file."""
87 | # If Bethini.ini
88 | if ini == ModifyINI.app_config_name:
89 | return ini
90 | pecking_orders = self.bethini["INI_pecking_order"]
91 | for main_ini in pecking_orders:
92 | if ini in pecking_orders[main_ini]:
93 | return main_ini
94 | return ini
95 |
96 | def get_ini_setting_name(self, ini: ININame) -> str:
97 | """Returns the INI settings name used in Bethini.ini to store the location
98 | of the given ini file.
99 | """
100 |
101 | return self.bethini["INIs"].get(ini) or ""
102 |
103 | def get_setting_values(self) -> dict[str, dict[str, int | float | str]]:
104 | """Returns a dictionary listing all the different value types for every setting."""
105 |
106 | setting_values: dict[str, dict[str, int | float | str]] = {}
107 | for ini_setting in self.data["iniValues"]:
108 | setting_values[ini_setting["name"]] = {}
109 | for value_type in self.bethini["valueTypes"]:
110 | try:
111 | the_value_for_this_type = ini_setting["value"][value_type]
112 | setting_values[ini_setting["name"]][value_type] = the_value_for_this_type
113 | except KeyError:
114 | continue
115 | return setting_values
116 |
117 | def get_setting_type(self, setting: str, section: str) -> str:
118 | """Returns the setting type for the given setting."""
119 | return self.setting_type_dict.get(f"{setting.lower()}:{section.lower()}", "string")
120 |
121 | def get_setting_type_dict(self) -> dict[str, str]:
122 | """Returns a dictionary listing all the settings and their types as specified in settings.json."""
123 | setting_type_dict: dict[str, str] = {}
124 | for ini_setting in self.data["iniValues"]:
125 | section = ini_setting["section"].lower()
126 | setting = ini_setting["name"].lower()
127 | setting_type_dict.setdefault(
128 | f"{setting}:{section}", ini_setting.get("type", "string"))
129 | return setting_type_dict
130 |
131 | def get_setting_notes(self, setting: str, section: str) -> str:
132 | """Returns the setting notes for the given setting."""
133 | return self.setting_notes_dict.get(f"{setting.lower()}:{section.lower()}", "")
134 |
135 | def get_setting_notes_dict(self) -> dict[str, str]:
136 | """Returns a dictionary listing all the settings and their notes as specified in settings.json."""
137 | setting_notes_dict: dict[str, str] = {}
138 | for ini_setting in self.data["iniValues"]:
139 | section = ini_setting["section"].lower()
140 | setting = ini_setting["name"].lower()
141 | setting_notes_dict.setdefault(
142 | f"{setting}:{section}", ini_setting.get("notes", ""))
143 | return setting_notes_dict
144 |
145 | def update_setting_notes(self, setting: str, section: str, notes: str) -> bool:
146 | """Updates the setting notes for the given setting."""
147 | success = False
148 | self.setting_notes_dict[f"{setting.lower()}:{section.lower()}"] = notes
149 | for ini_setting in self.data["iniValues"]:
150 | if ini_setting["name"].lower() == setting.lower() and ini_setting["section"].lower() == section.lower():
151 | ini_setting["notes"] = notes
152 | success = True
153 | break
154 | return success
155 |
156 | def save_data(self) -> None:
157 | """Saves the settings.json file."""
158 | with open(self.exedir / "apps" / self.appname / "settings.json", "w", encoding="utf-8") as app_json:
159 | json.dump(self.data, app_json, indent=4, ensure_ascii=False)
160 |
161 | def get_ini_section_setting_dict(self) -> dict[ININame, dict[str, list[str]]]:
162 | """Returns a dictionary listing all the INI files with their
163 | sections and settings as specified in settings.json
164 | """
165 |
166 | ini_section_setting_dict: dict[ININame, dict[str, list[str]]] = {}
167 | for ini_setting in self.data["iniValues"]:
168 | ini = ini_setting.get("ini", self.default_ini)
169 | if ini is None:
170 | raise TypeError
171 |
172 | section = ini_setting["section"].lower()
173 | setting = ini_setting["name"].lower()
174 | ini_section_setting_dict.setdefault(ini, {}).setdefault(section, []).append(setting)
175 | return ini_section_setting_dict
176 |
177 | def does_setting_exist(self, ini: ININame, section: str, setting: str) -> bool:
178 | """Checks if the given setting for the given section and ini file exists in settings.json."""
179 | setting_exists_list: list[bool] = []
180 | for valid_ini in self.valid_inis:
181 | if ini in self.bethini["INI_pecking_order"].get(valid_ini):
182 | setting_exists_list.append(
183 | setting.lower() in self.ini_section_setting_dict[valid_ini].get(section.lower(), ()))
184 |
185 | return True in setting_exists_list
186 |
187 | def preset_values(self, preset: PresetName) -> dict[str, GameSetting]:
188 | """Returns a dictionary listing all the settings and values
189 | for a given preset specified in settings.json.
190 | """
191 |
192 | preset_dict: dict[str, GameSetting] = {}
193 | for ini_setting in self.data["iniValues"]:
194 | preset_value = ini_setting["value"].get(preset)
195 | if preset_value is not None:
196 | ini = ini_setting.get("ini", self.default_ini)
197 | if ini is None:
198 | raise TypeError
199 | preset_dict[f"{ini_setting['name']}:{ini_setting['section']}"] = {
200 | "ini": ini,
201 | "section": ini_setting["section"],
202 | "value": str(preset_value),
203 | }
204 | return preset_dict
205 |
206 | def can_remove(self) -> dict[str, GameSetting]:
207 | """Returns a dictionary listing all the settings and default values
208 | NOT containing the alwaysPrint attribute as specified in settings.json.
209 | """
210 |
211 | can_remove: dict[str, GameSetting] = {}
212 | for ini_setting in self.data["iniValues"]:
213 | if not ini_setting.get("alwaysPrint"):
214 | can_remove[f"{ini_setting['name']}:{ini_setting['section']}"] = {
215 | "ini": ini_setting.get("ini", self.default_ini),
216 | "section": ini_setting["section"],
217 | "value": str(ini_setting["value"].get("default", "")),
218 | }
219 | return can_remove
220 |
221 | def pack_settings(self, tab_name: str, label_frame_name: str) -> PackSettings:
222 | """Returns the pack settings for the label frame."""
223 |
224 | default_pack_settings: PackSettings = {
225 | "Side": tk.TOP,
226 | "Anchor": tk.NW,
227 | "Fill": tk.BOTH,
228 | "Expand": 1,
229 | }
230 | return cast("SettingsLabelFrame", self.bethini["displayTabs"][tab_name][label_frame_name]).get("Pack", default_pack_settings)
231 |
232 | def number_of_vertically_stacked_settings(self, tab_name: str, label_frame_name: str) -> IntStr:
233 | """Returns the maximum number of vertically stacked settings desired for the label frame."""
234 |
235 | return cast("SettingsLabelFrame", self.bethini["displayTabs"][tab_name][label_frame_name])["NumberOfVerticallyStackedSettings"]
236 |
--------------------------------------------------------------------------------
/lib/choose_game.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import ttkbootstrap as ttk
3 | import logging
4 | from pathlib import Path
5 | from ttkbootstrap.constants import *
6 | from webbrowser import open_new_tab
7 | from ttkbootstrap.themes.standard import STANDARD_THEMES
8 |
9 | if __name__ == "__main__":
10 | sys.exit(1)
11 |
12 | from lib.customFunctions import set_titlebar_style, set_theme
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | class ChooseGameWindow(ttk.Toplevel):
18 | def __init__(self, master, version: str, exedir: Path, **kwargs):
19 | super().__init__(master, **kwargs)
20 | self.title(f"Bethini Pie {version}")
21 | set_titlebar_style(self)
22 | self.grab_set()
23 | self.focus_set()
24 | self.lift()
25 | self.protocol("WM_DELETE_WINDOW", self.destroy)
26 | self.minsize(300, 35)
27 | self.master = master
28 | self.result = None
29 | x = master.winfo_x()
30 | y = master.winfo_y()
31 | self.geometry(f"+{x + 50}+{y + 50}")
32 |
33 |
34 | choose_game_frame = ttk.Frame(self)
35 |
36 | choose_game_frame_2 = ttk.Frame(choose_game_frame)
37 |
38 | label_Bethini = ttk.Label(
39 | choose_game_frame_2, text="Bethini Pie", font=("Segoe UI", 20))
40 | label_Pie = ttk.Label(
41 | choose_game_frame_2,
42 | text="Performance INI Editor\nby DoubleYou",
43 | font=("Segoe UI", 15),
44 | justify=CENTER,
45 | style=WARNING,
46 | )
47 | label_link = ttk.Label(
48 | choose_game_frame_2,
49 | text="www.nexusmods.com/site/mods/631",
50 | font=("Segoe UI", 10),
51 | cursor="hand2",
52 | style=INFO,
53 | )
54 |
55 | choose_game_label = ttk.Label(
56 | choose_game_frame_2, text="Choose Game", font=("Segoe UI", 15))
57 |
58 | self.choose_game_tree = ttk.Treeview(
59 | choose_game_frame_2, selectmode=BROWSE, show="tree", columns=("Name"))
60 | self.choose_game_tree.column("#0", width=0, stretch=NO)
61 | self.choose_game_tree.column("Name", anchor=W, width=300)
62 |
63 | self.master.style_override.configure(
64 | "choose_game_button.TButton", font=("Segoe UI", 14),
65 | background=STANDARD_THEMES[master.theme_name.get()]["colors"].get("inputbg"),
66 | foreground=STANDARD_THEMES[master.theme_name.get()]["colors"].get("inputfg"))
67 | choose_game_button = ttk.Button(
68 | choose_game_frame_2,
69 | text="Select Game",
70 | style="choose_game_button.TButton",
71 | command=self.on_choose_game,
72 | )
73 |
74 | choose_game_tip = ttk.Label(
75 | choose_game_frame_2,
76 | text="Tip: You can change the game at any time\nby going to File > Choose Game.",
77 | font=("Segoe UI", 12),
78 | justify=CENTER,
79 | style="success",
80 | )
81 | for option in Path(exedir / "apps").iterdir():
82 | if Path(exedir / "apps" / option.name / "settings.json").exists():
83 | self.choose_game_tree.insert(
84 | "", index=END, id=option.name, text=option.name, values=[option.name])
85 |
86 | preferences_frame = ttk.Frame(choose_game_frame_2)
87 |
88 | theme_label = ttk.Label(preferences_frame, text="Theme:")
89 | theme_names = list(ttk.Style().theme_names())
90 | theme_mb = ttk.Menubutton(
91 | preferences_frame, textvariable=master.theme_name)
92 | theme_menu = ttk.Menu(theme_mb)
93 | for theme_name in theme_names:
94 | theme_menu.add_radiobutton(label=theme_name, variable=master.theme_name,
95 | value=theme_name, command=self.set_theme)
96 | theme_mb["menu"] = theme_menu
97 |
98 | choose_game_frame.pack(fill=BOTH, expand=True)
99 | choose_game_frame_2.pack(anchor=CENTER, expand=True)
100 |
101 | label_Bethini.pack(padx=5, pady=5)
102 | label_Pie.pack(padx=5, pady=15)
103 | label_link.pack(padx=25, pady=5)
104 | label_link.bind(
105 | "", lambda _event: open_new_tab("https://www.nexusmods.com/site/mods/631"))
106 |
107 | preferences_frame.pack()
108 | theme_label.pack(side=LEFT)
109 | theme_mb.pack(padx=5, pady=15)
110 | choose_game_label.pack(padx=5, pady=2)
111 | self.choose_game_tree.pack(padx=10)
112 | choose_game_button.pack(pady=15)
113 | choose_game_tip.pack(pady=10)
114 |
115 | def on_choose_game(self) -> None:
116 | self.result = self.choose_game_tree.focus()
117 | logger.debug(f"User selected: {self.result}")
118 | self.destroy()
119 |
120 | def set_theme(self) -> None:
121 | set_theme(self.master.style_override, self.master.theme_name.get())
122 | self.master.style_override.configure(
123 | "choose_game_button.TButton", font=("Segoe UI", 14),
124 | background=STANDARD_THEMES[self.master.theme_name.get()]["colors"].get("inputbg"),
125 | foreground=STANDARD_THEMES[self.master.theme_name.get()]["colors"].get("inputfg"))
--------------------------------------------------------------------------------
/lib/customConfigParser.py:
--------------------------------------------------------------------------------
1 | """Custom configparser."""
2 |
3 | import configparser
4 | import sys
5 | from io import TextIOWrapper
6 | from typing import cast
7 |
8 | if __name__ == "__main__":
9 | sys.exit(1)
10 |
11 |
12 | class customConfigParser(configparser.RawConfigParser):
13 | """Our custom configparser will not remove comments when the file is written.
14 | Also, it does not raise errors if duplicate options are detected.
15 | """
16 |
17 | def __init__(self) -> None:
18 | super().__init__(allow_no_value=True, delimiters=("=",), comment_prefixes=(), strict=False)
19 | # comment_prefixes=() is necessary to preserve comments.
20 |
21 | def _read(self, fp: TextIOWrapper, fpname: str) -> None:
22 | """Parse a sectioned configuration file.
23 |
24 | Each section in a configuration file contains a header, indicated by
25 | a name in square brackets (`[]`), plus key/value options, indicated by
26 | `name` and `value` delimited with a specific substring (`=` or `:` by
27 | default).
28 |
29 | Values can span multiple lines, as long as they are indented deeper
30 | than the first line of the value. Depending on the parser's mode, blank
31 | lines may be treated as parts of multiline values or ignored.
32 |
33 | Configuration files may include comments, prefixed by specific
34 | characters (`#` and `;` by default). Comments may appear on their own
35 | in an otherwise empty line or may be entered in lines holding values or
36 | section names.
37 | """
38 |
39 | # This read function was modified to pick the first option value if there is a
40 | # duplicate option. Any subsequent duplicate option values are discarded.
41 | elements_added: set[str | tuple[str, str]] = set()
42 | cursect: dict[str, list[str | int] | None] | None = None
43 | sectname: str | None = None
44 | optname = None
45 | indent_level = 0
46 | e: configparser.Error | None = None
47 | for lineno, line in enumerate(fp, start=1):
48 | comment_start: int | None = sys.maxsize
49 | # Strip inline comments
50 | inline_prefixes = dict.fromkeys(self._inline_comment_prefixes, -1)
51 | while comment_start == sys.maxsize and inline_prefixes:
52 | next_prefixes = {}
53 | for prefix, index in inline_prefixes.items():
54 | line_index = line.find(prefix, index + 1)
55 | if line_index == -1:
56 | continue
57 | next_prefixes[prefix] = line_index
58 | if line_index == 0 or (line_index > 0 and line[line_index - 1].isspace()):
59 | comment_start = min(comment_start, line_index)
60 | inline_prefixes = next_prefixes
61 | # Strip full line comments
62 | for prefix in self._comment_prefixes:
63 | if line.strip().startswith(prefix):
64 | comment_start = 0
65 | break
66 | if comment_start == sys.maxsize:
67 | comment_start = None
68 | value = line[:comment_start].strip()
69 | if not value:
70 | if self._empty_lines_in_values:
71 | # Add empty line to the value, but only if there was no comment on the line
72 | if comment_start is None and cursect is not None and optname and cursect[optname] is not None:
73 | cast("list[str | int]", cursect[optname]).append("") # newlines added at join
74 | else:
75 | # Empty line marks end of value
76 | indent_level = sys.maxsize
77 | continue
78 | # Continuation line?
79 | first_nonspace = self.NONSPACECRE.search(line)
80 | cur_indent_level = first_nonspace.start() if first_nonspace else 0
81 | if cursect is not None and optname and cur_indent_level > indent_level:
82 | cast("list[str | int]", cursect[optname]).append(value)
83 | # A section header or option header?
84 | else:
85 | indent_level = cur_indent_level
86 | # Is it a section header?
87 | mo = self.SECTCRE.match(value)
88 | if mo:
89 | sectname = cast("str", mo.group("header"))
90 | if sectname in self._sections:
91 | if self._strict and sectname in elements_added:
92 | raise configparser.DuplicateSectionError(sectname, fpname, lineno)
93 | cursect = self._sections[sectname]
94 | elements_added.add(sectname)
95 | elif sectname == self.default_section:
96 | cursect = self._defaults
97 | else:
98 | cursect = self._dict()
99 | self._sections[sectname] = cursect
100 | self._proxies[sectname] = configparser.SectionProxy(self, sectname)
101 | elements_added.add(sectname)
102 | # So sections can't start with a continuation line
103 | optname = None
104 | # No section header in the file?
105 | elif cursect is None:
106 | # Typically you raise a MissingSectionHeaderError when the input file is missing a section hearder
107 | # But given the fact that users could have corrupt one with invalid settings, add a dummy TotallyFakeSectionHeader
108 | # will fix the problem, and our code later in the pipeline removes invalid sections.
109 | cursect = self._dict()
110 | sectname = "TotallyFakeSectionHeader"
111 | self._sections[sectname] = cursect
112 | self._proxies[sectname] = configparser.SectionProxy(self, sectname)
113 | elements_added.add(sectname)
114 | optname = None
115 |
116 | # An option line?
117 | else:
118 | mo = self._optcre.match(value)
119 | if mo:
120 | optname, _vi, optval = mo.group("option", "vi", "value")
121 | if not optname:
122 | e = self._handle_error(e, fpname, lineno, line)
123 | optname = self.optionxform(optname.rstrip())
124 | sectname = cast("str", sectname)
125 | if self._strict and (sectname, optname) in elements_added:
126 | raise configparser.DuplicateOptionError(sectname, optname, fpname, lineno)
127 | elements_added.add((sectname, optname))
128 | # This check is fine because the OPTCRE cannot
129 | # match if it would set optval to None.
130 | if optval is not None:
131 | optval = optval.strip()
132 | # Check if this optname already exists
133 | if optname not in cursect:
134 | cursect[optname] = [optval]
135 | elif optname not in cursect:
136 | # Valueless option handling
137 | cursect[optname] = None
138 | else:
139 | # A non-fatal parsing error occurred. set up the
140 | # exception but keep going. the exception will be
141 | # raised at the end of the file and will contain a
142 | # list of all bogus lines.
143 | e = self._handle_error(e, fpname, lineno, line)
144 |
145 | self._join_multiline_values() # type: ignore[reportAttributeAccessIssue]
146 | # If any parsing errors occurred, raise an exception.
147 | if e:
148 | raise e
149 |
--------------------------------------------------------------------------------
/lib/customFunctions.py:
--------------------------------------------------------------------------------
1 | #
2 | # This work is licensed under the
3 | # Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
4 | # To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/
5 | # or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
6 | #
7 |
8 | import ctypes.wintypes
9 | import logging
10 | import os
11 | import sys
12 | import re
13 | from pathlib import Path
14 | from tkinter import filedialog, simpledialog
15 |
16 | if os.name == "nt":
17 | import winreg
18 | from ctypes import windll, byref, c_int, sizeof
19 |
20 | if __name__ == "__main__":
21 | sys.exit(1)
22 |
23 | from lib.app import AppName
24 | from lib.ModifyINI import ModifyINI
25 | from lib.type_helpers import *
26 |
27 | logger = logging.getLogger(__name__)
28 |
29 |
30 | def set_titlebar_style(window: tk.Misc) -> None:
31 | """
32 | Set the title bar style for a given window to use dark mode and Mica effect on Windows 11.
33 |
34 | Args:
35 | window (tk.Misc): The window to apply the title bar style to.
36 | """
37 | # Check if the windowing system is win32 (Windows) and the build version is 22000 or higher (Windows 11)
38 | winsys = window.style.tk.call("tk", "windowingsystem")
39 | if winsys == "win32" and sys.getwindowsversion().build >= 22000:
40 | window.update() # Ensure the window is updated to get the correct window handle
41 | hwnd = windll.user32.GetParent(
42 | window.winfo_id()) # Get the window handle
43 |
44 | # Constants for setting the dark mode and Mica effect
45 | DWMWA_USE_IMMERSIVE_DARK_MODE = 20
46 | DWMWA_MICA_EFFECT = 1029
47 |
48 | # Enable dark mode for the title bar
49 | dark_mode = c_int(1)
50 | windll.dwmapi.DwmSetWindowAttribute(
51 | hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, byref(dark_mode), sizeof(dark_mode))
52 |
53 | # Enable Mica effect for the title bar
54 | mica_effect = c_int(1)
55 | windll.dwmapi.DwmSetWindowAttribute(
56 | hwnd, DWMWA_MICA_EFFECT, byref(mica_effect), sizeof(mica_effect))
57 |
58 |
59 | def set_theme(style_object: ttk.Style, theme_name: str) -> None:
60 | """Set the application theme."""
61 |
62 | style_object.theme_use(theme_name)
63 | style_object.configure("choose_game_button.TButton", font=("Segoe UI", 14))
64 | ModifyINI.app_config().assign_setting_value("General", "sTheme", theme_name)
65 |
66 |
67 | def sanitize_and_convert_float(value: str) -> str:
68 | """
69 | Sanitize a string to ensure it can be converted to a valid float and handle exponential notation.
70 |
71 | This function takes a string input, removes any invalid characters, and converts exponential notation
72 | to its decimal equivalent. If the input contains invalid characters, the function shortens the string
73 | to the valid part before the invalid characters start. If the conversion to float fails, the function
74 | defaults the value to "0".
75 |
76 | Args:
77 | value (str): The input string to be sanitized and converted.
78 |
79 | Returns:
80 | str: A sanitized string that can be safely converted to a float.
81 | """
82 | # New code to handle invalid characters and exponentials
83 | match = re.match(r"^[\d.eE+-]+", value)
84 | if match:
85 | value = match.group(0)
86 | try:
87 | # Convert exponential notation to decimal
88 | value = str(float(value))
89 | except ValueError:
90 | # If conversion fails, default to 0
91 | value = "0"
92 | else:
93 | value = "0" # Default to 0 if no valid part is found
94 | return value
95 |
96 |
97 | def trim_trailing_zeros(value: float) -> str:
98 | """
99 | Remove trailing zeros from a float and return it as a string.
100 |
101 | Args:
102 | value (float): The float value to be formatted.
103 |
104 | Returns:
105 | str: The formatted string without trailing zeros.
106 | """
107 | # Format as a fixed-point number first
108 | formatted = f"{value:f}"
109 | # If there is a decimal point, strip trailing zeros and the trailing decimal point if needed.
110 | if '.' in formatted:
111 | formatted = formatted.rstrip('0').rstrip('.')
112 | return formatted
113 |
114 |
115 | def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
116 | """Convert an RGB color value to a hex representation."""
117 | return "#{:02x}{:02x}{:02x}".format(*rgb)
118 |
119 |
120 | def rgba_to_hex(rgba: tuple[int, int, int, int]) -> str:
121 | """Convert an RGBA color value to a hex representation."""
122 | return "#{:02x}{:02x}{:02x}{:02x}".format(*rgba)
123 |
124 |
125 | def rgba_to_decimal(rgba: tuple[int, int, int, int]) -> str:
126 | """Convert an RGBA color value to a decimal representation."""
127 | red, green, blue, alpha = rgba
128 | decimal_value = (red << 24) + (green << 16) + (blue << 8) + alpha
129 | return str(decimal_value)
130 |
131 |
132 | def abgr_to_decimal(abgr: tuple[int, int, int, int]) -> str:
133 | """Convert an ABGR color value to a decimal representation."""
134 | alpha, blue, green, red = abgr
135 | decimal_value = (alpha << 24) + (blue << 16) + (green << 8) + red
136 | return str(decimal_value)
137 |
138 |
139 | def hex_to_rgb(value: str) -> tuple[int, int, int] | tuple[int, ...]:
140 | """Convert a hex color value to an RGB color value."""
141 | value = value.lstrip("#")
142 | lv = len(value)
143 | if lv == 1:
144 | v = int(value, 16) * 17
145 | return v, v, v
146 | if lv == 3:
147 | return tuple(int(value[i: i + 1], 16) * 17 for i in range(3))
148 | return tuple(int(value[i: i + lv // 3], 16) for i in range(0, lv, lv // 3))
149 |
150 |
151 | def hex_to_decimal(hex_: str) -> str:
152 | """Convert a hex color value to a decimal representation."""
153 | return str(int(hex_.lstrip("#"), 16))
154 |
155 |
156 | def decimal_to_rgb(decimal_string: str) -> tuple[int, int, int]:
157 | """Convert a decimal representation to an RGB color value."""
158 | decimal = int(decimal_string)
159 | blue = decimal & 255
160 | green = (decimal >> 8) & 255
161 | red = (decimal >> 16) & 255
162 | return (red, green, blue)
163 |
164 |
165 | def decimal_to_rgba(decimal_string: str) -> tuple[int, int, int, int]:
166 | """Convert a decimal representation to an RGBA color value."""
167 | decimal = int(decimal_string)
168 | alpha = decimal & 255
169 | blue = (decimal >> 8) & 255
170 | green = (decimal >> 16) & 255
171 | red = (decimal >> 24) & 255
172 | return (red, green, blue, alpha)
173 |
174 |
175 | def decimal_to_abgr(decimal_string: str) -> tuple[int, int, int, int]:
176 | """Convert a decimal representation to an ABGR color value."""
177 | decimal = int(decimal_string)
178 | red = decimal & 255
179 | green = (decimal >> 8) & 255
180 | blue = (decimal >> 16) & 255
181 | alpha = (decimal >> 24) & 255
182 | return (alpha, blue, green, red)
183 |
184 |
185 | def browse_to_location(choice: str, browse: Browse, function: str, game_name: str) -> str | None:
186 | """
187 | Handle browsing to a location or manual entry for a file or directory.
188 |
189 | Args:
190 | choice (str): The user's choice, either "Browse...", "Manual...", or a predefined option.
191 | browse (Browse): A tuple containing browse options.
192 | function (str): A custom function to call if provided.
193 | game_name (str): The name of the game for context in custom functions.
194 |
195 | Returns:
196 | str | None: The selected or entered location, or None if the operation was canceled.
197 |
198 | Note:
199 | This is NOT meant to replace typical queries for paths, but solely for advanced use of the dropdowns optionmenus.
200 | """
201 | if choice == "Browse...":
202 | # Handle directory selection
203 | if browse[2] == "directory":
204 | response = filedialog.askdirectory()
205 | if not response:
206 | return None
207 |
208 | location = Path(response).resolve()
209 |
210 | else:
211 | # Handle file selection
212 | response = filedialog.askopenfilename(
213 | filetypes=[(browse[1], browse[1])])
214 | if not response:
215 | return None
216 |
217 | location = Path(response).resolve()
218 | try:
219 | with location.open() as _fp:
220 | pass
221 |
222 | except OSError as e:
223 | logger.exception(f"Failed to open file: {e}")
224 | return None
225 | # If a directory is expected but a file is selected, use the file's parent directory
226 | if browse[0] == "directory" and location.is_file():
227 | location = location.parent
228 |
229 | logger.debug(f"Location set to '{location}'")
230 | return str(location) + os.sep
231 |
232 | if choice == "Manual...":
233 | # Handle manual entry
234 | response = simpledialog.askstring(
235 | " Manual Entry", "Custom Value:") or ""
236 |
237 | if response:
238 | logger.debug(f"Manually entered a value of '{response}'")
239 | return response or None
240 |
241 | if function:
242 | # Call a custom function if provided
243 | return_value_of_custom_function = getattr(
244 | CustomFunctions, function)(game_name, choice)
245 | logger.debug(
246 | f"Return value of {function}: {return_value_of_custom_function}")
247 |
248 | return choice
249 |
250 |
251 | class Info:
252 | @staticmethod
253 | def get_game_config_directory(game_name: str) -> Path | None:
254 | """Find the game config directory as used for autodetection in dropdowns."""
255 |
256 | # Get existing saved location
257 | game_config_directory = ModifyINI.app_config().get_value(
258 | "Directories", f"s{game_name}INIPath")
259 |
260 | # If no saved location, use the Windows environment variable to find the location
261 | if game_config_directory is None and os.name == "nt":
262 | CSIDL_PERSONAL = 5 # My Documents
263 | SHGFP_TYPE_CURRENT = 0 # Get current, not default value
264 |
265 | buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
266 | ctypes.windll.shell32.SHGetFolderPathW(
267 | None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
268 |
269 | documents_directory = Path(buf.value)
270 | logger.info(f"User documents location: {documents_directory}")
271 |
272 | game_config_directory = (
273 | documents_directory / "My Games" / Info.game_documents_name(game_name))
274 |
275 | if game_config_directory is not None:
276 | return Path(game_config_directory)
277 | else:
278 | return None
279 |
280 | @staticmethod
281 | def game_documents_name(game_name: str) -> str:
282 | game_name_documents_location_dict = {
283 | "Skyrim Special Edition": "Skyrim Special Edition",
284 | "Skyrim": "Skyrim",
285 | "Starfield": "Starfield",
286 | "Fallout 3": "Fallout3",
287 | "Fallout New Vegas": "FalloutNV",
288 | "Fallout 4": "Fallout4",
289 | "Enderal": "Enderal",
290 | "Oblivion": "Oblivion",
291 | }
292 |
293 | game_documents_name = game_name_documents_location_dict.get(
294 | game_name, "")
295 | if game_documents_name:
296 | logger.debug(
297 | f"{game_name} Documents/My Games/ folder is {game_documents_name}.")
298 | else:
299 | logger.error(
300 | f"{game_name} not in the list of known Documents/My Games/ folders.")
301 | return game_documents_name
302 |
303 | @staticmethod
304 | def game_reg(game_name: str) -> str:
305 | game_name_registry_dict = {
306 | "Skyrim Special Edition": "Skyrim Special Edition",
307 | "Skyrim": "skyrim",
308 | "Fallout 3": "fallout3",
309 | "Fallout New Vegas": "falloutnv",
310 | "Fallout 4": "Fallout4",
311 | "Enderal": "skyrim",
312 | "Oblivion": "oblivion",
313 | }
314 |
315 | game_reg = game_name_registry_dict.get(game_name, "")
316 | if not game_reg:
317 | logger.error(
318 | f"{game_name} not in the list of known registry locations.")
319 |
320 | return game_reg
321 |
322 |
323 | class CustomFunctions:
324 | # Placeholders to be set when bethini_app initializes
325 | screenwidth = 0
326 | screenheight = 0
327 |
328 | @staticmethod
329 | def getCurrentResolution(_game_name: str) -> str:
330 | # _game_name is required for CustomFunction calls
331 |
332 | return f"{CustomFunctions.screenwidth}x{CustomFunctions.screenheight}"
333 |
334 | @staticmethod
335 | def getBethesdaGameFolder(game_name: str) -> str | None:
336 | """Find the game install directory as used for autodetection in dropdowns for Bethesda games."""
337 |
338 | # Get existing saved location
339 | game_folder = ModifyINI.app_config().get_value(
340 | "Directories", f"s{game_name}Path")
341 |
342 | # If no saved location, check the registry
343 | if game_folder is None and "winreg" in globals():
344 | key_name = Info.game_reg(game_name)
345 | try:
346 | with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, Rf"SOFTWARE\WOW6432Node\Bethesda Softworks\{key_name}") as reg_handle:
347 | value, value_type = winreg.QueryValueEx(
348 | reg_handle, "Installed Path")
349 |
350 | if value and value_type == winreg.REG_SZ and isinstance(value, str):
351 | game_folder = value
352 |
353 | except OSError:
354 | logger.exception(
355 | f"Game path not found in the registry. Run the {game_name} launcher to set it.")
356 |
357 | if game_folder is None:
358 | game_folder = ""
359 | return game_folder
360 |
361 | @staticmethod
362 | def getGamePath(game_name: str) -> str:
363 | return ModifyINI.app_config().get_value("Directories", f"s{game_name}Path", "")
364 |
365 | @staticmethod
366 | def getINILocations(gameName: str) -> list[str]:
367 | game_documents_path = Info.get_game_config_directory(gameName)
368 | if game_documents_path is None:
369 | return ["", "Browse..."]
370 | game_documents_path.mkdir(parents=True, exist_ok=True)
371 | # This code throws errors if the file doesn't exist. What is its purpose? Commenting out for now.
372 | # app = AppName(gameName)
373 | # ini_files = app.what_ini_files_are_used()
374 | # for file in ini_files:
375 | # if gameName == "Starfield" and file == "Ultra.ini":
376 | # continue
377 | # file_path = game_documents_path / file
378 | # with file_path.open() as _fp:
379 | # pass
380 |
381 | return [f"{game_documents_path}{os.sep}", "Browse..."]
--------------------------------------------------------------------------------
/lib/dev.py:
--------------------------------------------------------------------------------
1 | import json
2 | import configparser
3 | import re
4 |
5 | print("Hello World")
6 |
7 | with open('settings.json', 'r') as f:
8 | settings = json.load(f)
9 |
10 | print("Settings loaded.")
11 |
12 | def sanitize_and_convert_float(value: str) -> str:
13 | """
14 | Sanitize a string to ensure it can be converted to a valid float and handle exponential notation.
15 |
16 | This function takes a string input, removes any invalid characters, and converts exponential notation
17 | to its decimal equivalent. If the input contains invalid characters, the function shortens the string
18 | to the valid part before the invalid characters start. If the conversion to float fails, the function
19 | defaults the value to "0".
20 |
21 | Args:
22 | value (str): The input string to be sanitized and converted.
23 |
24 | Returns:
25 | str: A sanitized string that can be safely converted to a float.
26 | """
27 | # New code to handle invalid characters and exponentials
28 | match = re.match(r"^[\d.eE+-]+", value)
29 | if match:
30 | value = match.group(0)
31 | try:
32 | # Convert exponential notation to decimal
33 | value = str(float(value))
34 | except ValueError:
35 | # If conversion fails, default to 0
36 | value = "0"
37 | else:
38 | value = "0" # Default to 0 if no valid part is found
39 | return value
40 |
41 | def update_preset_value(setting: str, section: str, preset: str, value: str):
42 | for ini_setting in settings["iniValues"]:
43 | if ini_setting["name"].lower() == setting.lower() and ini_setting["section"].lower() == section.lower():
44 | default_value = ini_setting["value"]["default"]
45 | if ini_setting["type"] != "string":
46 | value = float(sanitize_and_convert_float(value))
47 | default_value = float(sanitize_and_convert_float(str(default_value)))
48 | if ini_setting["type"] != "float":
49 | value = int(value)
50 | default_value = int(default_value)
51 | # if default_value == value:
52 | # break
53 | # try:
54 | # if ini_setting["notes"] == "Unused":
55 | # print(f"Setting {setting} in section {section} is unused, skipping.")
56 | # break
57 | # except KeyError:
58 | # print("No notes found for this setting.")
59 | # print(f"Default value for {setting} in section {section} is {default_value}.")
60 | ini_setting["value"][preset] = value
61 | ini_setting["alwaysPrint"] = True
62 | print(f"Updated {setting} in section {section} to {value} for preset {preset}.")
63 | break
64 |
65 | with open('FalloutPrefs.ini', 'r') as f:
66 | config = configparser.ConfigParser()
67 | config.read_file(f)
68 |
69 | for section in config.sections():
70 | print(f"Section: {section}")
71 | for setting, value in config.items(section):
72 | print(f" {setting}: {value}")
73 | update_preset_value(setting, section, "Bethini Ultra", value)
74 |
75 | with open('settings.json', 'w') as f:
76 | json.dump(settings, f, indent=4, ensure_ascii=False)
--------------------------------------------------------------------------------
/lib/menu_bar.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import tkinter as tk
3 | import ttkbootstrap as ttk
4 | from ttkbootstrap.constants import *
5 | from ttkbootstrap.dialogs import Messagebox
6 | from webbrowser import open_new_tab
7 |
8 | if __name__ == "__main__":
9 | sys.exit(1)
10 |
11 | from lib.restore_backup_window import RestoreBackupWindow
12 | from lib.ModifyINI import ModifyINI
13 | from lib.preferences import preferences
14 | from lib.customFunctions import set_theme
15 |
16 | class MenuBar(ttk.Frame):
17 | def __init__(self, master, *args, **kwargs):
18 | super().__init__(master.container, *args, **kwargs)
19 | self.master = master
20 |
21 | # Create the File menu button
22 | file_menu_button = ttk.Button(self, text="File", command=self.show_file_menu, style="secondary.TButton")
23 | file_menu_button.pack(side=tk.LEFT, padx=0)
24 |
25 | # Create the Edit menu button
26 | edit_menu_button = ttk.Button(self, text="Edit", command=self.show_edit_menu, style="secondary.TButton")
27 | edit_menu_button.pack(side=tk.LEFT, padx=0)
28 |
29 | # Create the Theme menu button
30 | theme_menu_button = ttk.Button(self, text="Theme", command=self.show_theme_menu, style="secondary.TButton")
31 | theme_menu_button.pack(side=tk.LEFT, padx=0)
32 |
33 | # Create the Help menu button
34 | help_menu_button = ttk.Button(self, text="Help", command=self.show_help_menu, style="secondary.TButton")
35 | help_menu_button.pack(side=tk.LEFT, padx=0)
36 |
37 | # Create the menus
38 | self.file_menu = tk.Menu(self, tearoff=False)
39 | self.file_menu.add_command(label="Save", command=master.save_ini_files)
40 | self.file_menu.add_separator()
41 | self.file_menu.add_command(label="Restore Backup", command=lambda: RestoreBackupWindow(master))
42 | self.file_menu.add_separator()
43 | self.file_menu.add_command(label="Choose Game", command=lambda: master.choose_game(forced=True))
44 | self.file_menu.add_separator()
45 | self.file_menu.add_command(label="Exit", command=master.on_closing)
46 |
47 | self.edit_menu = tk.Menu(self, tearoff=False)
48 | self.edit_menu.add_command(label="Preferences", command=lambda: preferences(master))
49 | self.edit_menu.add_command(label="Setup", command=master.show_setup)
50 |
51 | self.theme_menu = tk.Menu(self, tearoff=False)
52 | theme_names = list(ttk.Style().theme_names())
53 | for theme_name in theme_names:
54 | self.theme_menu.add_radiobutton(label=theme_name, variable=master.theme_name,
55 | value=theme_name, command=self.set_theme)
56 |
57 | self.help_menu = tk.Menu(self, tearoff=False)
58 | self.help_menu.add_command(label="Visit Web Page", command=lambda: open_new_tab("https://www.nexusmods.com/site/mods/631/"))
59 | self.help_menu.add_command(label="Get Support", command=lambda: open_new_tab("https://stepmodifications.org/forum/forum/200-Bethini-support/"))
60 | self.help_menu.add_command(label="About", command=master.about)
61 |
62 | def show_file_menu(self) -> None:
63 | self.file_menu.post(self.winfo_rootx(), self.winfo_rooty() + self.winfo_height())
64 |
65 | def show_edit_menu(self) -> None:
66 | self.edit_menu.post(self.winfo_rootx() + 50, self.winfo_rooty() + self.winfo_height())
67 |
68 | def show_theme_menu(self) -> None:
69 | self.theme_menu.post(self.winfo_rootx() + 100, self.winfo_rooty() + self.winfo_height())
70 |
71 | def show_help_menu(self) -> None:
72 | self.help_menu.post(self.winfo_rootx() + 150, self.winfo_rooty() + self.winfo_height())
73 |
74 | def set_theme(self) -> None:
75 | set_theme(self.master.style_override, self.master.theme_name.get())
--------------------------------------------------------------------------------
/lib/preferences.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import logging
3 | import ttkbootstrap as ttk
4 | from ttkbootstrap.constants import *
5 |
6 | if __name__ == "__main__":
7 | sys.exit(1)
8 |
9 | from lib.ModifyINI import ModifyINI
10 | from lib.customFunctions import set_titlebar_style
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | class preferences(ttk.Toplevel):
16 | def __init__(self, master, **kwargs):
17 | super().__init__(master, **kwargs)
18 | self.title("Preferences")
19 | set_titlebar_style(self)
20 | self.grab_set()
21 | self.focus_set()
22 | self.result = None
23 |
24 | # Set a minimum window size (width=500, height=300)
25 | self.minsize(300, 100)
26 |
27 | # Get the cursor position
28 | cursor_x = master.winfo_pointerx()
29 | cursor_y = master.winfo_pointery()
30 |
31 | # Set the position of the Toplevel window near the cursor
32 | self.geometry(f"+{cursor_x}+{cursor_y}")
33 |
34 | preferences_frame = ttk.Frame(self)
35 | preferences_frame_real = ttk.Frame(preferences_frame)
36 |
37 | general_lf = ttk.LabelFrame(preferences_frame_real, text="General")
38 |
39 | log_level_frame = ttk.Frame(general_lf)
40 | log_level_label = ttk.Label(log_level_frame, text="Log Level")
41 | self.log_level_var = ttk.StringVar(self)
42 | log_level_mb = ttk.Menubutton(log_level_frame, textvariable=self.log_level_var)
43 | log_level_menu = ttk.Menu(log_level_mb)
44 | log_level_list = ["Critical",
45 | "Error",
46 | "Warning",
47 | "Info",
48 | "Debug"]
49 | for option in log_level_list:
50 | log_level_menu.add_radiobutton(label=option, value=option, variable=self.log_level_var)
51 | log_level_mb["menu"] = log_level_menu
52 | self.log_level_var.set(ModifyINI.app_config().get_value("General", "sLogLevel", "Info"))
53 |
54 | max_backup_frame = ttk.Frame(general_lf)
55 | max_backups_label = ttk.Label(
56 | max_backup_frame, text="Max Backups to Keep")
57 | self.max_backups_var = ttk.StringVar(self)
58 | max_backups_sb = ttk.Spinbox(
59 | max_backup_frame, from_=-1, to=100, increment=1, width=5, textvariable=self.max_backups_var)
60 | self.max_backups_var.set(ModifyINI.app_config().get_value(
61 | "General", "iMaxBackups", "-1"))
62 |
63 | max_logs_frame = ttk.Frame(general_lf)
64 | max_logs_label = ttk.Label(max_logs_frame, text="Max Logs to Keep")
65 | self.max_logs_var = ttk.StringVar(self)
66 | max_logs_sb = ttk.Spinbox(
67 | max_logs_frame, from_=-1, to=100, increment=1, width=5, textvariable=self.max_logs_var)
68 | self.max_logs_var.set(ModifyINI.app_config().get_value(
69 | "General", "iMaxLogs", "5"))
70 |
71 | always_select_game_frame = ttk.Frame(general_lf)
72 | self.always_select_game_var = ttk.StringVar(self)
73 | always_select_game_cb = ttk.Checkbutton(
74 | always_select_game_frame, text="Always Select Game", onvalue="1", offvalue="0")
75 | always_select_game_cb.var = self.always_select_game_var
76 | always_select_game_cb.var.set(ModifyINI.app_config().get_value(
77 | "General", "bAlwaysSelectGame", "1"))
78 | always_select_game_cb.configure(variable=always_select_game_cb.var)
79 |
80 | preferences_frame.pack(fill=BOTH, expand=True)
81 | preferences_frame_real.pack(anchor=CENTER, expand=True)
82 | general_lf.pack(anchor=CENTER, padx=10, pady=10)
83 |
84 | log_level_frame.pack(anchor=E, padx=10, pady=10)
85 | log_level_label.pack(side=LEFT)
86 | log_level_mb.pack(padx=10)
87 |
88 | max_backup_frame.pack(anchor=E, padx=10, pady=10)
89 | max_backups_label.pack(side=LEFT)
90 | max_backups_sb.pack(padx=10)
91 |
92 | max_logs_frame.pack(anchor=E, padx=10, pady=10)
93 | max_logs_label.pack(side=LEFT)
94 | max_logs_sb.pack(padx=10)
95 |
96 | always_select_game_frame.pack(anchor=E, padx=10, pady=10)
97 | always_select_game_cb.pack(side=LEFT, padx=10)
98 |
99 | self.save_button = ttk.Button(
100 | preferences_frame, text="Save", style="success.TButton", command=self.on_save)
101 | self.save_button.pack(side=RIGHT, padx=5, pady=5)
102 | self.cancel_button = ttk.Button(
103 | preferences_frame, text="Cancel", style="danger.TButton", command=self.on_cancel)
104 | self.cancel_button.pack(side=RIGHT, padx=5, pady=5)
105 |
106 | def on_save(self):
107 | logger.debug("Save")
108 | ModifyINI.app_config().assign_setting_value(
109 | "General", "sLogLevel", self.log_level_var.get())
110 | ModifyINI.app_config().assign_setting_value(
111 | "General", "iMaxBackups", self.max_backups_var.get())
112 | ModifyINI.app_config().assign_setting_value(
113 | "General", "iMaxLogs", self.max_logs_var.get())
114 | ModifyINI.app_config().assign_setting_value(
115 | "General", "bAlwaysSelectGame", self.always_select_game_var.get())
116 |
117 | self.destroy()
118 |
119 | def on_cancel(self):
120 | logger.debug("Cancel")
121 | self.destroy()
--------------------------------------------------------------------------------
/lib/restore_backup_window.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import logging
3 | import ttkbootstrap as ttk
4 | import shutil
5 | import os
6 |
7 | from pathlib import Path
8 | from stat import S_IWRITE, S_IREAD
9 | from ttkbootstrap.constants import *
10 | from ttkbootstrap.dialogs import Messagebox
11 | from lib.simple_dialog_windows import AskQuestionWindow
12 |
13 | if __name__ == "__main__":
14 | sys.exit(1)
15 |
16 | from lib.customFunctions import set_titlebar_style
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class RestoreBackupWindow(ttk.Toplevel):
22 | """RestoreBackupWindow shows the Restore Backup Window"""
23 |
24 | def __init__(self, master, **kwargs):
25 | super().__init__(master, **kwargs)
26 | self.title("Restore Backup")
27 | self.master = master
28 | set_titlebar_style(self)
29 | self.grab_set()
30 | self.focus_set()
31 | self.result = False
32 | # Set a minimum window size (width=500, height=300)
33 | self.minsize(400, 300)
34 | # Get the cursor position
35 | cursor_x = master.winfo_pointerx()
36 | cursor_y = master.winfo_pointery()
37 | # Set the position of the Toplevel window near the cursor
38 | self.geometry(f"+{cursor_x}+{cursor_y}")
39 |
40 | self.tk_dict = {}
41 |
42 | restore_frame = ttk.Frame(self)
43 | restore_frame_real = ttk.Frame(restore_frame)
44 |
45 | restore_frame.pack(fill=BOTH, expand=True, padx=5, pady=5)
46 | restore_frame_real.pack(anchor=CENTER, expand=True)
47 |
48 | # Iterate over the ini files used by the application
49 | for i, ini_file in enumerate(master.app.what_ini_files_are_used(), start=1):
50 | n = 0
51 | self.tk_dict[f"Frame_{i}"] = {}
52 | self.tk_dict[f"Frame_{i}"]["tkFrame"] = ttk.Frame(
53 | restore_frame_real)
54 | self.tk_dict[f"Frame_{i}"][f"Label_{i}"] = ttk.Label(
55 | self.tk_dict[f"Frame_{i}"]["tkFrame"], text=ini_file)
56 |
57 | self.tk_dict[f"Frame_{i}"]["ini_file"] = ini_file
58 | ini_location = master.getINILocation(ini_file)
59 | self.tk_dict[f"Frame_{i}"]["ini_location"] = ini_location
60 | backup_directory = Path(ini_location, "Bethini Pie backups")
61 | self.tk_dict[f"Frame_{i}"]["backup_directory"] = backup_directory
62 |
63 | self.tk_dict[f"Frame_{i}"][f"Treeview_{i}"] = ttk.Treeview(
64 | self.tk_dict[f"Frame_{i}"]["tkFrame"], selectmode=BROWSE, show="tree", columns=("Backup"))
65 |
66 | # Populate the Treeview with backup directories containing the ini file
67 | for backup_location in backup_directory.iterdir():
68 | if backup_location.is_dir() and (backup_location / ini_file).exists():
69 | has_backup = True
70 | n += 1
71 | self.tk_dict[f"Frame_{i}"][f"Treeview_{i}"].insert(
72 | "", "end", id=backup_location.name, text=backup_location.name, values=ini_file)
73 |
74 | self.tk_dict[f"Frame_{i}"][f"restore_button_{i}"] = ttk.Button(
75 | self.tk_dict[f"Frame_{i}"]["tkFrame"], text="Restore Selected")
76 |
77 | #Limit the height of the treeview
78 | if n > 5:
79 | n = 5
80 | self.tk_dict[f"Frame_{i}"][f"Treeview_{i}"]["height"] = n + 2
81 |
82 | if n > 0:
83 | self.tk_dict[f"Frame_{i}"]["tkFrame"].pack(
84 | padx=5, pady=5, anchor=CENTER)
85 | self.tk_dict[f"Frame_{i}"][f"Label_{i}"].pack(
86 | padx=5, pady=5, anchor=NW)
87 | self.tk_dict[f"Frame_{i}"][f"Treeview_{i}"].pack(
88 | padx=5, pady=5)
89 | self.tk_dict[f"Frame_{i}"][f"restore_button_{i}"].pack(
90 | padx=5, pady=5)
91 | self.tk_dict[f"Frame_{i}"][f"restore_button_{i}"].pack_forget()
92 |
93 | # Bind the Treeview selection event to show the restore button
94 | self.tk_dict[f"Frame_{i}"][f"Treeview_{i}"].bind(
95 | "", lambda e, i=i: self.on_treeview_click(e, i))
96 |
97 | self.close_button = ttk.Button(
98 | restore_frame, text="Close", command=self.on_close)
99 | self.close_button.pack(side=RIGHT, padx=10, pady=5)
100 |
101 | # Bind the window close event to the on_close method
102 | self.protocol("WM_DELETE_WINDOW", self.on_close)
103 |
104 | def on_close(self):
105 | """Handle the window close event."""
106 | logger.debug("Closed restore backup window")
107 | if self.result:
108 | Messagebox.show_info(message="A backup has been restored. Bethini Pie will now close.",
109 | title="Bethini Pie will now close", parent=self)
110 | self.master.quit()
111 | self.destroy()
112 |
113 | def on_treeview_click(self, event, i):
114 | """Handle the Treeview selection event to show the restore button."""
115 | item = self.tk_dict[f"Frame_{i}"][f"Treeview_{i}"].focus()
116 | if item:
117 | self.tk_dict[f"Frame_{i}"][f"restore_button_{i}"].pack()
118 | self.tk_dict[f"Frame_{i}"][f"restore_button_{i}"].bind(
119 | "", lambda e, i=i, item=item: self.on_restore_button_click(e, i, item))
120 | else:
121 | self.tk_dict[f"Frame_{i}"][f"restore_button_{i}"].pack_forget()
122 |
123 | def on_restore_button_click(self, event, i, item):
124 | """Handle the restore button click event."""
125 | logger.debug(f"Restore button clicked for backup {item}")
126 | ini_file = self.tk_dict[f"Frame_{i}"]["ini_file"]
127 | backup_directory = Path(self.tk_dict[f"Frame_{i}"]["backup_directory"])
128 | backup_file = backup_directory / item / ini_file
129 | response = Messagebox.show_question(
130 | parent=self, title="Restore Backup", message=f"Are you sure you want to restore this backup?\n{backup_file}", buttons=["No:secondary", "Yes:primary"])
131 | logger.debug(f"User clicked {response}")
132 | if response == "No":
133 | Messagebox.show_info(parent=self, title="Cancelled restore",
134 | message="Restore backup cancelled. No files were modified.")
135 | elif response == "Yes":
136 | self.restore_backup(i, item)
137 |
138 | def restore_backup(self, i, item):
139 | """Restore the selected backup."""
140 | ini_file = self.tk_dict[f"Frame_{i}"]["ini_file"]
141 | ini_location = Path(self.tk_dict[f"Frame_{i}"]["ini_location"])
142 | backup_directory = Path(self.tk_dict[f"Frame_{i}"]["backup_directory"])
143 | original_file = ini_location / ini_file
144 | backup_file = backup_directory / item / ini_file
145 | logger.info(f"Restoring backup {backup_file} to {original_file}")
146 | try:
147 | shutil.copyfile(backup_file, original_file)
148 | msg = f"Restoring backup {backup_file} to {original_file} was successful."
149 | Messagebox.show_info(parent=self, title="Successfully restored backup",
150 | message=f"Restoring backup {backup_file} to {original_file} was successful.")
151 | logger.info(msg)
152 | self.result = True
153 | except FileNotFoundError:
154 | msg = f"Restoring {backup_file} to {original_file} failed due to {backup_file} not existing."
155 | logger.exception(msg)
156 | Messagebox.show_error(
157 | parent=self, title="Error restoring backup", message=msg)
158 | except PermissionError:
159 | msg = f"Restoring {backup_file} to {original_file} failed due to a permission error."
160 | logger.exception(msg)
161 | if not os.access(original_file, os.W_OK):
162 | logger.warning(f"{original_file} is read only.")
163 | change_read_only = AskQuestionWindow(
164 | self.master, title="Remove read-only flag?",
165 | question=f"{original_file} is set to read-only, so it cannot be overwritten. Would you like to temporarily clear the read-only flag to allow it to be saved?")
166 | self.master.wait_window(change_read_only)
167 | if change_read_only.result:
168 | try:
169 | os.chmod(original_file, S_IWRITE)
170 | shutil.copyfile(backup_file, original_file)
171 | msg = f"Restoring backup {backup_file} to {original_file} was successful."
172 | Messagebox.show_info(parent=self, title="Successfully restored backup",
173 | message=f"Restoring backup {backup_file} to {original_file} was successful.")
174 | logger.info(msg)
175 | self.result = True
176 | os.chmod(original_file, S_IREAD)
177 | except PermissionError as e:
178 | logger.exception(
179 | f"{original_file} was still not able to be modified after clearing the read-only flag.")
180 | else:
181 | logger.debug(f"User decided not to clear the read-only flag on {original_file}")
182 | else:
183 | logger.info(f"{original_file} is not read only.")
184 |
185 |
--------------------------------------------------------------------------------
/lib/save_changes_dialog.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import ttkbootstrap as ttk
3 | from ttkbootstrap.constants import *
4 |
5 | if __name__ == "__main__":
6 | sys.exit(1)
7 |
8 | from lib.tableview_scrollable import TableviewScrollable
9 | from lib.ModifyINI import ModifyINI
10 | from lib.customFunctions import set_titlebar_style
11 |
12 |
13 | class SaveChangesDialog(ttk.Toplevel):
14 | def __init__(self, parent: ttk.Window, ini_object: ModifyINI, *args, **kwargs):
15 | '''
16 | SaveChangesDialog is a custom dialog window that displays a table of changes made to an INI file and prompts the user to save those changes.
17 |
18 | SaveChangesDialog Parameters:
19 |
20 | parent (ttk.Window):
21 | The parent application window.
22 |
23 | ini_object (ModifyINI):
24 | The ini object to be saved.
25 |
26 | sort (tuple[bool, bool]):
27 | This tuple contains two boolean values. The first value indicates whether the ini file should be sorted by default.
28 | The second value determines whether the checkbox for sorting should be displayed.
29 | '''
30 | super().__init__(parent, *args, **kwargs)
31 | set_titlebar_style(self)
32 |
33 | self.ini_object = ini_object
34 | ini_name = ini_object.ini_path.name
35 | self.result = False
36 | self.sort = ini_object.sortable
37 | self.sortcb = ttk.BooleanVar(value=self.sort)
38 |
39 | self.title(f"Save {ini_name}?")
40 | self.minsize(600, 500)
41 | self.grab_set()
42 | self.focus_set()
43 |
44 | # Set the position of the window to align with the Northwest corner of the parent
45 | parent_x = parent.winfo_rootx()
46 | parent_y = parent.winfo_rooty()
47 | self.geometry(f"+{parent_x}+{parent_y}")
48 |
49 | # Create a frame for the table
50 | frame = ttk.Frame(self)
51 | frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
52 |
53 | question = ttk.Label(
54 | frame, text=f"Would you like to save the following changes to {ini_name}?")
55 | question.pack(fill=X, expand=False, pady=5)
56 |
57 | # Create the TableviewScrollable
58 | coldata = ["Section", "ID", "Change"]
59 | rowdata = [
60 | (section, setting, value)
61 | for section, settings in ini_object.modifications.items()
62 | for setting, value in settings.items()
63 | ]
64 | self.table = TableviewScrollable(
65 | frame, coldata=coldata, rowdata=rowdata, searchable=False, autoalign=False, yscrollbar=True)
66 | self.table.pack(fill=BOTH, expand=True)
67 | self.table.autofit_columns()
68 |
69 | # Create buttons
70 | button_frame = ttk.Frame(self)
71 | button_frame.pack(fill=X, padx=10, pady=10)
72 |
73 | # Create a checkbox asking the user if they want to sort or not.
74 | if ini_object.sortable:
75 | sort_checkbox = ttk.Checkbutton(
76 | button_frame,
77 | text="Sorted",
78 | variable=self.sortcb,
79 | onvalue=True,
80 | offvalue=False,
81 | )
82 | sort_checkbox.pack(side=LEFT)
83 |
84 | save_button = ttk.Button(
85 | button_frame, text="Save", command=self.on_save, style="success.TButton")
86 | save_button.pack(side=RIGHT, padx=5)
87 |
88 | cancel_button = ttk.Button(
89 | button_frame, text="Cancel", command=self.on_cancel, style="danger.TButton")
90 | cancel_button.pack(side=RIGHT, padx=5)
91 |
92 |
93 |
94 | def on_save(self):
95 | self.result = True
96 | self.sort = self.sortcb.get()
97 | self.destroy()
98 |
99 | def on_cancel(self):
100 | self.result = False
101 | self.destroy()
102 |
--------------------------------------------------------------------------------
/lib/scalar.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import tkinter as tk
3 | import ttkbootstrap as ttk
4 | from typing import TYPE_CHECKING, Literal
5 |
6 | if __name__ == "__main__":
7 | sys.exit(1)
8 |
9 | if TYPE_CHECKING:
10 | from lib.type_helpers import *
11 |
12 |
13 | class Scalar(ttk.Scale):
14 | """A ttk.Scale with limited decimal places."""
15 |
16 | def __init__(
17 | self,
18 | master: tk.Misc | None = None,
19 | # command: str | Callable[[str], object] = "",
20 | from_: float = 0,
21 | length: int = 100,
22 | orient: Literal["horizontal", "vertical"] = "horizontal",
23 | to: float = 1,
24 | variable: ttk.IntVar | ttk.DoubleVar | None = None,
25 | decimal_places: "IntStr" = "0",
26 | ) -> None:
27 | self.decimal_places = int(decimal_places)
28 | # Currently unused. Supports the above commented command parameter.
29 | # if command:
30 | # self.chain = command
31 | # else:
32 | # self.chain = lambda *_a: None
33 | super().__init__(master, command=self._value_changed,
34 | from_=from_, length=length, orient=orient, to=to)
35 | self.variable = variable
36 | if self.variable:
37 | self.configure(variable=self.variable)
38 |
39 | def _value_changed(self, _new_value: str) -> None:
40 | if self.variable:
41 | value = self.get()
42 | value = int(value) if self.decimal_places == 0 else round(
43 | value, self.decimal_places)
44 |
45 | if isinstance(self.variable, ttk.IntVar):
46 | self.variable.set(int(value))
47 | else:
48 | self.variable.set(value)
49 |
50 | # See above comments.
51 | # if isinstance(self.chain, str):
52 | # pass
53 | # else:
54 | # self.chain(value)
55 |
--------------------------------------------------------------------------------
/lib/simple_dialog_windows.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import logging
3 | import ttkbootstrap as ttk
4 | from ttkbootstrap.constants import *
5 | from ttkbootstrap.icons import Icon
6 |
7 | if __name__ == "__main__":
8 | sys.exit(1)
9 |
10 | from lib.customFunctions import set_titlebar_style
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | class AskQuestionWindow(ttk.Toplevel):
16 | """AskQuestionWindow is a Toplevel window asking the user a yes/no question, with the button pressed being stored as the self.result."""
17 | def __init__(self, master, title: str, question: str, wraplength: int = 400, **kwargs):
18 | super().__init__(master, **kwargs)
19 | self.title(title)
20 | set_titlebar_style(self)
21 | self.grab_set()
22 | self.focus_set()
23 | self.protocol("WM_DELETE_WINDOW", self.destroy)
24 | self.minsize(200, 35)
25 | self.result = None
26 | x = master.winfo_x()
27 | y = master.winfo_y()
28 | self.geometry(f"+{x + 50}+{y + 50}")
29 |
30 | # Create the main frame for the dialog
31 | ask_question_frame = ttk.Frame(self)
32 | ask_question_frame.pack(fill=BOTH, expand=True)
33 |
34 | # Create a sub-frame for the content
35 | ask_question_frame_real = ttk.Frame(ask_question_frame)
36 | ask_question_frame_real.pack(anchor=CENTER, expand=True, pady=10)
37 |
38 | # Store the PhotoImage object as an instance variable to prevent garbage collection
39 | self.question_icon = ttk.PhotoImage(data=Icon.question)
40 |
41 | # Create and pack the icon label
42 | icon_lbl = ttk.Label(ask_question_frame_real, image=self.question_icon)
43 | icon_lbl.pack(side=LEFT, padx=5, anchor=CENTER)
44 |
45 | # Create and pack the question label
46 | ask_question_label = ttk.Label(
47 | ask_question_frame_real,
48 | text=question,
49 | justify=LEFT,
50 | wraplength=wraplength,
51 | )
52 | ask_question_label.pack(anchor=CENTER, padx=10, pady=10)
53 | logger.debug(f"User was asked: {question}")
54 |
55 | # Add a separator
56 | ttk.Separator(ask_question_frame).pack(fill=X)
57 |
58 | # Create and pack the "Yes" button
59 | yes_button = ttk.Button(
60 | ask_question_frame, text="Yes", command=self.on_yes, style="success.TButton")
61 | yes_button.pack(side=RIGHT, padx=8, pady=8)
62 |
63 | # Create and pack the "No" button
64 | no_button = ttk.Button(
65 | ask_question_frame, text="No", command=self.on_no, style="danger.TButton")
66 | no_button.pack(side=RIGHT, pady=8)
67 |
68 | def on_no(self):
69 | """Handle the "No" button click event."""
70 | logger.debug("User clicked no.")
71 | self.result = False
72 | self.destroy()
73 |
74 | def on_yes(self):
75 | """Handle the "Yes" button click event."""
76 | logger.debug("User clicked yes.")
77 | self.result = True
78 | self.destroy()
79 |
80 | class ManualEntryWindow(ttk.Toplevel):
81 | """ManualEntryWindow is a Toplevel window asking the user to input into a text box."""
82 | def __init__(self, master, **kwargs):
83 | super().__init__(master, **kwargs)
84 | self.title("Manual Entry")
85 | set_titlebar_style(self)
86 | self.grab_set()
87 | self.focus_set()
88 | self.protocol("WM_DELETE_WINDOW", self.destroy)
89 | self.minsize(300, 35)
90 | self.result = ttk.StringVar(self, value=None)
91 |
92 | # Create the main frame for the dialog
93 | manual_entry_frame = ttk.Frame(self)
94 | manual_entry_frame.pack(fill=BOTH, expand=True)
95 |
96 | # Create a sub-frame for the content
97 | manual_entry_frame_real = ttk.Frame(manual_entry_frame)
98 | manual_entry_frame_real.pack(anchor=CENTER, expand=True, pady=10)
99 |
100 | # Create and pack the question label
101 | manual_entry_label = ttk.Label(
102 | manual_entry_frame_real,
103 | text="Enter a custom value:",
104 | justify=CENTER,
105 | )
106 | manual_entry_label.pack(anchor=CENTER, padx=10, pady=10)
107 |
108 | # Create and pack the entry
109 | manual_entry = ttk.Entry(manual_entry_frame_real, textvariable=self.result)
110 | manual_entry.pack()
111 |
112 | # Add a separator
113 | ttk.Separator(manual_entry_frame).pack(fill=X)
114 |
115 | # Create and pack the "Yes" button
116 | ok_button = ttk.Button(
117 | manual_entry_frame, command=self.on_ok, text="OK")
118 | ok_button.pack(side=RIGHT, padx=8, pady=8)
119 |
120 | def on_ok(self):
121 | """Handle the "OK" button click event."""
122 | logger.debug("User clicked OK.")
123 | self.destroy()
--------------------------------------------------------------------------------
/lib/tableview_scrollable.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import ttkbootstrap as ttk
3 | from ttkbootstrap.constants import *
4 | from ttkbootstrap.tableview import Tableview, TableCellRightClickMenu, TableHeaderRightClickMenu
5 |
6 | if __name__ == "__main__":
7 | sys.exit(1)
8 |
9 | class TableviewScrollable(Tableview):
10 | def __init__(
11 | self,
12 | master=None,
13 | bootstyle=DEFAULT,
14 | coldata=[],
15 | rowdata=[],
16 | paginated=False,
17 | searchable=False,
18 | yscrollbar=True,
19 | autofit=False,
20 | autoalign=True,
21 | stripecolor=None,
22 | pagesize=10,
23 | height=10,
24 | delimiter=",",
25 | ):
26 | """
27 | Parameters:
28 |
29 | master (Widget):
30 | The parent widget.
31 |
32 | bootstyle (str):
33 | A style keyword used to set the focus color of the entry
34 | and the background color of the date button. Available
35 | options include -> primary, secondary, success, info,
36 | warning, danger, dark, light.
37 |
38 | coldata (List[str | Dict]):
39 | An iterable containing either the heading name or a
40 | dictionary of column settings. Configurable settings
41 | include >> text, image, command, anchor, width, minwidth,
42 | maxwidth, stretch. Also see `Tableview.insert_column`.
43 |
44 | rowdata (List):
45 | An iterable of row data. The lenth of each row of data
46 | must match the number of columns. Also see
47 | `Tableview.insert_row`.
48 |
49 | paginated (bool):
50 | Specifies that the data is to be paginated. A pagination
51 | frame will be created below the table with controls that
52 | enable the user to page forward and backwards in the
53 | data set.
54 |
55 | pagesize (int):
56 | When `paginated=True`, this specifies the number of rows
57 | to show per page.
58 |
59 | searchable (bool):
60 | If `True`, a searchbar will be created above the table.
61 | Press the key to initiate a search. Searching
62 | with an empty string will reset the search criteria, or
63 | pressing the reset button to the right of the search
64 | bar. Currently, the search method looks for any row
65 | that contains the search text. The filtered results
66 | are displayed in the table view.
67 |
68 | yscrollbar (bool):
69 | If `True`, a vertical scrollbar will be created to the right
70 | of the table.
71 |
72 | autofit (bool):
73 | If `True`, the table columns will be automatically sized
74 | when loaded based on the records in the current view.
75 | Also see `Tableview.autofit_columns`.
76 |
77 | autoalign (bool):
78 | If `True`, the column headers and data are automatically
79 | aligned. Numbers and number headers are right-aligned
80 | and all other data types are left-aligned. The auto
81 | align method evaluates the first record in each column
82 | to determine the data type for alignment. Also see
83 | `Tableview.autoalign_columns`.
84 |
85 | stripecolor (Tuple[str, str]):
86 | If provided, even numbered rows will be color using the
87 | (background, foreground) specified. You may specify one
88 | or the other by passing in **None**. For example,
89 | `stripecolor=('green', None)` will set the stripe
90 | background as green, but the foreground will remain as
91 | default. You may use standand color names, hexadecimal
92 | color codes, or bootstyle color keywords. For example,
93 | ('light', '#222') will set the background to the "light"
94 | themed ttkbootstrap color and the foreground to the
95 | specified hexadecimal color. Also see
96 | `Tableview.apply_table_stripes`.
97 |
98 | height (int):
99 | Specifies how many rows will appear in the table's viewport.
100 | If the number of records extends beyond the table height,
101 | the user may use the mousewheel or scrollbar to navigate
102 | the data.
103 |
104 | delimiter (str):
105 | The character to use as a delimiter when exporting data
106 | to CSV.
107 | """
108 | self.yscrollbar = yscrollbar
109 | super().__init__(
110 | master,
111 | bootstyle,
112 | coldata,
113 | rowdata,
114 | paginated,
115 | searchable,
116 | autofit,
117 | autoalign,
118 | stripecolor,
119 | pagesize,
120 | height,
121 | delimiter,
122 | )
123 |
124 | def _build_tableview_widget(self, coldata, rowdata, bootstyle):
125 | """Build the data table"""
126 | if self._searchable:
127 | self._build_search_frame()
128 |
129 | table_frame = ttk.Frame(self)
130 | table_frame.pack(fill=BOTH, expand=YES, side=TOP)
131 |
132 | self.view = ttk.Treeview(
133 | master=table_frame,
134 | columns=[x for x in range(len(coldata))],
135 | height=self._height,
136 | selectmode=EXTENDED,
137 | show=HEADINGS,
138 | bootstyle=f"{bootstyle}-table",
139 | )
140 | self.view.pack(fill=BOTH, expand=YES, side=LEFT)
141 |
142 | if self.yscrollbar:
143 | self.ybar = ttk.Scrollbar(
144 | master=table_frame, command=self.view.yview, orient=VERTICAL
145 | )
146 | self.ybar.pack(fill=Y, side=RIGHT)
147 | self.view.configure(yscrollcommand=self.ybar.set)
148 |
149 | self.hbar = ttk.Scrollbar(
150 | master=self, command=self.view.xview, orient=HORIZONTAL
151 | )
152 | self.hbar.pack(fill=X)
153 | self.view.configure(xscrollcommand=self.hbar.set)
154 |
155 | if self._paginated:
156 | self._build_pagination_frame()
157 |
158 | self.build_table_data(coldata, rowdata)
159 |
160 | self._rightclickmenu_cell = TableCellRightClickMenu(self)
161 | self._rightclickmenu_head = TableHeaderRightClickMenu(self)
162 | self._set_widget_binding()
--------------------------------------------------------------------------------
/lib/tooltips.py:
--------------------------------------------------------------------------------
1 | """Modification of Hovertip"""
2 |
3 | import sys
4 | import tkinter as tk
5 | import ttkbootstrap as ttk
6 | from ttkbootstrap.constants import *
7 | from ttkbootstrap.tooltip import ToolTip
8 | from pathlib import Path
9 |
10 | from PIL import Image, ImageTk
11 |
12 | if __name__ == "__main__":
13 | sys.exit(1)
14 |
15 | from lib.customFunctions import set_titlebar_style
16 |
17 |
18 | class Hovertip(ToolTip):
19 | """A tooltip that pops up when a mouse hovers over an anchor widget."""
20 |
21 | def __init__(
22 | self,
23 | widget: tk.Widget,
24 | text: str,
25 | description: str,
26 | code: list[str] | None,
27 | preview_window: ttk.Toplevel,
28 | preview_frame: ttk.Frame,
29 | photo_for_setting: Path | None,
30 | bootstyle: str = None,
31 | wraplength: int = 250,
32 | delay: int = 500,
33 | **kwargs,
34 | ) -> None:
35 | """A tooltip popup window that shows text when the
36 | mouse is hovering over the widget and closes when the mouse is no
37 | longer hovering over the widget. Also serves as our Preview Window handler.
38 |
39 |
40 | ToolTip Parameters:
41 |
42 | widget (Widget):
43 | The tooltip window will position over this widget when
44 | hovering.
45 |
46 | text (str):
47 | The text to display in the tooltip window.
48 |
49 | description (str):
50 | The description to display in the preview window.
51 |
52 | code list[str]:
53 | A list of strings, typically ini settings in their section and ini file, placed inside a "code block" or Entry widget.
54 |
55 | preview_window (ttk.Toplevel):
56 | The toplevel widget for the preview window.
57 |
58 | preview_frame (ttk.Frame):
59 | The frame widget inside the preview window.
60 |
61 | photo_for_setting (Path):
62 | The photo to be placed inside the preview window.
63 |
64 | bootstyle (str):
65 | The style to apply to the tooltip label. You can use
66 | any of the standard ttkbootstrap label styles.
67 |
68 | wraplength (int):
69 | The width of the tooltip window in screenunits before the
70 | text is wrapped to the next line. By default, this will be
71 | a scaled factor of 300.
72 |
73 | **kwargs (Dict):
74 | Other keyword arguments passed to the `Toplevel` window.
75 |
76 | """
77 |
78 | super().__init__(
79 | widget,
80 | text,
81 | bootstyle,
82 | wraplength,
83 | delay,
84 | **kwargs,)
85 |
86 | self.description = description
87 | self.preview_window = preview_window
88 | self.preview_frame = preview_frame
89 | self.photo_for_setting = photo_for_setting
90 | self.widget = widget
91 | self.code = code
92 | self.preview_image: ImageTk.PhotoImage | None = None
93 | self.widget.bind("", self.show_preview)
94 |
95 | def show_preview(self, _event: "tk.Event[tk.Widget] | None" = None) -> None:
96 | """Display the preview window."""
97 |
98 | for widget in self.preview_frame.winfo_children():
99 | widget.destroy()
100 | set_titlebar_style(self.preview_window)
101 |
102 | if self.photo_for_setting:
103 | if self.preview_image is None:
104 | self.preview_image = ImageTk.PhotoImage(
105 | Image.open(self.photo_for_setting))
106 |
107 | ttk.Label(
108 | self.preview_frame,
109 | image=self.preview_image,
110 | ).pack(anchor=NW)
111 |
112 | ttk.Label(
113 | self.preview_frame,
114 | text=self.description,
115 | wraplength=1000,
116 | ).pack(anchor=NW, padx = 10, pady = 10)
117 |
118 | if self.code:
119 | code_text = ttk.Text(self.preview_frame)
120 | for iterator, line in enumerate(self.code, start = 1):
121 | code_text.insert(END, line + "\n")
122 | code_text.configure(height=iterator)
123 | code_text.pack(anchor=NW, padx = 10, pady=10)
124 |
125 | self.preview_window.minsize(300, 50)
126 | self.preview_window.deiconify()
127 |
--------------------------------------------------------------------------------
/lib/type_helpers.py:
--------------------------------------------------------------------------------
1 | #
2 | # This work is licensed under the
3 | # Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
4 | # To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/
5 | # or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
6 | #
7 |
8 | import tkinter as tk
9 | from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypeAlias, TypedDict
10 |
11 | import ttkbootstrap as ttk
12 |
13 | if TYPE_CHECKING:
14 | from collections.abc import Callable
15 |
16 | from lib.scalar import Scalar
17 |
18 | ININame: TypeAlias = Literal[
19 | "Bethini.ini",
20 | "Fallout4.ini",
21 | "Fallout4Prefs.ini",
22 | "Fallout4Custom.ini",
23 | "Fallout.ini",
24 | "FalloutPrefs.ini",
25 | "FalloutCustom.ini",
26 | "Skyrim.ini",
27 | "SkyrimPrefs.ini",
28 | "SkyrimCustom.ini",
29 | "Starfield.ini",
30 | "StarfieldCustom.ini",
31 | "StarfieldPrefs.ini",
32 | "Ultra.ini",
33 | ]
34 |
35 | IntStr: TypeAlias = str
36 | """A string representing an integer."""
37 |
38 | FloatStr: TypeAlias = str
39 | """A string representing an float."""
40 |
41 | ValidationType: TypeAlias = Literal["integer", "whole", "counting", "float"]
42 |
43 | Browse: TypeAlias = tuple[Literal["directory"], Literal["directory"] | str, Literal["directory", "file"]]
44 |
45 | ColorType: TypeAlias = Literal["rgb", "rgb 1", "rgba", "abgr decimal", "rgba decimal", "decimal", "hex"]
46 | ColorValue: TypeAlias = str | tuple[int, ...]
47 |
48 | TabId: TypeAlias = str
49 | """A string in the format Page1, Page2, etc."""
50 |
51 | SettingId: TypeAlias = str
52 | """A string in the format Setting1, Setting2, etc."""
53 |
54 | SettingFrameId: TypeAlias = str
55 | """A string in the format SettingFrame1, SettingFrame2, etc."""
56 |
57 | LabelFrameId: TypeAlias = str
58 | """A string in the format LabelFrame1, LabelFrame2, etc."""
59 |
60 | ValueType: TypeAlias = Literal["boolean", "float", "number", "string"]
61 |
62 | ValueList: TypeAlias = list[list[Literal[""] | IntStr | FloatStr | str]]
63 |
64 | TkAnchor: TypeAlias = Literal["nw", "n", "ne", "w", "center", "e", "sw", "s", "se"]
65 | TkFill: TypeAlias = Literal["none", "x", "y", "both"]
66 | TkSide: TypeAlias = Literal["left", "right", "top", "bottom"]
67 |
68 | SettingType: TypeAlias = Literal[
69 | "Checkbutton",
70 | "Color",
71 | "Combobox",
72 | "Dropdown",
73 | "Entry",
74 | "preset",
75 | "radioPreset",
76 | "Slider",
77 | "Spinbox",
78 | ]
79 |
80 | WidgetId: TypeAlias = Literal[
81 | "second_tk_widget",
82 | "TkCheckbutton",
83 | "TkColor",
84 | "TkCombobox",
85 | "TkEntry",
86 | "TkOptionMenu",
87 | "TkPresetButton",
88 | "TkRadioPreset",
89 | "TkSlider",
90 | "TkSpinbox",
91 | ]
92 |
93 | PresetName: TypeAlias = Literal[
94 | "default",
95 | "recommended",
96 | "fixedDefault",
97 | "Vanilla Low",
98 | "Vanilla Medium",
99 | "Vanilla High",
100 | "Vanilla Ultra",
101 | "Bethini Poor",
102 | "Bethini Low",
103 | "Bethini Medium",
104 | "Bethini High",
105 | "Bethini Ultra",
106 | ] | str
107 |
108 |
109 | class GameSetting(TypedDict):
110 | ini: ININame | None
111 | section: str
112 | value: str
113 |
114 |
115 | class GameSettingInfo(TypedDict, total=False):
116 | alwaysPrint: bool
117 | ini: ININame | None
118 | name: str
119 | section: str
120 | type: ValueType
121 | value: dict[PresetName | str, int | float | str]
122 |
123 |
124 | class DependentSetting(TypedDict, total=False):
125 | operator: Literal["greater-than", "greater-or-equal-than", "less-than", "less-or-equal-than", "not-equal", "equal"]
126 | operator_func: "Callable[[Any], Any] | None"
127 | value: str | list[list[str]] | float
128 |
129 | var: Literal["string", "float"]
130 | setToOff: bool
131 |
132 | Offvalue: ValueList | None
133 | Onvalue: ValueList | None
134 |
135 |
136 | class BethiniSetting(
137 | TypedDict(
138 | "BethiniSetting",
139 | {
140 | "decimal places": NotRequired[IntStr | None],
141 | "from": NotRequired[IntStr],
142 | "preset id": NotRequired[str],
143 | },
144 | ),
145 | total=False,
146 | ):
147 | """Type annotations for setting dictionary values.
148 |
149 | :Usage:
150 | setting: Setting = self.tab_dictionary[tab_id]["LabelFrames"][label_frame_id]["SettingFrames"][frame_id][setting_id]
151 | """
152 |
153 | browse: Browse
154 | choices: str | list[Literal["Browse...", "Manual..."] | str]
155 | colorValueType: ColorType
156 | custom_function: str
157 | customWidth: IntStr
158 | delimiter: Literal["x"] | None
159 | dependentSettings: dict[str, DependentSetting]
160 | entry_width: IntStr
161 | fileFormat: Literal["directory", "file"] | None
162 | forceSelect: IntStr | None
163 | formula: str | None
164 | increment: IntStr
165 | label_frame_id: LabelFrameId
166 | label_frame_name: str
167 | length: IntStr
168 | Name: str
169 | Offvalue: ValueList
170 | Onvalue: ValueList
171 | partial: list[str] | None
172 | rgbType: Literal["multiple settings"] | None
173 | second_tk_widget: ttk.Spinbox | None
174 | setting_frame_id: SettingFrameId
175 | setting_id: SettingId
176 | settingChoices: dict[str, list[str]] | None
177 | settings: list[str]
178 | tab_id: TabId
179 | targetINIs: list[ININame]
180 | targetSections: list[str]
181 | tk_var: tk.StringVar
182 | tk_widget: "ttk.Checkbutton | tk.Button | ttk.Button | ttk.Combobox | ttk.Entry | ttk.OptionMenu | ttk.Radiobutton | Scalar | ttk.Spinbox"
183 | TkCheckbutton: ttk.Checkbutton
184 | TkColor: tk.Button
185 | TkCombobox: ttk.Combobox
186 | TkDescriptionLabel: ttk.Label
187 | TkEntry: ttk.Entry
188 | TkFinalSettingFrame: ttk.Frame
189 | TkLabel: ttk.Label
190 | TkOptionMenu: ttk.OptionMenu
191 | TkPresetButton: ttk.Button
192 | TkRadioPreset: ttk.Radiobutton
193 | TkSlider: "Scalar"
194 | TkSpinbox: ttk.Spinbox
195 | to: IntStr
196 | tooltip_wrap_length: int
197 | tooltip_wrap_length: int
198 | tooltip: str
199 | type: SettingType
200 | validate: ValidationType | str
201 | value: str
202 | valueSet: bool
203 | widget_id: WidgetId
204 | width: IntStr
205 |
206 |
207 | class PackSettings(TypedDict):
208 | Anchor: TkAnchor
209 | Expand: Literal[0, 1] | bool
210 | Fill: TkFill
211 | Side: TkSide
212 |
213 |
214 | class SettingsLabelFrame(TypedDict, total=False):
215 | Name: str
216 | NumberOfVerticallyStackedSettings: IntStr
217 | Pack: PackSettings
218 | Settings: dict[SettingId, BethiniSetting]
219 | SettingFrames: dict[SettingFrameId, dict[SettingId, BethiniSetting]]
220 | TkLabelFrame: ttk.Labelframe | ttk.Frame
221 |
222 |
223 | class DisplayTab(TypedDict, total=False):
224 | Name: (
225 | Literal[
226 | "Setup",
227 | "Preferences",
228 | "Basic",
229 | "General",
230 | "Gameplay",
231 | "Interface",
232 | "Environment",
233 | "Shadows",
234 | "Visuals",
235 | "View Distance",
236 | "Advanced",
237 | "Log"
238 | ]
239 | | str
240 | )
241 | SetupWindow: tk.BaseWidget
242 | TkFrameForTab: ttk.Frame
243 | TkPhotoImageForTab: tk.PhotoImage
244 | LabelFrames: dict[LabelFrameId, SettingsLabelFrame]
245 | PreferencesWindow: ttk.Toplevel
246 | NoLabelFrame: SettingsLabelFrame
247 |
248 |
249 | class AppBethiniJSON(TypedDict):
250 | customFunctions: dict[str, str]
251 | Default: Literal[""]
252 | displayTabs: dict[str, DisplayTab]
253 | INIs: dict[ININame, str]
254 | INI_pecking_order: dict[ININame, list[str]]
255 | presetsIgnoreTheseSettings: list[str]
256 | valueTypes: list[PresetName]
257 |
258 |
259 | class AppSettingsJSON(TypedDict):
260 | gameId: str
261 | gameName: str
262 | iniPaths: list[str]
263 | iniValues: list[GameSettingInfo]
264 | presetPaths: list[str]
265 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Pillow>=11.1.0
2 | simpleeval>=1.0.3
3 | git+https://github.com/DoubleYouC/ttkbootstrap.git
--------------------------------------------------------------------------------