├── .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 | ![](../../assets/dialogs/querybox-get-color.png) 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 --------------------------------------------------------------------------------