diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..57034b6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.* +build +dist +__pycache__ +distribution/arch diff --git a/.github/workflows/build-for-pypi.yml b/.github/workflows/build-for-pypi.yml new file mode 100644 index 0000000..487825d --- /dev/null +++ b/.github/workflows/build-for-pypi.yml @@ -0,0 +1,25 @@ +name: Build Wheel and Publish to PyPI + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag name for this release (e.g. v1.2.3)" + required: true + +jobs: + build_and_publish: + runs-on: ubuntu-latest + permissions: + it-token: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag }} + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + - run: python3 -m pip install build + - run: python3 -m build # creates dist/* + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index a65bd1f..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Build Python Distribution - -on: push - -jobs: - build_release: - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest] - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: "3.12" - - run: python3 -m pip install virtualenv cx-Freeze==8.0.0 - - run: python3 -m virtualenv venv - - run: .\venv\Scripts\activate.bat - - run: pip install . - - run: cxfreeze build_exe - - uses: actions/upload-artifact@v4 - with: - name: build_release - path: build/exe.win*/ - - release: - if: startsWith(github.ref, 'refs/tags/v') - needs: [ build_release ] - name: Create release - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Download - uses: actions/download-artifact@v4 - with: - name: build_release - path: . - - name: Bundle App - run: | - cd exe.win* - zip -r SETS.zip . - mv SETS.zip .. - cd .. - - name: Create release - uses: ncipollo/release-action@v1 - with: - artifacts: SETS.zip diff --git a/.gitignore b/.gitignore index 0449b53..e354e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ images/ ..debug/ __pycache__/ +dist/ +build/ *.py[cod] -.SETS_settings.ini +*.spec diff --git a/INSTALLATION.md b/INSTALLATION.md deleted file mode 100644 index b4cebb4..0000000 --- a/INSTALLATION.md +++ /dev/null @@ -1,121 +0,0 @@ -# SETS - STO Equipment and Trait Selector -A Star Trek Online build tool in Python -**Website: https://stobuilds.com/apps/sets** -**GitHub: https://github.com/STOCD/SETS** - -## Manual installation -### Windows -Please refer to the manual installation guide on the website: https://stobuilds.com/apps/sets/installation - diff --git a/README.md b/README.md index 30440a7..b694b26 100644 --- a/README.md +++ b/README.md @@ -7,64 +7,37 @@ Builds can be exported to a PNG or JSON file that can be opened by another perso ## Installation ### Executable for Windows -Download the zipped app from the [release](https://github.com/STOCD/SETS/releases) page. Unzip it into a folder where you want your app to live. To speed up the image downloading process, obtain the images library as detailed below (Images library section). Double-clicking `SETS.exe` will start the app. +Download the latest app installer from the [release](https://github.com/STOCD/SETS/releases) page. Execute the installer and follow its instructions to install the app. -You can create a desktop shortcut by rightclicking on `SETS.exe` and clicking on "Create shortcut" in the context menu. Then move the created shortcut to your desktop. To create a start menu entry, open the start menu folder by rightclicking on an arbitrary app in your start menu and clicking on "Open file location". Then move the created shortcut to the folder that opened. +### Executable for Arch +Download the latest package from the [release](https://github.com/STOCD/SETS/releases) page. Run `sudo pacman -U /path/to/package-file` to install the app. -### Script (UNIX-like systems) -*Before installation, make sure python 3 is installed on your system alongside the python package manager pip.* - -First, create a folder to house your app and open it in your file manager or shell. - -Download the source code. This can be done using `git` or manual download: -- Manual Download: On the GitHub page of [this repository](https://github.com/STOCD/SETS), click on the green `CODE` button and select "Download ZIP". Save the archive and unpack it so that the files and folders seen on the repository page are *directly* inside your app folder. -- Git: run `git clone https://github.com/STOCD/SETS.git .` - -Run the `install.sh` script by double-clicking it in your file manager or running `./install.sh` in your shell. If you cannot run the file, make sure it is executable using your file manager or the command `chmod +x install.sh`. - -To speed up the image download process on first start of the app, download the latest image archive from [releases](https://github.com/STOCD/SETS/releases). Create a `.config` folder and unpack the images archive into it. The images should be in `/.config/images/`. +### Executable for Debian +Download the latest package from the [release](https://github.com/STOCD/SETS/releases) page. Run `sudo apt install -f /path/to/package-file` to install the app. -Start the app by running the `run.sh` file by double-clicking it in your file manager or running `.run.sh` in your shell. If you cannot run the file, make sure it is executable using your file manager or the command `chmod +x run.sh`. +### Script version using PIP +Install the app globally or in a python virtual environment by running `python -m pip install sets`. Start the app using the `sets` command. If the app is installed in a python virtual environment, make sure to activate it before trying to start the app. +### Script (all systems; development version) +*Before installation, make sure python 3 is installed on your system alongside the python package manager pip.* -### Script (Cross-Platform) -*The commands below are for Windows and require a version of python 3 to be installed. If you want to install the app on Linux, use `python3` instead of `python`. A more comprehensive guide for installing the script version can be found on the [website](https://stobuilds.com/apps/sets/installation).* - -First, create a folder to house your app. Open a command prompt and navigate *inside* the created folder. +First, create a folder to house your app. Open a command prompt or shell and navigate *inside* the created folder. Download the source code. This can be done using `git` or manual download: - Manual Download: On the GitHub page of [this repository](https://github.com/STOCD/SETS), click on the green `CODE` button and select "Download ZIP". Save the archive and unpack it so that the files and folders seen on the repository page are *directly* inside your app folder. - Git: run `git clone https://github.com/STOCD/SETS.git .` -Install dependencies by running `python -m pip install .`. - -To speed up the image download process on first start of the app, download the latest image archive from [releases](https://github.com/STOCD/SETS/releases). Create a `.config` folder and unpack the images archive into it. The images should be in `\.config\images\`. - *Ubuntu* users might need to install the `libxcb-cursor0` package for this app to work: `sudo apt install libxcb-cursor0` -To run the app, navigate to your apps folder. Then: -- Windows: Use `python main.py` to start the app. -- Linux: Use `python3 main.py` to start the app. - -### Images library -All installation methods require an images library containing the game icons. The app will download these automatically, but as this takes a very long time, it is recommended to download the newest compressed image library from the [release](https://github.com/STOCD/SETS/releases) page. Once downloaded this has to be decompressed and placed in into the `.config/images` folder. You might need to create a folder with the name `.config` manually. The folder structure should look like below: -``` -SETS - +- .config - | `- images - | `- - +- SETS.exe / main.py - +- ... -``` +On UNIX systems (or using a compatible shell), run the `install.sh` script by double-clicking it in your file manager or running `./install.sh` in your shell. If you cannot run the file, make sure it is executable using your file manager or the command `chmod +x install.sh`. After that, the app can be started by running `run.sh` either from your file explorer or shell. If you cannot run the file, make sure it is executable using your file manager or the command `chmod +x run.sh`. -## Updating the app -### Executable for Windows -Navigate to your SETS folder and delete all files and folders **except** the `.config` folder and the `.SETS_settings.ini` file. Download the newest version from the [release](https://github.com/STOCD/SETS/releases) page and unpack it into the SETS folder to replace the files and folders deleted before. +If you cannot use the installer script, continue by installing dependencies using `python -m pip install .`. -### Script (Cross-Platform) -When using Git, open a command line at the location of your SETS folder and type `git pull` to get the newest version of the app. +To run the app, open a shell inside the app folder and run `python main.py --config-dir ./sets-config`. -Otherwise, navigate to your SETS folder and delete all files and folders **except** the `.config` folder and the `.SETS_settings.ini` file. Also keep your *virtual environment* folder in place if you used a virtual environment to install dependencies. Download the app into the same folder as detailed in the installation section above. Open a command line at the location of your SETS folder and update dependencies by running `python -m pip install .`. + +## Updating the app +All versions of this app can be updated by following the installation steps detailed above. The existing installation will be replaced automatically, preserving settings and other configuration. ## Contributing If you find any information or images missing, please check or update the [official wiki](https://stowiki.net) -- where SETS gets this information. You can report wiki issues on the [Star Trek Online Community Discord Server](https://discord.gg/eApUvTRr5q) in the "#wiki-discussion" channel or on the [STOBuilds Discord Server](https://discord.gg/kxwHxbsqzF) in the "#wiki-update-talk" channel. diff --git a/distribution/README.md b/distribution/README.md new file mode 100644 index 0000000..a5642c0 --- /dev/null +++ b/distribution/README.md @@ -0,0 +1,71 @@ +# Development Notes + +These are currently reflecting the state of the app, but are subject ot change. + +## Windows +- [Currently Testing With Inno Setup](https://jrsoftware.org/isinfo.php) + - [SETS.iss](./SETS.iss) + +## Linux + +#### System Wide Paths +- /opt/sets/ + - This is the ideal loation for Linux, all assets are located here. +- /usr/bin/sets -> /opt/sets/SETS + - shell script to execute the binary from a PATH location +- /usr/share/applications/sets.desktop + - The `.desktop` entry that registers the application. +- /usr/share/icons/hicolor/256x256/apps/sets.png + - Location for the application icon, referred to in the `.desktop` entry. + +#### Config Paths +1. If `$XDG_CONFIG_HOME` is set, takes priority. Is supposed to be a directory. Don't use if it is a file. Don't create it if it doesn't exist. +2. `$HOME/.config` if the `.config` folder exists, but do not create it if it does not. +3. [`$HOME/.` if you can keep the settings in one file (and the dot here is important)] -> not used, because SETS requires a folder +4. ` $HOME/.sets/` + +##### .desktop entry template +``` +[Desktop Entry] +Type=Application +Name=SETS +Comment=STO Equipment and Trait Selector +Icon=/usr/share/icons/hicolor/256x256/apps/sets.png +Exec=/opt/sets/SETS +Terminal=False +Categories=Utility;GameTool; +StartupWMClass=STO Equipment and Trait Selector +``` + +#### Debian `.deb` Package Approach + +```bash +apt install -f ./sets---x86_64.deb +``` +- Unpacks the contents to `/opt/sets`, `/usr/share/applications/sets.desktop`, etc +- Automatically registers the package manager metadata with `dpkg` so the system becomes aware which files belong to which package + - This is the biggest advantage. +- Allows uninstallation by name (apt remove sets), because it recorded which files it put there. + - Does not remove settings. +- uses `-f` flag to install dependencies + +To check whether it was installed correctly: `apt list --installed sets` + +Use `dpkg -L sets` to list registered files for SETS. + + +#### Arch `PKGBUILD` File Structure +###### Note: to public to AUR we need a PKGBUILD that builds from source + +On Arch there is similar tools, but there is no standard format like `.deb`, rather it's usually just a compressed archive `sets.pkg.tar.zst` by a `PKGBUILD` script, which can then be fed into `pacman`/`paru`/`yay`. + +Run `makepkg` from the directory that contains the `PKGBUILD` file and that spits out the `sets---x86_64.pkg.tar.zst` file, which can then be installed via pacman: + +``` +sudo pacman -U ./sets---x86_64.pkg.tar.zst +``` + +And `pacman -Qs sets` to confirm that it is registered, just as a sanity check. + +For debugging `pacman -Ql sets` lists where everything registers under that package is located (the assets and `.desktop` files, to ensure the `PKGBUILD` file was defined properly) + diff --git a/distribution/SETS.iss b/distribution/SETS.iss new file mode 100644 index 0000000..4928191 --- /dev/null +++ b/distribution/SETS.iss @@ -0,0 +1,194 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "SETS" +#define MyAppVersion "3.0.0" +#define MyAppPublisher "STOCD" +#define MyAppURL "https://github.com/STOCD/SETS" +#define MyAppExeName "SETS.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{A0137D88-47DD-4D9D-8904-8CA92CD0D3B3} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} +; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run +; on anything but x64 and Windows 11 on Arm. +ArchitecturesAllowed=x64compatible +; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the +; install be done in "64-bit mode" on x64 or Windows 11 on Arm, +; meaning it should use the native 64-bit Program Files directory and +; the 64-bit view of the registry. +ArchitecturesInstallIn64BitMode=x64compatible +DisableProgramGroupPage=yes +OutputDir=..\dist\{#MyAppName} +OutputBaseFilename=SETS-Installer_{#MyAppVersion} +SetupIconFile=..\local\sets_icon_small.ico +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "..\dist\{#MyAppName}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\dist\{#MyAppName}\_internal\*"; DestDir: "{app}\_internal"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[Code] +var + checkbox: TNewCheckBox; + UninstallFirstPage: TNewNotebookPage; + configPath: string; + +function GetConfigPath(): string; +var + envAppdata: string; + envProf: string; +begin + envAppdata := GetEnv('APPDATA'); + envProf := GetEnv('USERPROFILE'); + if DirExists(envAppdata + '\SETS') and FileExists(envAppdata + '\SETS\SETS_settings.ini') then + begin + Result := envAppdata + '\SETS'; + end + else if DirExists(envProf + '\SETS') and FileExists(envProf + '\SETS\SETS_settings.ini') then + begin + Result := envProf + '\SETS'; + end + else + begin + Result := ''; + end; +end; + +procedure UpdateUninstallWizard; +begin + if UninstallProgressForm.InnerNotebook.ActivePage = UninstallFirstPage then + begin + UninstallProgressForm.PageNameLabel.Caption := 'Uninstall SETS'; + UninstallProgressForm.PageDescriptionLabel.Caption := 'Preparing to remove SETS from your device.'; + end; +end; + + +procedure InitializeUninstallProgressForm(); +var + UninstallButton: TNewButton; + infotext: TNewStaticText; + CancelButtonEnabled: Boolean; + CancelButtonModalResult: Integer; + PageNameLabel: string; + PageDescriptionLabel: string; +begin + if not UninstallSilent then + begin + PageNameLabel := UninstallProgressForm.PageNameLabel.Caption; + PageDescriptionLabel := UninstallProgressForm.PageDescriptionLabel.Caption; + + UninstallFirstPage := TNewNotebookPage.Create(UninstallProgressForm); + UninstallFirstPage.Notebook := UninstallProgressForm.InnerNotebook; + UninstallFirstPage.Parent := UninstallProgressForm.InnerNotebook; + UninstallFirstPage.Align := alClient; + + infotext := TNewStaticText.Create(UninstallProgressForm); + infotext.Parent := UninstallFirstPage; + infotext.Top := UninstallProgressForm.StatusLabel.Top; + infotext.Left := UninstallProgressForm.PageNameLabel.Left; + infotext.AutoSize := True; + infotext.WordWrap := True; + infotext.Width := UninstallProgressForm.PageNameLabel.Width; + + checkbox := TNewCheckBox.Create(UninstallProgressForm); + checkbox.Caption := 'Delete App Configuration (including default library!)'; + checkbox.Checked := True; + checkbox.Parent := UninstallFirstPage; + checkbox.Left := UninstallProgressForm.StatusLabel.Left; + checkbox.Width := UninstallProgressForm.StatusLabel.Width; + + configPath := GetConfigPath(); + if configPath = '' then + begin + checkbox.Checked := False; + checkbox.Enabled := False; + infotext.Caption := 'No app configuration found. Press Uninstall to proceed with uninstallation.'; + end + else + begin + infotext.Caption := 'App configuration found in following location:'#13 + configPath; + end; + + infotext.AdjustHeight; + checkbox.Top := infotext.Top + infotext.Height + ScaleY(8); + + UninstallButton := TNewButton.Create(UninstallProgressForm); + UninstallButton.Parent := UninstallProgressForm; + UninstallButton.Left := + UninstallProgressForm.CancelButton.Left - + UninstallProgressForm.CancelButton.Width - + ScaleX(10); + UninstallButton.Top := UninstallProgressForm.CancelButton.Top; + UninstallButton.Width := UninstallProgressForm.CancelButton.Width; + UninstallButton.Height := UninstallProgressForm.CancelButton.Height; + UninstallButton.ModalResult := mrOK; + UninstallButton.Caption := 'Uninstall'; + UninstallButton.TabOrder := UninstallProgressForm.CancelButton.TabOrder; + UninstallButton.Default := True; + UninstallProgressForm.CancelButton.TabOrder := UninstallButton.TabOrder + 1; + + UninstallProgressForm.InnerNotebook.ActivePage := UninstallFirstPage; + + UpdateUninstallWizard; + CancelButtonEnabled := UninstallProgressForm.CancelButton.Enabled + UninstallProgressForm.CancelButton.Enabled := True; + CancelButtonModalResult := UninstallProgressForm.CancelButton.ModalResult; + UninstallProgressForm.CancelButton.ModalResult := mrCancel; + if UninstallProgressForm.ShowModal = mrCancel then Abort; + + UninstallButton.Visible := False; + UninstallProgressForm.CancelButton.Enabled := CancelButtonEnabled; + UninstallProgressForm.CancelButton.ModalResult := CancelButtonModalResult; + + UninstallProgressForm.PageNameLabel.Caption := PageNameLabel; + UninstallProgressForm.PageDescriptionLabel.Caption := PageDescriptionLabel; + + UninstallProgressForm.InnerNotebook.ActivePage := UninstallProgressForm.InstallingPage; + end; +end; + +procedure removeConfig(); +begin + if DirExists(configPath) then + begin + DelTree(configPath, True, True, True); + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usPostUninstall then + begin + if checkbox.Checked then + begin + removeConfig(); + end; + end; +end; diff --git a/distribution/arch/PKGBUILD b/distribution/arch/PKGBUILD new file mode 100644 index 0000000..931f454 --- /dev/null +++ b/distribution/arch/PKGBUILD @@ -0,0 +1,65 @@ +# Maintainer: Shinga +pkgname=sets +pkgver=3.0.0 +pkgrel=1 +pkgdesc="SETS - STO Equipment and Trait Selector" +arch=('x86_64') +url="https://github.com/STOCD/SETS" +license=('GPL-3.0') +depends=('python') +makedepends=('python' 'python-virtualenv' 'python-wheel' 'python-setuptools' 'git' 'unzip') +source=("https://github.com/STOCD/SETS/archive/refs/tags/v${pkgver}.tar.gz") +sha256sums=('') + +build() { + cd "$srcdir" + + srcdir_name="${srcdir}/SETS-${pkgver#v}" + cd "$srcdir_name" || return 1 + + mkdir -p "$srcdir_name/build-venv" + python -m venv build-venv + source build-venv/bin/activate + + # Upgrade pip then install exact packages used in CI + pip install --upgrade pip + pip install -e ".[pyinst]" + + # Run pyinstaller from the venv + # ensure we run pyinstaller in project root and output to dist/ + pyinstaller --name SETS --onedir main.py \ + --add-data "local:local" \ + --icon "local/SETS_icon_small.png" \ + --windowed + + deactivate +} + +package() { + cd "$srcdir" + + srcdir_name="SETS-${pkgver#v}" + + install -d "${pkgdir}/opt/sets" + install -d "${pkgdir}/usr/bin" + install -d "${pkgdir}/usr/share/applications" + install -d "${pkgdir}/usr/share/icons/hicolor/256x256/apps" + + cp -r "${srcdir}/${srcdir_name}/dist/SETS/_internal" "${pkgdir}/opt/sets/" + cp "${srcdir}/${srcdir_name}/dist/SETS/SETS" "${pkgdir}/opt/sets/" + + cat > "${pkgdir}/usr/bin/sets" <<'EOF' +#!/bin/sh +exec /opt/sets/SETS "$@" +EOF + + chmod 755 "${pkgdir}/usr/bin/sets" + + # Install desktop file + cp "${srcdir}/${srcdir_name}/distribution/sets.desktop" "${pkgdir}/usr/share/applications/sets.desktop" + + # Install an icon into the icon theme + if [ -f "${pkgdir}/opt/sets/_internal/local/SETS_icon_small.png" ]; then + install -Dm644 "${pkgdir}/opt/sets/_internal/local/SETS_icon_small.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/sets.png" + fi +} diff --git a/distribution/arch/README.md b/distribution/arch/README.md new file mode 100644 index 0000000..8154f1b --- /dev/null +++ b/distribution/arch/README.md @@ -0,0 +1,21 @@ +# PKGBUILD / makepkg / AUR / Arch Build Reference + +I plan to include more information regarding the AUR here as well at a later date. +For now this should serve as a quick reference/mini-instruction manual. + +- `makepkg` in isolation, will create + - sets---x86_64.pkg.tar.zst + - `v3.0.0.tar.gz` + - It pulls the source code from this tag (which I had to create ahead of time so as not to compile the older version), though whenever new code is introduced, that code will not be part of the tag unless updated, or a new tag is made. If it is updated, then the SHA256 hash of the tarball will change as well. So you must compute it again with `sha256sum v3.0.0` and replace the old hash with the new one inside the PKGBUILD. + - Whenever there is a new tag, the PyPi package should be updated as well, by running the one and only workflow that is still useful. + - `./src` + - This is the directory it will extract the tarball into. This is effectively a working directory for makepkg, in order to be able to compile/build what it needs to build first, before being able to package it. + - `./pkg` + - This too is a working directory; it is what the `.pkg.tar.zst` file contains. Once `src` is built, the relevant files are copied into the `pkg` folder, within which, there a directory structure that mirrors the Linux Filesystem Hierarchy. It will grab what it needs to grab from `src` and possibly other sources, place it in the `pkg` folder in the subdirectory that corresponds to the actual directory where those files will get placed when the package is actually installed through `pacman` + + +- `makepkg -si` will do the above, but `-i/--install` actually installs the package after it's done constructing it, and `--s/--syncdeps` will also install missing dependencies if there are any (system-wide dependencies that are not managed by us, e.g. `libssl` and the like. This is convention when installing with `-i` to mimic the behavior of `pacman` when installing. + + +- `pacman -U package.pkg.tar.zst` will install the package and register it with `pacman`, fetching any required dependencies that it can resolve along the way. + - When in doubt/sanity check, `pacman -Sy` to ensure `pacman`'s package index is up to date. diff --git a/distribution/build_pyinstaller.sh b/distribution/build_pyinstaller.sh new file mode 100755 index 0000000..11d9a3c --- /dev/null +++ b/distribution/build_pyinstaller.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -e +if [ ! -d distribution ] || [ ! -f local/SETS_icon_small.png ] +then + echo "[Error] Start this script from the base folder of the application" + exit +fi + +echo "[Info] Checking for existing venv \".venv\"" +if [ ! -d ".venv" ] +then + echo "[Info] No venv found. Creating venv \".venv\"..." + python3 -m venv .venv +fi + +echo "[Info] Activating venv." +. ".venv/bin/activate" + +echo "[Info] Installing (build) dependencies." +python3 -m pip install --upgrade pip setuptools wheel +python3 -m pip install -e ".[pyinst]" + +echo "[Info] Creating binary app." +pyinstaller --noconfirm --clean --onedir --name SETS main.py \ + --add-data local:local --windowed \ + --icon local/SETS_icon_small.png + +echo "[Info] Leaving venv." +deactivate diff --git a/distribution/debian/DEBIAN/control b/distribution/debian/DEBIAN/control new file mode 100644 index 0000000..588d07c --- /dev/null +++ b/distribution/debian/DEBIAN/control @@ -0,0 +1,8 @@ +Package: sets +Maintainer: Shinga +Architecture: all +Version: 3.0.0 +Section: misc +Priority: optional +Standards-Version: 4.7.2 +Description: STO Equipment and Trait Selector diff --git a/distribution/debian/README.md b/distribution/debian/README.md new file mode 100644 index 0000000..d0bded0 --- /dev/null +++ b/distribution/debian/README.md @@ -0,0 +1,31 @@ +# debian-package / dpkg Build Reference + +## Build Preperation +Before building, make sure to complete the following steps: +- Update the app version in `DEBIAN/control`. +- Add an entry to the changelog file `changelog`. + +## Building +To build and package the app for debian-based distros using docker, run +`docker compose -f distribution/debian/build_deb.compose.yaml up` from the base directory of the +project. This will create the .deb package and put it into the `dist/` folder. + +To build and package the app for debian-based distros while running a debian-based machine: +- From the base directory of the project, run `distribution/build_pyinstaller.sh` to build the +binary. +- From the base directory of the project, run `distribution/debian/package_deb.sh` to package the +app. The result will be located in the `dist/` folder. + +To build and package the app for debian-based distros using docker: +- From the base directory of the project, run +`docker compose -f distribution/debian/build_deb.compose.yaml up -d`. The result will be in the +`dist/` folder. + +## File reference +*All files relative to the `distribution/debian` folder.* +- `DEBIAN/control`: Defines the debian package and tells dpkg what to do. Will be included as is in +the package. +- `copyright`: Contains copyright information for the project. Required by dpkg and will be included +as is in the package. +- `changelog`: Contains changelog information for the project. Required by dpkg and will be included +as is in the package. diff --git a/distribution/debian/build_deb.Dockerfile b/distribution/debian/build_deb.Dockerfile new file mode 100644 index 0000000..7f68f9b --- /dev/null +++ b/distribution/debian/build_deb.Dockerfile @@ -0,0 +1,5 @@ +FROM deb_build_base + +RUN mkdir /build +COPY ./ /build/ +WORKDIR /build diff --git a/distribution/debian/build_deb.compose.yaml b/distribution/debian/build_deb.compose.yaml new file mode 100644 index 0000000..a1feec7 --- /dev/null +++ b/distribution/debian/build_deb.compose.yaml @@ -0,0 +1,31 @@ +name: sets_deb_build + +services: + deb_build_base: + image: deb_build_base + build: + context: ../.. + dockerfile: distribution/debian/deb_build_base.Dockerfile + + sets_deb_build: + depends_on: + deb_build_base: + condition: service_completed_successfully + image: setsdeb + build: + context: ../.. + dockerfile: distribution/debian/build_deb.Dockerfile + container_name: running_sets_deb_build + volumes: + - type: bind + source: ../../dist/ + target: /mnt/ + read_only: false + command: + - /bin/sh + - -c + - | + cd /build + distribution/build_pyinstaller.sh + distribution/debian/package_deb.sh + cp dist/sets.deb /mnt/sets.deb diff --git a/distribution/debian/changelog b/distribution/debian/changelog new file mode 100644 index 0000000..66a0ce6 --- /dev/null +++ b/distribution/debian/changelog @@ -0,0 +1,5 @@ +sets (3.0.0-1) stable; urgency=low + + * Initial release. + + -- Shinga Thu, 27 Nov 2025 13:12:55 +0100 diff --git a/distribution/debian/copyright b/distribution/debian/copyright new file mode 100644 index 0000000..dca4a1d --- /dev/null +++ b/distribution/debian/copyright @@ -0,0 +1,6 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: * +Copyright: 2025 STOCD +License: GPL-3 /usr/share/common-licenses/GPL-3 + diff --git a/distribution/debian/deb_build_base.Dockerfile b/distribution/debian/deb_build_base.Dockerfile new file mode 100644 index 0000000..aeb3e88 --- /dev/null +++ b/distribution/debian/deb_build_base.Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.14-trixie AS deb_build_base + +RUN apt-get update +RUN apt-get install -y binutils +RUN apt-get install -y libopencv-dev +RUN apt-get install -y python3-opencv +RUN apt-get install -y libxcb-cursor0 diff --git a/distribution/debian/package_deb.sh b/distribution/debian/package_deb.sh new file mode 100755 index 0000000..eec41ad --- /dev/null +++ b/distribution/debian/package_deb.sh @@ -0,0 +1,62 @@ +#!/bin/sh +set -e +if [ ! -d distribution ] +then + echo "[Error] Start this script from the base folder of the application." + exit +fi +if [ ! -d dist ] || [ ! -d "dist/SETS" ] +then + echo "[Error] Build the app before attempting to package it." + exit +fi + +echo "[Info] Creating .deb Package..." +PKGDIR="dist/deb-pkg" +PKGNAME="sets" + +echo "[Info] Creating temporary folder for packaging \"${PKGDIR}/\"." +mkdir -p "${PKGDIR}" + +echo "[Info] Cleaning \"${PKGDIR}/\" folder." +rm -rf "${PKGDIR}"/* + +echo "[Info] Copying base structure." +cp -r "distribution/debian/DEBIAN" "${PKGDIR}" +echo "[Info] Copying copyright information." +mkdir -p "${PKGDIR}/usr/share/doc/${PKGNAME}" +cp "distribution/debian/copyright" "${PKGDIR}/usr/share/doc/${PKGNAME}/copyright" +echo "[Info] Copying changelog." +cp "distribution/debian/changelog" "${PKGDIR}/usr/share/doc/${PKGNAME}/changelog" +gzip -9 "${PKGDIR}/usr/share/doc/${PKGNAME}/changelog" + +echo "[Info] Copying app." +mkdir -p "${PKGDIR}/opt/${PKGNAME}" +cp -r "dist/SETS"/* "${PKGDIR}/opt/${PKGNAME}/" + +echo "[Info] Linking app binary." +mkdir -p "${PKGDIR}/usr/bin" +LAUNCHCOMMAND="\"/opt/${PKGNAME}/SETS\" \"\$@\"" +cat > "${PKGDIR}/usr/bin/${PKGNAME}" < means bg in this sub-dictionary - 'defaults': { - 'bg': '#1a1a1a', # background - 'mbg': '#242424', # medium background - 'lbg': '#404040', # light background - 'sets': '#c59129', # accent - 'lsets': '#60c59129', # light accent - 'font': ('Overpass', 11, 'normal'), - 'heading': ('Overpass', 14, 'bold'), - 'subhead': ('Overpass', 12, 'medium'), - 'small_text': ('Overpass', 10, 'normal'), - 'fg': '#eeeeee', # foreground (usually text) - 'mfg': '#bbbbbb', # medium foreground - 'bc': '#888888', # border color - 'bw': 1, # border width - 'br': 2, # border radius - 'sep': 2, # seperator -> width of major seperating lines - 'margin': 10, # default margin between widgets - 'csp': 5, # child spacing -> content margin - 'isp': 15, # item spacing - }, - # dark frame - 'frame': { - 'background-color': '@bg', - 'border-style': 'none', - 'margin': 0, - 'padding': 0 - }, - # medium frame - 'medium_frame': { - 'background-color': '@mbg', - 'margin': 0, - 'padding': 0 - }, - # light frame - 'light_frame': { - 'background': '@lbg', - 'margin': 0, - 'padding': 0 - }, - # default text (non-button, non-entry, non table) - 'label': { - 'color': '@fg', - 'margin': (3, 0, 3, 0), - 'qproperty-indent': '0', # disables auto-indent - 'border-style': 'none', - 'font': '@font' - }, - # default text (non-button, non-entry, non table) - 'hint_label': { - 'color': '@mfg', - 'margin': (3, 0, 3, 0), - 'qproperty-indent': '0', # disables auto-indent - 'border-style': 'none', - 'font': '@font' - }, - # heading label - 'label_heading': { - 'color': '@fg', - 'qproperty-indent': '0', - 'border-style': 'none', - 'font': '@heading' - }, - # label for subheading - 'label_subhead': { - 'color': '@fg', - 'qproperty-indent': '0', - 'border-style': 'none', - 'margin-bottom': 3, - 'font': '@subhead' - }, - # default button - 'button': { - 'background-color': 'none', - 'color': '@fg', - 'text-decoration': 'none', - 'border-width': '@bw', - 'border-style': 'solid', - 'border-color': '@sets', - 'margin': (3, 3, 3, 3), - 'padding': (2, 5, 0, 5), - 'font': ('Overpass', 13, 'medium'), - ':hover': { - 'border-color': '@bc' - }, - ':disabled': { - 'color': '@bc' - }, - # Tooltip - '~QToolTip': { - 'background-color': '@mbg', - 'border-style': 'solid', - 'border-color': '@lbg', - 'border-width': '@bw', - 'padding': (0, 0, 0, 0), - 'color': '@fg', - 'font': 'Overpass' - } - }, - # heavy button - 'heavy_button': { - 'background-color': '@sets', - 'color': '@fg', - 'text-decoration': 'none', - 'border-width': '@bw', - 'border-style': 'solid', - 'border-color': '@sets', - 'margin': (3, 3, 3, 3), - 'padding': (2, 5, 0, 5), - 'font': ('Overpass', 13, 'bold'), - ':hover': { - 'background-color': '@mbg' - }, - ':disabled': { - 'color': '@bc' - } - }, - # build item button - 'item': { - 'background-color': '#242424', - 'border-width': 1, - 'border-color': '#888888', - 'border-highlight-color': '#ffd700' - }, - # build item button - 'item_dark': { - 'background-color': '#1a1a1a', - 'border-width': 1, - 'border-color': '#404040', - }, - # checkbox - 'checkbox': { - '::indicator': { - 'width': 16, - 'height': 16, - 'border-style': 'solid', - 'border-width': '@bw', - 'border-color': '@bc', - 'background-color': '@lbg', - }, - '::indicator:hover': { - 'border-color': '@sets' - }, - '::indicator:checked': { - 'image': 'url(local/check.svg)' - }, - '::indicator:unchecked': { - 'image': 'url(local/uncheck.svg)', - } - }, - # holds sub-pages - 'tabber': { - 'background-color': 'none', - 'border': 'none', - 'margin': 0, - 'padding': 0, - '::pane': { - 'border': 'none', - } - }, - # default tabber buttons (hidden) - 'tabber_tab': { - '::tab': { - 'height': 0, - 'width': 0 - } - }, - # combo box - 'combobox': { - 'border-style': 'solid', - 'border-width': '@bw', - 'border-color': '@bc', - 'background-color': '@bg', - 'padding': (1, 5, 1, 5), - 'color': '@fg', - 'font': '@subhead', - '::down-arrow': { - 'image': 'url(local/thick-chevron-down.svg)', - 'width': '@margin', - }, - '::drop-down': { - 'border-style': 'none', - 'padding': (2, 2, 2, 2) - }, - '~QAbstractItemView': { - 'background-color': '@mbg', - 'border-style': 'solid', - 'border-color': '@bc', - 'border-width': '@bw', - 'border-radius': '@br', - 'color': '@fg', - 'outline': '0', - '::item': { - 'border-width': '@bw', - 'border-style': 'solid', - 'border-color': '@mbg', - }, - '::item:hover': { - 'border-color': '@sets', - }, - } - }, - # additional style for doff combobox - 'doff_combo': { - 'color': '@fg', - 'border-style': 'none', - 'border-width': 0, - 'margin': 0, - 'font': '@small_text' - }, - # additional style for boff combobox - 'boff_combo': { - 'font': '@font', - ':disabled': { - 'border-color': '@bg', - 'border-left-width': 0, - 'padding-left': 0 - }, - '::down-arrow:disabled': { - 'image': 'none', - 'width': '@margin', - }, - }, - # auto-completion popup of combobox - 'popup': { - 'background-color': '@mbg', - 'border-style': 'solid', - 'border-color': '@bc', - 'border-width': '@bw', - 'border-radius': '@br', - 'color': '@fg', - 'outline': '0', - '::item': { - 'border-width': '@bw', - 'border-style': 'solid', - 'border-color': '@mbg', - }, - '::item:hover': { - 'border-color': '@sets', - }, - }, - # line of user-editable text - 'entry': { - 'background-color': '@mbg', - 'color': '@fg', - 'border-width': '@bw', - 'border-style': 'solid', - 'border-color': '@bc', - 'font': '@subhead', - 'selection-background-color': '@lsets', - # cursor is inside the line - ':focus': { - 'border-color': '@sets' - }, - ':hover': { - 'background-color': '@lbg' - } - }, - # for item tooltips - 'infobox': { - 'background-color': '#000000', - 'border-style': 'none', - 'color': '@fg', - # 'margin': 0, - # 'padding': 0, - }, - 'infobox_frame': { - 'background-color': '#000000', - 'border-style': 'solid', - 'border-width': '@bw', - 'border-color': '@mbg', - 'border-radius': '@br', - # 'margin': 0, - # 'padding': '@sep', - }, - # tooltip for TooltipLabel - 'label_tooltip': { - 'color': '@fg', - 'background-color': '@bg', - 'border-color': '@lbg', - 'border-radius': '@br', - 'border-style': 'solid', - 'border-width': '@bw', - 'font': '@font', - 'padding': 2, - 'qproperty-indent': '0', # disables auto-indent - }, - # for formatting tooltip text, will contain css from tooltip_def - 'tooltip': {}, - 'tooltip_def': { - 'indent': { - 'margin': (0, 0, 0, 20), - }, - 'ul': { - 'margin': (0, 0, 0, 20), - '-qt-list-indent': '0', - }, - 'li': { - 'margin-bottom': 1, - }, - 'boff_header': { - 'color': '#42afca', - 'font-size': 'large', - 'font-weight': 'bold', - 'margin': 0 - }, - 'boff_subheader': { - 'font-size': 10, - 'margin': (0, 0, 20, 0) - }, - 'trait_header': { - 'color': '#42afca', - 'font-size': 'large', - 'font-weight': 'bold', - 'margin': 0, # padding: 0 - }, - 'trait_subheader': { - 'color': '#42afca', - 'font-size': 10, - 'margin': (0, 0, 20, 0), - }, - 'equipment_name': { - 'font-size': 'large', - 'font-weight': 'bold', - 'margin': 0 - }, - 'equipment_type_subheader': { - 'font-size': 10, - 'margin': (0, 0, 20, 0), - }, - 'equipment_head': { - 'color': '#42afca', - 'font-size': 12, - 'margin': (10, 0, 0, 0) - }, - 'equipment_subhead': { - 'color': '#f4f400', - 'font-size': 10, - 'margin': 0 - }, - 'equipment_who': { - 'color': '#ff6347', - 'font-size': 10, - 'margin': (0, 0, 10, 0) - }, - 'skill_ultimate_name': { - 'color': '#ffd700;', - 'font-size': 12, - 'margin': (10, 0, 0, 0) - }, - }, - # picker window - 'picker': { - 'background-color': '@bg', - 'border-color': '@sets', - 'border-width': 3, - 'border-style': 'solid', - 'border-radius': '@br' - }, - # list widget displaying items in picker - 'picker_list': { - 'background-color': '@bg', - 'color': '@fg', - 'border-style': 'none', - 'margin': 0, - 'font': '@font', - 'outline': '0', # removes dotted line around clicked item - '::item': { - 'border-width': '@bw', - 'border-style': 'solid', - 'border-color': '@bg', - }, - '::item:selected': { - 'background-color': '@bg', - 'border-width': '@bw', - 'border-style': 'solid', - 'border-color': '@bg', - }, - # selected but not the last click of the user - '::item:selected:!active': { - 'color': '@fg' - }, - '::item:hover': { - 'background-color': '@lbg', - }, - '~QScrollBar': { - 'border-style': 'none', - 'border': 'none', - 'border-radius': 0 - } - }, - # large text editor - 'textedit': { - 'background-color': '@mbg', - 'border-style': 'solid', - 'border-width': '@bw', - 'border-color': '@bc', - 'font': '@font', - 'color': '@fg', - 'padding': 3, - 'selection-background-color': '@lsets' - }, - # context menu - 'context_menu': { - 'background-color': '@bg', - 'border-color': '@lbg', - 'border-width': '@bw', - 'border-style': 'solid', - 'border-radius': '@br', - 'padding': '@sep', - '::item': { - 'color': '@fg', - 'font': '@font', - 'border-color': '@bg', - 'border-radius': 0, - 'border-style': 'solid', - 'border-width': '@bw', - 'padding': (3, 3, 1, 10), - }, - '::icon': { - 'padding': (1, 1, 1, 10), - }, - '::item:selected': { - 'border-color': '@sets', - }, - '::item:disabled': { - 'color': '@mfg' - }, - '::item:disabled:selected': { - 'border-color': '@bg' - } - }, - # frame for duty officers - 'doff_frame': { - 'background-color': '@bg', - 'border-style': 'solid', - 'border-width': '@bw', - 'border-color': '@bc', - 'padding': 2 - }, - # segment of the bonus bar - 'bonus_bar': { - ':disabled': { - 'border-style': 'solid', - 'border-top-style': 'none', - 'border-bottom-style': 'none', - 'border-width': '@bw', - 'border-color': '@bc', - 'background-color': '@bg', - }, - ':checked': { - 'background-color': '@sets' - } - }, - # label holding career / ground icon - 'unlock_label': { - 'border-style': 'none', - 'border-top-style': 'solid', - 'border-top-width': 1, - 'border-top-color': '@bc', - 'margin': (0, 0, 3, 0), - 'padding': (3, 10, 0, 10) - }, - # horizontal seperator - 'hr': { - 'background-color': '@lbg', - 'border-style': 'none', - 'height': 1 - }, - # horizontal sliding selector - 'slider': { - 'font': ('Roboto Mono', 11, 'Normal'), - 'color': '@fg', - '::groove:horizontal': { - 'border-style': 'none', - 'background-color': '@lbg', - 'border-radius': '@bw', - 'height': 3 - }, - '::handle:horizontal': { - 'border-style': 'solid', - 'border-width': '@bw', - 'border-color': '@bc', - 'background-color': '@bc', - 'width': 6, - 'margin-top': -7, - 'margin-bottom': -7 - }, - '::handle:horizontal:hover': { - 'border-color': '@sets' - }, - '::handle:horizontal:pressed': { - 'background-color': '#666666' - }, - }, - # small window - 'dialog_window': { - 'background-color': '@sets' - }, - } + __version__ = '3.0.0' @staticmethod def base_path() -> str: """initialize the base path""" - if getattr(sys, 'frozen', False): - base_path = os.path.dirname(sys.executable) - else: - base_path = os.path.abspath(os.path.dirname(__file__)) + try: + base_path = sys._MEIPASS + except Exception: + if getattr(sys, 'frozen', False): + # The application is frozen + base_path = os__dirname(sys.executable) + else: + base_path = os__abspath(os__dirname(__file__)) return base_path - @staticmethod - def app_config() -> dict: - config = { - 'settings_path': '.SETS_settings.ini', - 'config_folder_path': '.config', - 'config_subfolders': { - 'library': 'library', - 'cache': 'cache', - 'cargo': 'cargo', - 'images': 'images', - 'ship_images': 'ship_images', - 'backups': 'backups', - 'auto_backups': 'auto_backups' - }, - 'autosave_filename': '.autosave.json', - 'box_width': 49, - 'box_height': 64, - 'link_website': 'https://stobuilds.com/apps/sets', - 'link_github': 'https://github.com/STOCD', - 'link_discord': 'https://discord.gg/kxwHxbsqzF', - 'link_downloads': 'https://github.com/STOCD/SETS/releases', - 'default_settings': { - 'ui_scale': 1.0, - 'default_mark': '', - 'default_rarity': 'Common', - 'picker_relative': 0, - 'default_save_format': 'JSON', - 'geometry': None, - 'pref_backup': 0 - } - } - return config - @staticmethod def launch(): argparser = ArgumentParser(prog='SETS', description='STO Equipment and Trait Selector') + # argparser.add_argument( + # '--build-cache', action='store_true', required=False, + # help='Provide this flag to build the cache instead of starting the app.') argparser.add_argument( - '--build-cache', action='store_true', required=False, - help='Provide this flag to build the cache instead of starting the app.') + '--config-dir', type=str, required=False, + help='Change configuration directory (must be readable and writable)') args, _ = argparser.parse_known_args() - if args.build_cache: - exit_code = build_cache(Path(Launcher.base_path())) - sys.exit(exit_code) exit_code = SETS( - theme=Launcher.theme, args=args, - path=Launcher.base_path(), config=Launcher.app_config(), - versions=(Launcher.__version__, Launcher.version)).run() + args=args, app_dir_path=Launcher.base_path(), version=Launcher.__version__).run() sys.exit(exit_code) diff --git a/pyproject.toml b/pyproject.toml index 8bce299..a48ed0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "SETS" description = "A Star Trek Online build tool in Python" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.14" license = {file = "LICENSE"} classifiers = [ "Programming Language :: Python :: 3", @@ -16,14 +16,17 @@ classifiers = [ "Development Status :: 4 - Beta" ] dependencies = [ - "PySide6", - "requests", - "numpy", - "requests_html", - "lxml_html_clean" + "PySide6==6.11.0", + "requests==2.34.0", + "numpy==2.4.4" ] dynamic = ["version"] +[project.optional-dependencies] +pyinst = [ + "pyinstaller==6.19.0" +] + [project.urls] homepage = "https://stobuilds.com/apps/sets" repository = "https://github.com/STOCD/SETS" @@ -36,14 +39,4 @@ sets = "main:Launcher.launch" [tool.hatch.version] path = "main.py" -pattern = "\\s*version = '(?P.*)'" - -[tool.cxfreeze] -executables = [ - { script = "main.py", base = "gui", icon = "local/icon", target_name = "SETS" } -] - -[tool.cxfreeze.build_exe] -include_files = ["local", "LICENSE", "README.md"] -packages = ["PySide6", "requests", "numpy", "requests_html", "lxml_html_clean"] -optimize = 2 +pattern = "\\s*__version__ = '(?P.*)'" diff --git a/run.sh b/run.sh index 623454c..6a426b7 100755 --- a/run.sh +++ b/run.sh @@ -7,5 +7,5 @@ then echo "[Info] (Run \"install.sh\ to install SETS if you haven't done that already.)" exit fi -python3 main.py +python3 main.py --config-dir ./sets-config deactivate diff --git a/src/app.py b/src/app.py index e6ed575..e9b29ea 100644 --- a/src/app.py +++ b/src/app.py @@ -1,131 +1,100 @@ import os from pathlib import Path -from PySide6.QtCore import QSettings, Qt, QThread -from PySide6.QtGui import QFontDatabase, QTextOption -from PySide6.QtWidgets import QApplication, QFrame, QPlainTextEdit, QScrollArea, QTabWidget, QWidget - +from PySide6.QtCore import QDir, QPoint, Qt, QThread +from PySide6.QtGui import QCloseEvent, QFontDatabase, QTextOption +from PySide6.QtWidgets import ( + QApplication, QFrame, QLineEdit, QPlainTextEdit, QPushButton, QScrollArea, QTabWidget, QWidget) + +from .buildhelpers import empty_build +from .buildloader import BuildLoader +from .buildmanager import BuildManager from .cargomanager import CargoManager +from .config import SETSConfig, SETSSettings from .constants import ( - ABOTTOM, ACENTER, AHCENTER, ALEFT, ARIGHT, ATOP, AVCENTER, CAREERS, FACTIONS, MARKS, + ABOTTOM, AHCENTER, ALEFT, ARIGHT, ATOP, AVCENTER, CAREERS, FACTIONS, GROUND_BOFF_SPECS, MARKS, PRIMARY_SPECS, RARITIES, SCROLLOFF, SCROLLON, SECONDARY_SPECS, SMAXMAX, SMAXMIN, SMINMAX, - SMINMIN) -from .datafunctions import cache_skills + SMINMIN, SMIXMAX) +from .contextmenu import ContextMenu from .downloader import Downloader +from .exportwindow import ExportWindow from .imagemanager import ImageManager -from .iofunc import ( - create_folder, delete_folder_contents, get_asset_path, load_icon, load_json, open_url, - store_json) -from .subwindows import ExportWindow, ItemEditor, Picker, ShipSelector +from .iofunc import browse_path, delete_folder_contents, load_icon, open_url, store_json +from .picker import ItemEditor, Picker, ShipSelector +from .splash import SplashScreen +from .textedit import format_path, format_skill_tooltip +from .theme import AppTheme +from .widgetbuilder import ( + create_annotated_slider2, create_button2, create_button_series2, create_checkbox2, + create_combo_box2, create_entry2, create_frame2, create_item_button2, create_label2) from .widgets import ( - Cache, ContextMenu, GridLayout, HBoxLayout, ImageLabel, ShipButton, ShipImage, TooltipLabel, - VBoxLayout, WidgetStorage) + DoffCombobox, GridLayout, HBoxLayout, ImageLabel, ItemButton, ItemSlot, ShipButton, ShipImage, + Tabbers, Thread, TooltipLabel, VBoxLayout) # only for developing; allows to terminate the qt event loop with keyboard interrupt -from signal import signal, SIGINT, SIG_DFL -signal(SIGINT, SIG_DFL) +# from signal import signal, SIGINT, SIG_DFL +# signal(SIGINT, SIG_DFL) class SETS(): - from .callbacks import ( - clear_all, clear_slot, clear_build_callback, copy_equipment_item, edit_equipment_item, - elite_callback, faction_combo_callback, load_build_callback, load_skills_callback, - open_wiki_context, paste_equipment_item, save_build_callback, save_skills_callback, - select_ship, set_build_item, set_ui_scale_setting, ship_info_callback, - skill_unlock_callback, spec_combo_callback, species_combo_callback, switch_main_tab, - tier_callback) - from .datafunctions import ( - autosave, backup_cargo_data, empty_build, - init_backend, load_legacy_build_image) - from .export import get_build_markdown - from .splash import enter_splash, exit_splash, splash_text - from .style import ( - create_style_sheet, get_style, get_style_class, prepare_tooltip_css, theme_font) - from .widgetbuilder import ( - create_annotated_slider, create_boff_station_ground, create_boff_station_space, - create_bonus_bar_segment, create_bonus_bar_space, create_build_section, create_button, - create_button_series, create_checkbox, create_combo_box, create_doff_section, - create_entry, create_frame, create_item_button, create_label, - create_personal_trait_section, create_skill_button_ground, create_skill_group_space, - create_starship_trait_section) - - app_dir = None - # (release version, dev version) - versions = ('', '') - # see main.py for contents - config = {} - # see main.py for contents - theme = {} - # see main.py for defaults - settings: QSettings - # stores widgets that need to be accessed from outside their creating function - widgets: WidgetStorage - # stores refined cargo data - cache: Cache - # stores current build - build: dict - # height of items - box_height: int - # width of items - box_width: int - # for picking items - picker_window: Picker - # for selecting ships - ship_selector_window: ShipSelector - # for editing equipment items - edit_window: ItemEditor - # context menu for equipment - context_menu: ContextMenu - # shows markdown export - export_window: ExportWindow - - def __init__(self, theme, args, path, config, versions): + def __init__(self, args, app_dir_path: str, version: str): """ Creates new Instance of SETS Parameters: + - :param args: command line arguments, following arguments must be accessible + - `args.config_dir`: contains override for config dir, `str` or `None` + - :param app_dir_path: absolute path to install directory - :param version: version of the app - - :param theme: dict -> default theme - - :param args: command line arguments - - :param path: absolute path to directory containing the main.py file - - :param config: app configuration (!= settings these are not changed by the user) """ - self.versions = versions - self.theme = theme + self.version: str = version self.args = args - self.app_dir = path - self.config = config - self.widgets = WidgetStorage() - self.cache = Cache() - self.init_settings() + self.app_dir: Path = Path(app_dir_path) + self.app_dir2: Path = Path(app_dir_path) + self.config: SETSConfig = SETSConfig() + self.config.config_dir = self.get_config_dir_path(args.config_dir) + self.settings = SETSSettings(self.config.config_dir / self.config.settings_file) self.init_config() - self.prepare_tooltip_css() + QDir.addSearchPath('local_folder', self.app_dir / 'local') + self.theme: AppTheme = AppTheme(self.config.ui_scale) self.init_environment() self.downloader = Downloader( - self.config['config_subfolders']['images'], - self.config['config_subfolders']['ship_images']) - self.cargo: CargoManager = CargoManager(self.config['config_subfolders']) + self.config.config_subfolders['images'], + self.config.config_subfolders['ship_images']) + self.cargo: CargoManager = CargoManager( + self.config.config_subfolders, self.app_dir2, self.downloader, self.settings, + self.theme) self.images: ImageManager = ImageManager( - Path(self.config['config_subfolders']['images']), - Path(self.config['config_subfolders']['ship_images']), - self.cargo, self.downloader) + Path(self.config.config_subfolders['images']), + Path(self.config.config_subfolders['ship_images']), + self.app_dir2, self.cargo, self.downloader) + self.build: BuildManager = BuildManager( + self.cargo, self.images, self.config.autosave_path, self.theme.tooltips) + self.splash: SplashScreen = SplashScreen() + self.images.splash_text.connect(self.splash.loading_text) + self.downloader.progress_init.connect(self.splash.progress_init) + self.downloader.progress_step.connect(self.splash.progress_step) + self.tabbers: Tabbers = Tabbers() self.app, self.window = self.create_main_window() self.cache_icons() - self.cache_item_aliases() - self.building = True - self.build = self.empty_build() - self.export_window = ExportWindow(self, self.window, self.get_build_markdown) + self.cargo.load_static_data() + self.build_loader: BuildLoader = BuildLoader( + self.build, self.cargo, self.config, self.settings, self.window) + self.export_window = ExportWindow(self.theme, self.window, self.build, self.cargo) + self.picker_window: Picker = Picker(self.theme, self.window, self.settings, self.images) + self.picker_window.dialog_result.connect(self.build.handle_picker_result) + self.edit_window: ItemEditor = ItemEditor(self.theme, self.window) + self.edit_window.dialog_result.connect(self.build.finish_item_edit) + self.ship_selector_window: ShipSelector = ShipSelector(self.theme, self.window) + self.ship_selector_window.dialog_result.connect(self.build.finish_ship_pick) + self.context_menu: ContextMenu = ContextMenu(self.theme, self.build, self.cargo) + self.context_menu.edit_slot.connect(self.edit_window.edit_item) self.setup_main_layout() - self.picker_window = Picker( - self, self.window, - default_rarity_getter=lambda: self.settings.value('default_rarity'), - default_mark_getter=lambda: self.settings.value('default_mark')) - self.edit_window = ItemEditor(self, self.window) - self.ship_selector_window = ShipSelector(self, self.window) - self.context_menu = self.create_context_menu() self.window.show() - self.init_backend() + self._backend_thread: Thread = Thread(self.init_backend) + self._backend_thread.done.connect(self.complete_app_init) + self._backend_thread.start() def run(self) -> int: """ @@ -135,82 +104,132 @@ def run(self) -> int: """ return self.app.exec() - def init_settings(self): + def setup_config_dir(self, dir_path: Path) -> None | OSError: + """ + Sets up config directory. + """ + try: + dir_path.mkdir(exist_ok=True) + for folder in self.config.config_subfolders: + folder_path = dir_path / folder + folder_path.mkdir(exist_ok=True) + self.config.config_subfolders[folder] = folder_path + except OSError as e: + return e + + def get_config_dir_path(self, override: str | None = None) -> Path | None: """ - Prepares settings. Loads stored settings. Saves current settings for next startup. + Identifies appropriate config directory and returns path to that directory. Returns `None` + if no usable config dir could be identified. """ - settings_path = os.path.abspath(os.path.join(self.app_dir, self.config['settings_path'])) - self.settings = QSettings(settings_path, QSettings.Format.IniFormat) - for setting, value in self.config['default_settings'].items(): - if self.settings.value(setting, None) is None: - self.settings.setValue(setting, value) + if override is not None: + config_dir = Path(override) + if self.setup_config_dir(config_dir) is None: + return config_dir + else: + return + + if os.name == 'nt': + for env_name in ('APPDATA', 'USERPROFILE'): + config_basedir = os.getenv(env_name) + if config_basedir is not None: + config_dir = Path(config_basedir, 'SETS') + if self.setup_config_dir(config_dir) is None: + return config_dir + else: + config_basedir = os.getenv('XDG_CONFIG_HOME') + if config_basedir is not None: + config_dir = Path(config_basedir, 'SETS') + if self.setup_config_dir(config_dir) is None: + return config_dir + home_dir = os.getenv('HOME') + if home_dir is None: + return + config_dir = Path(home_dir, '.config', 'SETS') + if self.setup_config_dir(config_dir) is None: + return config_dir + config_dir = Path(home_dir, '.sets') + if self.setup_config_dir(config_dir) is None: + return config_dir def init_config(self): """ Prepares config. """ - config_folder = os.path.abspath(os.path.join( - self.app_dir, self.config['config_folder_path'])) - self.config['config_folder_path'] = config_folder - for folder, path in self.config['config_subfolders'].items(): - self.config['config_subfolders'][folder] = os.path.join(config_folder, path) - self.config['autosave_filename'] = os.path.join( - config_folder, self.config['autosave_filename']) - self.config['ui_scale'] = self.settings.value('ui_scale', type=float) - self.box_width = self.config['box_width'] * self.config['ui_scale'] * 0.8 - self.box_height = self.config['box_height'] * self.config['ui_scale'] * 0.8 + self.config.autosave_path = self.config.config_dir / self.config.autosave_filename + self.config.ui_scale = self.settings.ui_scale + if os.name == 'nt': + self.config.home_dir = Path(os.getenv('USERPROFILE')) + else: + self.config.home_dir = Path(os.getenv('HOME')) def init_environment(self): """ - Creates required folders if necessary. + Creates external files before starting the app. + """ + if not self.config.autosave_path.exists(): + store_json(empty_build(), self.config.autosave_path) + + def init_backend(self): + """ + Sets up downloader and provides cargo data and images. + """ + self.splash.show_progress(False) + self.splash.show_splash(True) + self.splash.set_loading_text('Loading Cargo Data...') + self.downloader.default_session_from_env() + self.cargo.provision_cargo_data() + self.images.image_set = self.cargo.image_set + self.images.failed_images = self.cargo.failed_images + self.splash.set_loading_text('Downloading Images...') + self.images.download_images(self.cargo.skills) + self.cargo.store_failed_images() + self.splash.show_progress(False) + self.splash.set_loading_text('Loading Base Images...') + self.images.load_base_images() + + def complete_app_init(self): + """ + Updates ui and starts thread to load images. """ - create_folder(self.config['config_folder_path']) - create_folder(self.config['config_subfolders']['library']) - create_folder(self.config['config_subfolders']['cache']) - create_folder(self.config['config_subfolders']['cargo']) - create_folder(self.config['config_subfolders']['images']) - create_folder(self.config['config_subfolders']['ship_images']) - create_folder(self.config['config_subfolders']['backups']) - create_folder(self.config['config_subfolders']['auto_backups']) - if not os.path.exists(self.config['autosave_filename']): - store_json(self.empty_build(), self.config['autosave_filename']) + self.splash.set_loading_text('Populating UI...') + self.init_ui() + self.splash.set_loading_text('Loading Build...') + self.build_loader.load_build_file(self.config.autosave_path) + self.splash.set_loading_text('Loading Images...') + self._backend_thread = Thread(self.images.load_images) + self._backend_thread.start() + self.splash.show_splash(False) def cache_icons(self): """ Loads static icons. """ - self.cache.icons['copy'] = load_icon('copy.png', self.app_dir) - self.cache.icons['paste'] = load_icon('paste.png', self.app_dir) - self.cache.icons['clear'] = load_icon('clear.png', self.app_dir) - self.cache.icons['edit'] = load_icon('edit.png', self.app_dir) - self.cache.icons['link'] = load_icon('external_link.png', self.app_dir) - self.cache.icons['dual_cannons'] = load_icon('DC_icon.svg', self.app_dir).pixmap(16, 24.5) - self.cache.icons['ground'] = load_icon('ground_icon.png', self.app_dir).pixmap( - self.box_width * 1.2, self.box_width * 1.2) - self.cache.icons['tac'] = load_icon('tac_icon.png', self.app_dir).pixmap( - self.box_width, self.box_width) - self.cache.icons['tac-small'] = load_icon('tac-small.svg', self.app_dir).pixmap(25, 25) - self.cache.icons['sci'] = load_icon('sci_icon.png', self.app_dir).pixmap( - self.box_width, self.box_width) - self.cache.icons['sci-small'] = load_icon('sci-small.svg', self.app_dir).pixmap(25, 25) - self.cache.icons['eng'] = load_icon('eng_icon.png', self.app_dir).pixmap( - self.box_width, self.box_width) - self.cache.icons['STOCD'] = load_icon('stocd.png', self.app_dir).pixmap( - self.box_height, self.box_height * 182 / 106) - - def cache_item_aliases(self): - """ - Loads item aliases into cache (used for fixing renamed items). - """ - self.cache.item_aliases = load_json(get_asset_path('aliases.json', self.app_dir)) - - def main_window_close_callback(self, event): + self.theme.icons['copy'] = load_icon('copy.png', self.app_dir2) + self.theme.icons['paste'] = load_icon('paste.png', self.app_dir2) + self.theme.icons['clear'] = load_icon('clear.png', self.app_dir2) + self.theme.icons['edit'] = load_icon('edit.png', self.app_dir2) + self.theme.icons['link'] = load_icon('external_link.png', self.app_dir2) + self.theme.icons['dual_cannons'] = load_icon('DC_icon.svg', self.app_dir2, size=(16, 24.5)) + icon_size = (self.theme.opt.box_width * 1.2, self.theme.opt.box_width * 1.2) + self.theme.icons['ground'] = load_icon('ground_icon.png', self.app_dir2, icon_size) + icon_size = (self.theme.opt.box_width, self.theme.opt.box_width) + self.theme.icons['tac'] = load_icon('tac_icon.png', self.app_dir2, icon_size) + self.theme.icons['sci'] = load_icon('sci_icon.png', self.app_dir2, icon_size) + self.theme.icons['eng'] = load_icon('eng_icon.png', self.app_dir2, icon_size) + self.theme.icons['tac-small'] = load_icon('tac-small.svg', self.app_dir2, size=(25, 25)) + self.theme.icons['sci-small'] = load_icon('sci-small.svg', self.app_dir2, size=(25, 25)) + icon_size = (self.theme.opt.box_height, self.theme.opt.box_width * 182 / 106) + self.theme.icons['STOCD'] = load_icon('stocd.png', self.app_dir2, icon_size) + + def main_window_close_callback(self, event: QCloseEvent): """ Executed when application is closed. """ window_geometry = self.window.saveGeometry() - self.settings.setValue('geometry', window_geometry) - self.autosave() + self.settings.state__geometry = window_geometry + self.build.autosave() + self.settings.store_settings() event.accept() # ---------------------------------------------------------------------------------------------- @@ -226,235 +245,619 @@ def create_main_window(self, argv=[]) -> tuple[QApplication, QWidget]: app = QApplication(argv) font_database = QFontDatabase() font_database.addApplicationFont( - get_asset_path('Overpass-VariableFont_wght.ttf', self.app_dir)) - font_database.addApplicationFont( - get_asset_path('RobotoMono-Regular.ttf', self.app_dir)) - app.setStyleSheet(self.create_style_sheet(self.theme['app']['style'])) + str(self.app_dir2 / 'local' / 'Overpass-VariableFont_wght.ttf')) + font_database.addApplicationFont(str(self.app_dir2 / 'local' / 'RobotoMono-Regular.ttf')) + app.setStyleSheet(self.theme.create_style_sheet(self.theme['app']['style'])) window = QWidget() - window.setWindowIcon(load_icon('SETS_icon_small.png', self.app_dir)) + window.setWindowIcon(load_icon('SETS_icon_small.png', self.app_dir2)) window.setWindowTitle('STO Equipment and Trait Selector') - if self.settings.value('geometry'): - window.restoreGeometry(self.settings.value('geometry')) + if self.settings.state__geometry: + window.restoreGeometry(self.settings.state__geometry) window.closeEvent = self.main_window_close_callback app.focusWindowChanged.connect(self.hide_tooltips) QThread.currentThread().setPriority(QThread.Priority.TimeCriticalPriority) return app, window + def init_ui(self): + """ + Updates ui with cargo data and loads base images. + """ + self.ship_selector_window.set_ships(self.cargo.ships.keys()) + space_doff_specs = [''] + sorted(self.cargo.space_doffs.keys()) + for combobox in self.build.space.doffs_spec: + combobox.addItems(space_doff_specs) + ground_doff_specs = [''] + sorted(self.cargo.ground_doffs.keys()) + for combobox in self.build.ground.doffs_spec: + combobox.addItems(ground_doff_specs) + for career_block in self.build.skills.space.values(): + for skill_button in career_block: + skill_button.set_item(self.images.get(skill_button.skill_image_name)) + for skill_group in self.build.skills.ground: + for skill_button in skill_group: + skill_button.set_item(self.images.get(skill_button.skill_image_name)) + + def picker( + self, environment: str, build_key: str, build_subkey: int, button: ItemButton, + equipment: bool = False, boff_id: int | None = None): + """ + opens dialog to select item, stores it to build and updates item button + + Parameters: + - :param items: iterable of items available to pick from + - :param environment: space or ground + - :param build_key: key to self.build[environment]; for storing picked item + - :param build_subkey: index of the item within its build_key (category) + - :param button: reference to the button clicked + - :param equipment: set to True to show rarity, mark, and modifier selector (optional) + - :param boff_id: id of the boff; only set when picking boff abilities! (optional) + """ + modifiers = {} + image_suffix = '' + if equipment: + items = self.cargo.equipment[build_key].keys() + modifiers = self.cargo.modifiers[build_key] + elif build_key == 'boffs': + if environment == 'space': + profession, specialization = self.build['space']['boff_specs'][boff_id] + if specialization == 'Temporal Operative': + specialization = 'Temporal' + else: + profession = self.build['ground']['boff_profs'][boff_id] + specialization = self.build['ground']['boff_specs'][boff_id] + items = self.cargo.boff_abilities[environment][profession][build_subkey] + if specialization != '': + items = items + self.cargo.boff_abilities[environment][specialization][build_subkey] + elif build_key == 'starship_traits': + items = self.cargo.starship_traits.keys() + image_suffix = '__space__starship_traits' + elif 'traits' in build_key: + if environment == 'space': + items = self.cargo.space_traits[build_key].keys() + else: + items = self.cargo.ground_traits[build_key].keys() + image_suffix = f'__{environment}__{build_key}' + else: + items = [] + if self.settings.picker_relative == 1: + pos = button.mapToGlobal(QPoint(0, 0)) + else: + pos = None + slot = ItemSlot(environment, build_key, build_subkey, boff_id, equipment) + self.picker_window.pick_item(items, pos, slot, modifiers, image_suffix) + def setup_main_layout(self): """ Creates the main layout and places it into the main window. """ + self.build._building = True # master layout: banner, borders and splash screen - layout = VBoxLayout(margins=0, spacing=0) - background_frame = self.create_frame( - style_override={'background-color': '@sets'}, size_policy=SMINMIN) + layout = VBoxLayout() + background_frame = create_frame2( + self.theme, style_override={'background-color': '@sets'}, size_policy=SMINMIN) layout.addWidget(background_frame) self.window.setLayout(layout) - main_layout = VBoxLayout(margins=0, spacing=0) - banner = ImageLabel(get_asset_path('sets_banner.png', self.app_dir), (2880, 126)) + main_layout = VBoxLayout() + banner = ImageLabel(self.app_dir / 'local' / 'sets_banner.png', (2880, 126)) main_layout.addWidget(banner) - frame_width = 8 * self.config['ui_scale'] - tabber_layout = VBoxLayout(margins=frame_width, spacing=0) + frame_width = 8 * self.theme.scale + tabber_layout = VBoxLayout(margins=frame_width) splash_tabber = QTabWidget() - splash_tabber.setStyleSheet(self.get_style_class('QTabWidget', 'tabber')) - splash_tabber.tabBar().setStyleSheet(self.get_style_class('QTabBar', 'tabber_tab')) + splash_tabber.setStyleSheet(self.theme.get_style_class('QTabWidget', 'tabber')) + splash_tabber.tabBar().setStyleSheet(self.theme.get_style_class('QTabBar', 'tabber_tab')) splash_tabber.setSizePolicy(SMINMIN) - self.widgets.splash_tabber = splash_tabber + self.splash.tabber = splash_tabber tabber_layout.addWidget(splash_tabber) main_layout.addLayout(tabber_layout) background_frame.setLayout(main_layout) - content_frame = self.create_frame() - splash_frame = self.create_frame() + content_frame = create_frame2(self.theme) + splash_frame = create_frame2(self.theme) splash_tabber.addTab(content_frame, 'Main') splash_tabber.addTab(splash_frame, 'Splash') self.setup_splash(splash_frame) - content_layout = GridLayout(margins=0, spacing=0) + content_layout = GridLayout() content_layout.setColumnStretch(0, 1) content_layout.setColumnStretch(1, 4) - margin = 3 * self.config['ui_scale'] - menu_layout = GridLayout(margins=(margin, margin, margin, 0), spacing=0) + margin = 3 * self.theme.scale + menu_layout = GridLayout(margins=(margin, margin, margin, 0)) menu_layout.setColumnStretch(0, 2) menu_layout.setColumnStretch(1, 5) menu_layout.setColumnStretch(2, 2) left_button_group = { - 'Save': {'callback': self.save_build_callback}, - 'Open': {'callback': self.load_build_callback}, - 'Clear Current Tab': {'callback': self.clear_build_callback}, - 'Clear All Tabs': {'callback': self.clear_all} + 'Save': {'callback': self.build_loader.save_build_callback}, + 'Save As': {'callback': self.build_loader.save_build_as_callback}, + 'Open': {'callback': self.build_loader.load_build_callback}, + 'Clear Current Tab': {'callback': lambda: self.build.clear_build_callback( + self.tabbers.build_tabber.currentIndex())} } - menu_layout.addLayout(self.create_button_series(left_button_group), 0, 0, ALEFT | ATOP) + menu_layout.addLayout( + create_button_series2(self.theme, left_button_group), 0, 0, alignment=ALEFT | ATOP) center_button_group = { 'default': {'font': ('Overpass', 16, 'medium')}, - 'SPACE': {'callback': lambda: self.switch_main_tab(0), 'stretch': 1, 'size': SMINMAX}, - 'GROUND': {'callback': lambda: self.switch_main_tab(1), 'stretch': 1, 'size': SMINMAX}, + 'SPACE': {'callback': lambda: self.tabbers.switch(0), 'stretch': 1, 'size': SMINMAX}, + 'GROUND': {'callback': lambda: self.tabbers.switch(1), 'stretch': 1, 'size': SMINMAX}, 'SPACE SKILLS': { - 'callback': lambda: self.switch_main_tab(2), + 'callback': lambda: self.tabbers.switch(2), 'stretch': 1, 'size': SMINMAX }, 'GROUND SKILLS': { - 'callback': lambda: self.switch_main_tab(3), + 'callback': lambda: self.tabbers.switch(3), 'stretch': 1, 'size': SMINMAX } } - center_buttons = self.create_button_series(center_button_group, 'heavy_button') + center_buttons = create_button_series2(self.theme, center_button_group, 'heavy_button') menu_layout.addLayout(center_buttons, 0, 1) right_button_group = { + 'Clear All Tabs': {'callback': self.build.clear_all}, 'Export': {'callback': self.export_window.invoke}, - 'Settings': {'callback': lambda: self.switch_main_tab(5)}, + 'Settings': {'callback': lambda: self.tabbers.switch(5)}, } - menu_layout.addLayout(self.create_button_series(right_button_group), 0, 2, ARIGHT | ATOP) + menu_layout.addLayout( + create_button_series2(self.theme, right_button_group), 0, 2, alignment=ARIGHT | ATOP) content_layout.addLayout(menu_layout, 0, 0, 1, 2) # sidebar - sidebar = self.create_frame(size_policy=SMINMIN) - self.widgets.sidebar = sidebar - sidebar_layout = GridLayout(margins=0, spacing=0) + sidebar = create_frame2(self.theme, size_policy=SMINMIN) + sidebar_layout = GridLayout() sidebar_tabber = QTabWidget() - sidebar_tabber.setStyleSheet(self.get_style_class('QTabWidget', 'tabber')) - sidebar_tabber.tabBar().setStyleSheet(self.get_style_class('QTabBar', 'tabber_tab')) + sidebar_tabber.setStyleSheet(self.theme.get_style_class('QTabWidget', 'tabber')) + sidebar_tabber.tabBar().setStyleSheet(self.theme.get_style_class('QTabBar', 'tabber_tab')) sidebar_tabber.setSizePolicy(SMINMIN) - self.widgets.sidebar_tabber = sidebar_tabber - sidebar_tab_names = ( - 'space', 'ground', 'space_skills', 'ground_skills', 'empty', 'settings') - for tab_name in sidebar_tab_names: - tab_frame = self.create_frame() + self.tabbers.sidebar_tabber = sidebar_tabber + for tab_name in ('space', 'ground', 'space_skills', 'ground_skills', 'empty', 'settings'): + tab_frame = create_frame2(self.theme) sidebar_tabber.addTab(tab_frame, tab_name) - self.widgets.sidebar_frames.append(tab_frame) + self.tabbers.sidebar_frames.append(tab_frame) self.setup_ship_frame() sidebar_layout.addWidget(sidebar_tabber, 0, 0) character_tabber = QTabWidget() - character_tabber.setStyleSheet(self.get_style_class('QTabWidget', 'tabber')) - character_tabber.tabBar().setStyleSheet(self.get_style_class('QTabBar', 'tabber_tab')) + character_tabber.setStyleSheet(self.theme.get_style_class('QTabWidget', 'tabber')) + character_tabber.tabBar().setStyleSheet( + self.theme.get_style_class('QTabBar', 'tabber_tab')) character_tabber.setSizePolicy(SMINMAX) - self.widgets.character_tabber = character_tabber - char_frame = self.create_frame() + self.tabbers.character_tabber = character_tabber + char_frame = create_frame2(self.theme) self.setup_character_frame(char_frame) character_tabber.addTab(char_frame, 'char') - empty_frame = self.create_frame() + empty_frame = create_frame2(self.theme) character_tabber.addTab(empty_frame, 'empty') - settings_frame = self.create_frame() + settings_frame = create_frame2(self.theme) character_tabber.addTab(settings_frame, 'settings') - self.widgets.character_frames = [char_frame, empty_frame, settings_frame] + self.tabbers.character_frames = [char_frame, empty_frame, settings_frame] sidebar_layout.addWidget(character_tabber, 1, 0) - seperator = self.create_frame(size_policy=SMAXMIN, style_override={ - 'background-color': '@sets', 'margin-top': '@isp', 'margin-bottom': '@isp'}) - seperator.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + seperator = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ + 'background-color': '@sets', 'margin-top': '@isp', 'margin-bottom': '@isp'}) + seperator.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) sidebar_layout.addWidget(seperator, 0, 1, 2, 1) sidebar.setLayout(sidebar_layout) content_layout.addWidget(sidebar, 1, 0) # build section build_tabber = QTabWidget() - build_tabber.setStyleSheet(self.get_style_class('QTabWidget', 'tabber')) - build_tabber.tabBar().setStyleSheet(self.get_style_class('QTabBar', 'tabber_tab')) + build_tabber.setStyleSheet(self.theme.get_style_class('QTabWidget', 'tabber')) + build_tabber.tabBar().setStyleSheet(self.theme.get_style_class('QTabBar', 'tabber_tab')) build_tabber.setSizePolicy(SMINMIN) - self.widgets.build_tabber = build_tabber + self.tabbers.build_tabber = build_tabber build_tab_names = ( - 'space_build', 'ground_build', 'space_skills', 'ground_skills', 'library', - 'settings') + 'space_build', 'ground_build', 'space_skills', 'ground_skills', 'library', 'settings') for tab_name in build_tab_names: - tab_frame = self.create_frame() + tab_frame = create_frame2(self.theme) build_tabber.addTab(tab_frame, tab_name) - self.widgets.build_frames.append(tab_frame) + self.tabbers.build_frames.append(tab_frame) content_layout.addWidget(build_tabber, 1, 1) - self.setup_build_frames() + self.setup_space_build_frame() + self.setup_ground_build_frame() + self.setup_space_skill_frame() + self.setup_ground_skill_frame() self.setup_settings_frame() content_frame.setLayout(content_layout) + self.build._building = False def setup_ship_frame(self): """ Creates ship info frame """ - frame = self.widgets.sidebar_frames[0] - csp = self.theme['defaults']['csp'] * self.config['ui_scale'] + frame = self.tabbers.sidebar_frames[0] + csp = self.theme['defaults']['csp'] * self.theme.scale layout = VBoxLayout(margins=csp, spacing=csp) - image_frame = self.create_frame(size_policy=SMINMIN) - image_layout = GridLayout(margins=0, spacing=0) + image_frame = create_frame2(self.theme, size_policy=SMINMIN) + image_layout = GridLayout() ship_image = ShipImage() ship_image.setSizePolicy(SMINMIN) - self.widgets.ship['image'] = ship_image + self.build.ship.image = ship_image image_layout.addWidget(ship_image, 0, 0) image_frame.setLayout(image_layout) layout.addWidget(image_frame, stretch=1) - ship_frame = self.create_frame(size_policy=SMINMIN) - ship_layout = GridLayout(margins=0, spacing=csp) + ship_frame = create_frame2(self.theme, size_policy=SMINMIN) + ship_layout = GridLayout(spacing=csp) ship_layout.setRowStretch(4, 1) ship_layout.setColumnStretch(2, 1) ship_selector = ShipButton('') ship_selector.setSizePolicy(SMINMAX) ship_selector.setStyleSheet( - self.get_style_class('ShipButton', 'button', override={'margin': 0})) - ship_selector.setFont(self.theme_font(font_spec='@subhead')) - ship_selector.clicked.connect(self.select_ship) - self.widgets.ship['button'] = ship_selector + self.theme.get_style_class('ShipButton', 'button', override={'margin': 0})) + ship_selector.setFont(self.theme.get_font(font_spec='@subhead')) + ship_selector.clicked.connect(self.ship_selector_window.pick_ship) + self.build.ship.button = ship_selector ship_layout.addWidget(ship_selector, 0, 0, 1, 4, alignment=ATOP) - tier_label = self.create_label('Ship Tier:') + tier_label = create_label2(self.theme, 'Ship Tier:') ship_layout.addWidget(tier_label, 1, 0) - tier_combo = self.create_combo_box() - tier_combo.currentTextChanged.connect(self.tier_callback) + tier_combo = create_combo_box2(self.theme) + tier_combo.currentTextChanged.connect(self.build.tier_callback) tier_combo.setSizePolicy(SMAXMAX) - self.widgets.ship['tier'] = tier_combo + self.build.ship.tier = tier_combo ship_layout.addWidget(tier_combo, 1, 1, alignment=ALEFT) - dc_tooltip = self.create_label('Can equip Dual Cannons', 'label_tooltip') + dc_tooltip = create_label2(self.theme, 'Can equip Dual Cannons', 'label_tooltip') dc_label = TooltipLabel('', dc_tooltip) - dc_label.setPixmap(self.cache.icons['dual_cannons']) + dc_label.setPixmap(self.theme.icons['dual_cannons']) dc_label_size_policy = dc_label.sizePolicy() dc_label_size_policy.setRetainSizeWhenHidden(True) dc_label.setSizePolicy(dc_label_size_policy) - self.widgets.ship['dc'] = dc_label + self.build.ship.dc = dc_label ship_layout.addWidget(dc_label, 1, 2, alignment=ARIGHT) - info_button = self.create_button('Ship Info', style_override={'margin': 0}) - info_button.clicked.connect(self.ship_info_callback) + info_button = create_button2(self.theme, 'Ship Info', style_override={'margin': 0}) + info_button.clicked.connect(self.build.ship_info_callback) ship_layout.addWidget(info_button, 1, 3, alignment=ARIGHT) - name_label = self.create_label('Ship Name:') + name_label = create_label2(self.theme, 'Ship Name:') ship_layout.addWidget(name_label, 2, 0) - name_entry = self.create_entry() + name_entry = create_entry2(self.theme) name_entry.editingFinished.connect( - lambda: self.set_build_item(self.build['space'], 'ship_name', name_entry.text())) - self.widgets.ship['name'] = name_entry + lambda: self.build.set('space', 'ship_name', value=name_entry.text())) + self.build.ship.name = name_entry name_entry.setSizePolicy(SMINMAX) ship_layout.addWidget(name_entry, 2, 1, 1, 3) - desc_label = self.create_label('Build Description:') + desc_label = create_label2(self.theme, 'Build Description:') ship_layout.addWidget(desc_label, 3, 0, 1, 4) desc_edit = QPlainTextEdit() desc_edit.setSizePolicy(SMINMIN) - desc_edit.setStyleSheet(self.get_style_class('QPlainTextEdit', 'textedit')) - desc_edit.setFont(self.theme_font('textedit')) + desc_edit.setStyleSheet(self.theme.get_style_class('QPlainTextEdit', 'textedit')) + desc_edit.setFont(self.theme.get_font('textedit')) desc_edit.setWordWrapMode(QTextOption.WrapMode.WordWrap) - desc_edit.textChanged.connect(lambda: self.set_build_item( - self.build['space'], 'ship_desc', desc_edit.toPlainText(), autosave=False)) - self.widgets.ship['desc'] = desc_edit + desc_edit.textChanged.connect(lambda: self.build.set( + 'space', 'ship_desc', value=desc_edit.toPlainText(), autosave=False)) + self.build.ship.desc = desc_edit ship_layout.addWidget(desc_edit, 4, 0, 1, 4) ship_frame.setLayout(ship_layout) layout.addWidget(ship_frame, stretch=2) frame.setLayout(layout) - def setup_build_frames(self): + def create_build_section( + self, label_text: str, button_count: int, environment: str, build_key: str, + is_equipment: bool = False, label_store: str = '') -> GridLayout: """ - Creates build areas + Creates a block of item buttons below a label. + + Parameters: + - :param label_text: text to be displayed above the buttons + - :param button_count: number of buttons to be created + - :param environment: "space" or "ground" + - :param build_key: key for self.build['space'/'ground'] + - :param is_equipment: True when items are equipment, False if items are abilities or traits + - :param label_store: stores category label in self.widgets.build[`label_store`] if set """ - self.setup_space_build_frame() - self.setup_ground_build_frame() - cache_skills(self.cache.skills, self.app_dir) - self.setup_space_skill_frame() - self.setup_ground_skill_frame() + layout = GridLayout(spacing=self.theme['defaults']['margin'] * self.theme.scale) + label = create_label2(self.theme, label_text, style_override={'margin': (0, 0, 6, 0)}) + label_size_policy = label.sizePolicy() + label_size_policy.setRetainSizeWhenHidden(True) + label.setSizePolicy(label_size_policy) + layout.addWidget(label, 0, 0, 1, button_count, alignment=ALEFT) + widget_storage = self.build.space if environment == 'space' else self.build.ground + if label_store != '': + setattr(widget_storage, label_store, label) + for i in range(button_count): + button = create_item_button2(self.theme) + button.clicked.connect(lambda subkey=i, bt=button: self.picker( + environment, build_key, subkey, bt, is_equipment)) + button.rightclicked.connect(lambda event, subkey=i: self.context_menu.invoke( + event, build_key, subkey, environment)) + getattr(widget_storage, build_key)[i] = button + layout.addWidget(button, 1, i, alignment=ALEFT) + return layout + + def create_boff_station_space( + self, profession: str, specialization: str = '', boff_id: int = 0) -> GridLayout: + """ + Creates a block of item buttons with label / Combobox representing boff station. + + Parameters: + - :param profession: "Tactical", "Science", "Engineering" or "Universal" + - :param specialization: specialization of the seat; empty if it has no specialization + - :param boff_id: identifies the boff station + """ + layout = GridLayout(spacing=self.theme['defaults']['margin'] * self.theme.scale) + layout.setColumnStretch(3, 1) + if specialization != '': + specialization = f' / {specialization}' + if profession == 'Universal': + label_options = ( + f'Tactical{specialization}', + f'Science{specialization}', + f'Engineering{specialization}' + ) + else: + label_options = (profession + specialization,) + widget_storage = self.build.space + label_layout = HBoxLayout(spacing=self.config.ui_scale * 3) + icon_label = TooltipLabel('', create_label2(self.theme, '', 'label_tooltip')) + widget_storage.boff_label_icons[boff_id] = icon_label + label_layout.addWidget(icon_label, alignment=ALEFT) + icon_label.hide() + label = create_combo_box2( + self.theme, size_policy=SMAXMAX, style_override=self.theme['boff_combo']) + label.currentTextChanged.connect( + lambda new: self.build.boff_profession_callback_space(boff_id, new)) + label.addItems(label_options) + label_size_policy = label.sizePolicy() + label_size_policy.setRetainSizeWhenHidden(True) + label.setSizePolicy(label_size_policy) + widget_storage.boff_labels[boff_id] = label + label_layout.addWidget(label, alignment=ALEFT) + layout.addLayout(label_layout, 0, 0, 1, 4, alignment=ALEFT) + for i in range(4): + button = create_item_button2(self.theme) + button.sizePolicy().setRetainSizeWhenHidden(True) + button.clicked.connect(lambda subkey=i, bt=button: self.picker( + 'space', 'boffs', subkey, bt, boff_id=boff_id)) + button.rightclicked.connect(lambda event, subkey=i: self.context_menu.invoke( + event, 'boffs', subkey, 'space', boff_id)) + layout.addWidget(button, 1, i, alignment=ALEFT) + widget_storage.boffs[boff_id][i] = button + return layout + + def create_boff_station_ground(self, boff_id: int) -> VBoxLayout: + """ + Creates a block of item buttons with label / Combobox representing boff station. + + Parameters: + - :param boff_id: identifies the boff station + """ + widget_storage = self.build.ground + m = self.theme['defaults']['margin'] * self.theme.scale + layout = VBoxLayout(spacing=m) + label_layout = HBoxLayout(spacing=m) + label_layout.setAlignment(ALEFT) + prof_label = create_combo_box2(self.theme, style_override=self.theme['boff_combo']) + prof_label.currentTextChanged.connect( + lambda new: self.build.boff_label_callback_ground(boff_id, 'boff_profs', new)) + prof_label.addItems(CAREERS) + widget_storage.boff_profs[boff_id] = prof_label + label_layout.addWidget(prof_label) + spec_label = create_combo_box2(self.theme, style_override=self.theme['boff_combo']) + spec_label.currentTextChanged.connect( + lambda new: self.build.boff_label_callback_ground(boff_id, 'boff_specs', new)) + spec_label.addItems(GROUND_BOFF_SPECS) + widget_storage.boff_specs[boff_id] = spec_label + label_layout.addWidget(spec_label) + layout.addLayout(label_layout) + button_layout = HBoxLayout(spacing=m) + button_layout.setAlignment(ALEFT) + for i in range(4): + button = create_item_button2(self.theme) + button.clicked.connect(lambda subkey=i, bt=button: self.picker( + 'ground', 'boffs', subkey, bt, boff_id=boff_id)) + button.rightclicked.connect(lambda event, subkey=i: self.context_menu.invoke( + event, 'boffs', subkey, 'ground', boff_id)) + button_layout.addWidget(button) + widget_storage.boffs[boff_id][i] = button + layout.addLayout(button_layout) + return layout + + def create_personal_trait_section(self, environment: str) -> GridLayout: + """ + Creates build section for personal traits + + Parameters: + - :param environment: "space" / "ground" + """ + layout = GridLayout(spacing=self.theme['defaults']['margin'] * self.theme.scale) + label = create_label2( + self.theme, 'Personal Traits', style_override={'margin': (0, 0, 6, 0)}) + layout.addWidget(label, 0, 0, 1, 4, alignment=ALEFT) + widget_storage = self.build.space if environment == 'space' else self.build.ground + for row in range(3): + for col in range(4): + i = row * 4 + col + button = create_item_button2(self.theme) + button.clicked.connect( + lambda subkey=i, bt=button: self.picker(environment, 'traits', subkey, bt)) + button.rightclicked.connect(lambda event, subkey=i: self.context_menu.invoke( + event, 'traits', subkey, environment)) + layout.addWidget(button, row + 1, col, alignment=ALEFT) + widget_storage.traits[i] = button + # Last button is for innate trait and should not be clickable + button.setEnabled(False) + button.set_style(self.theme['item_dark']) + return layout + + def create_starship_trait_section(self) -> GridLayout: + """ + Creates build section for starship traits + """ + layout = GridLayout(spacing=self.theme['defaults']['margin'] * self.theme.scale) + label = create_label2( + self.theme, 'Starship Traits', style_override={'margin': (0, 0, 6, 0)}) + label.sizePolicy().setRetainSizeWhenHidden(True) + layout.addWidget(label, 0, 0, 1, 4, alignment=ALEFT) + widget_storage = self.build.space + for col in range(5): + button = create_item_button2(self.theme) + button.sizePolicy().setRetainSizeWhenHidden(True) + button.clicked.connect( + lambda subkey=col, bt=button: self.picker('space', 'starship_traits', subkey, bt)) + button.rightclicked.connect(lambda event, subkey=col: self.context_menu.invoke( + event, 'starship_traits', subkey, 'space')) + layout.addWidget(button, 1, col, alignment=ALEFT) + widget_storage.starship_traits[col] = button + for col in range(2): + button = create_item_button2(self.theme) + button.sizePolicy().setRetainSizeWhenHidden(True) + button.clicked.connect(lambda subkey=col + 5, bt=button: self.picker( + 'space', 'starship_traits', subkey, bt)) + button.rightclicked.connect(lambda event, subkey=col + 5: self.context_menu.invoke( + event, 'starship_traits', subkey, 'space')) + layout.addWidget(button, 2, col, alignment=ALEFT) + widget_storage.starship_traits[col + 5] = button + return layout + + def create_doff_section(self, environment: str) -> GridLayout: + """ + Creates duty officer section + + Parameters: + - :param environment: "space" / "ground" + """ + doff_layout = GridLayout(spacing=self.theme['defaults']['bw'] * self.theme.scale) + doff_layout.setColumnStretch(1, 1) + widget_storage = self.build.space if environment == 'space' else self.build.ground + for i in range(6): + spec_combo = create_combo_box2(self.theme, style_override=self.theme['doff_combo']) + spec_combo.currentTextChanged.connect( + lambda spec, id=i: self.build.doff_spec_callback(spec, environment, id)) + doff_layout.addWidget(spec_combo, i, 0) + widget_storage.doffs_spec[i] = spec_combo + variant_combo = create_combo_box2( + self.theme, style_override=self.theme['doff_combo'], class_=DoffCombobox) + variant_combo.currentTextChanged.connect( + lambda variant, id=i: self.build.doff_variant_callback(variant, environment, id)) + doff_layout.addWidget(variant_combo, i, 1) + widget_storage.doffs_variant[i] = variant_combo + return doff_layout + + def create_skill_group_space(self, group_data: dict, id_offset: int) -> GridLayout: + """ + Creates a skill group (3 related skill nodes) in appropriate shape + + Parameters: + - :param group_data: skill group data + - :param id_offset: index of the first skill node in self.build + """ + layout = GridLayout(spacing=self.theme['defaults']['csp'] * self.config.ui_scale) + # one skill with 3 ranks + if group_data['grouping'] == 'column': + for index, node in enumerate(group_data['nodes']): + button = create_item_button2(self.theme) + skill_id = id_offset + index + button.clicked.connect(lambda id=skill_id: self.build.skill_callback_space( + group_data['career'], id, 'column')) + button.skill_image_name = node['image'] + button.tooltip = format_skill_tooltip( + group_data['skill'], group_data, index, 'space', self.theme.tooltips) + self.build.skills.space[group_data['career']][id_offset + index] = button + layout.addWidget(button, index, 0) + # == 'pair+1': one skill with 2 ranks and one sub-skill with 1 rank + # == 'separate': 3 separate skills + else: + button = create_item_button2(self.theme) + button.clicked.connect(lambda id=id_offset: self.build.skill_callback_space( + group_data['career'], id, group_data['grouping'])) + button.skill_image_name = group_data['nodes'][0]['image'] + button.tooltip = format_skill_tooltip( + group_data['skill'][0], group_data, 0, 'space', self.theme.tooltips) + layout.addWidget(button, 0, 0, 1, 2, alignment=AHCENTER | ABOTTOM) + self.build.skills.space[group_data['career']][id_offset] = button + button = create_item_button2(self.theme) + button.clicked.connect(lambda id=id_offset + 1: self.build.skill_callback_space( + group_data['career'], id, group_data['grouping'])) + button.skill_image_name = group_data['nodes'][1]['image'] + button.tooltip = format_skill_tooltip( + group_data['skill'][1], group_data, 1, 'space', self.theme.tooltips) + layout.addWidget(button, 1, 0, alignment=ATOP) + self.build.skills.space[group_data['career']][id_offset + 1] = button + button = create_item_button2(self.theme) + button.clicked.connect(lambda id=id_offset + 2: self.build.skill_callback_space( + group_data['career'], id, group_data['grouping'])) + button.skill_image_name = group_data['nodes'][2]['image'] + button.tooltip = format_skill_tooltip( + group_data['skill'][2], group_data, 2, 'space', self.theme.tooltips) + layout.addWidget(button, 1, 1, alignment=ATOP) + self.build.skills.space[group_data['career']][id_offset + 2] = button + return layout + + def create_bonus_bar_segment( + self, bar: str, index: int, style: str = 'bonus_bar', + style_override: dict = {}) -> QPushButton: + """ + Creates segment of bar showing the spent skill points. + + Parameters: + - :param bar: identifies the bar ("tac" / "sci" / "eng" / "ground") + - :param index: index of the segment within the bar + - :param style: style key + - :param style_override: overrides style specified by self.theme + """ + seg = QPushButton() + seg.setEnabled(False) + seg.setCheckable(True) + seg.setStyleSheet(self.theme.get_style_class('QPushButton', style, style_override)) + seg.setFixedSize(7 * self.theme.scale, 17 * self.theme.scale) + self.build.skills.bonus_bars[bar][index] = seg + return seg + + def create_bonus_bar_space(self, career: str, layout: GridLayout, column: int): + """ + Creates bonus bar for space career and inserts it into the given layout. + + Parameters: + - :param career: "tac" / "eng" / "sci" + - :param layout: layout to insert the bar into + - :param column: column of the layout to use + """ + segment_index = 0 + button_index = 0 + for row in range(29, 5, -1): + if row % 6 == 0: + button = create_item_button2(self.theme) + button.clicked.connect( + lambda i=button_index: self.build.skill_unlock_callback(career, i)) + layout.addWidget(button, row, column, alignment=AHCENTER) + self.build.skills.unlocks[career][button_index] = button + button_index += 1 + else: + segment = self.create_bonus_bar_segment(career, segment_index) + layout.addWidget(segment, row, column, alignment=AHCENTER) + segment_index += 1 + for row in range(5, 1, -1): + segment = self.create_bonus_bar_segment(career, segment_index) + layout.addWidget(segment, row, column, alignment=AHCENTER) + segment_index += 1 + button = create_item_button2(self.theme) + button.clicked.connect(lambda: self.build.skill_unlock_callback(career, 4)) + layout.addWidget(button, 1, column, alignment=AHCENTER) + self.build.skills.unlocks[career][4] = button + + def create_skill_button_ground(self, group_data: dict, id: int, node_id: int) -> ItemButton: + """ + Creates ground skill button and returns it + + Parameters: + - :param group_data: skill group data + - :param id: index of the skill node in self.build + - :param node_id: 0 or 1 for first or second node + """ + button = create_item_button2(self.theme) + button.clicked.connect(lambda: self.build.skill_callback_ground(group_data['tree'], id)) + button.skill_image_name = group_data['nodes'][node_id]['image'] + button.tooltip = format_skill_tooltip( + group_data['nodes'][node_id]['name'], group_data, node_id, 'ground', + self.theme.tooltips) + self.build.skills.ground[group_data['tree']][id] = button + return button def setup_space_build_frame(self): """ Creates space build layout """ - frame = self.widgets.build_frames[0] - isp = self.theme['defaults']['isp'] * 2 * self.config['ui_scale'] + frame = self.tabbers.build_frames[0] + isp = self.theme['defaults']['isp'] * 2 * self.theme.scale layout = GridLayout(margins=isp, spacing=isp) layout.setColumnStretch(0, 1) layout.setColumnStretch(10, 1) @@ -464,25 +867,25 @@ def setup_space_build_frame(self): fore_layout = self.create_build_section('Fore Weapons', 5, 'space', 'fore_weapons', True) layout.addLayout(fore_layout, 0, 1, alignment=ALEFT) aft_layout = self.create_build_section( - 'Aft Weapons', 5, 'space', 'aft_weapons', True, 'aft_weapons_label') + 'Aft Weapons', 5, 'space', 'aft_weapons', True, 'aft_weapons_label') layout.addLayout(aft_layout, 1, 1, alignment=ALEFT) exp_layout = self.create_build_section( - 'Experimental Weapon', 1, 'space', 'experimental', True, 'experimental_label') + 'Experimental Weapon', 1, 'space', 'experimental', True, 'experimental_label') layout.addLayout(exp_layout, 2, 1, alignment=ALEFT) device_layout = self.create_build_section('Devices', 6, 'space', 'devices', True) layout.addLayout(device_layout, 3, 1, alignment=ALEFT) hangar_layout = self.create_build_section( - 'Hangars', 2, 'space', 'hangars', True, 'hangars_label') + 'Hangars', 2, 'space', 'hangars', True, 'hangars_label') layout.addLayout(hangar_layout, 4, 1, alignment=ALEFT) - sep1 = self.create_frame(size_policy=SMAXMIN, style_override={ + sep1 = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ 'background-color': '@bg', 'margin-top': '@isp', 'margin-bottom': '@isp'}) - sep1.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + sep1.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) layout.addWidget(sep1, 0, 2, 5, 1) deflector_layout = self.create_build_section('Deflector', 1, 'space', 'deflector', True) layout.addLayout(deflector_layout, 0, 3, alignment=ALEFT) secdef_layout = self.create_build_section( - 'Sec-Def', 1, 'space', 'sec_def', True, 'sec_def_label') + 'Sec-Def', 1, 'space', 'sec_def', True, 'sec_def_label') layout.addLayout(secdef_layout, 1, 3, alignment=ALEFT) engine_layout = self.create_build_section('Engines', 1, 'space', 'engines', True) layout.addLayout(engine_layout, 2, 3, alignment=ALEFT) @@ -490,26 +893,26 @@ def setup_space_build_frame(self): layout.addLayout(warp_layout, 3, 3, alignment=ALEFT) shield_layout = self.create_build_section('Shield', 1, 'space', 'shield', True) layout.addLayout(shield_layout, 4, 3, alignment=ALEFT) - sep2 = self.create_frame(size_policy=SMAXMIN, style_override={ + sep2 = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ 'background-color': '@bg', 'margin-top': '@isp', 'margin-bottom': '@isp'}) - sep2.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + sep2.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) layout.addWidget(sep2, 0, 4, 5, 1) uni_layout = self.create_build_section( - 'Universal Consoles', 3, 'space', 'uni_consoles', True, 'uni_consoles_label') + 'Universal Consoles', 3, 'space', 'uni_consoles', True, 'uni_consoles_label') layout.addLayout(uni_layout, 0, 5, alignment=ALEFT) eng_layout = self.create_build_section( - 'Engineering Consoles', 5, 'space', 'eng_consoles', True, 'eng_consoles_label') + 'Engineering Consoles', 5, 'space', 'eng_consoles', True, 'eng_consoles_label') layout.addLayout(eng_layout, 1, 5, alignment=ALEFT) sci_layout = self.create_build_section( - 'Science Consoles', 5, 'space', 'sci_consoles', True, 'sci_consoles_label') + 'Science Consoles', 5, 'space', 'sci_consoles', True, 'sci_consoles_label') layout.addLayout(sci_layout, 2, 5, alignment=ALEFT) tac_layout = self.create_build_section( - 'Tactical Consoles', 5, 'space', 'tac_consoles', True, 'tac_consoles_label') + 'Tactical Consoles', 5, 'space', 'tac_consoles', True, 'tac_consoles_label') layout.addLayout(tac_layout, 3, 5, alignment=ALEFT) - sep3 = self.create_frame(size_policy=SMAXMIN, style_override={ + sep3 = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ 'background-color': '@bg', 'margin-top': '@isp', 'margin-bottom': '@isp'}) - sep3.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + sep3.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) layout.addWidget(sep3, 0, 6, 5, 1) # Boffs @@ -524,7 +927,7 @@ def setup_space_build_frame(self): boff_5_layout = self.create_boff_station_space('Universal', 'Temporal', boff_id=4) layout.addLayout(boff_5_layout, 4, 7, alignment=ALEFT) boff_6_layout = self.create_boff_station_space('Universal', boff_id=5) - width_placeholder = self.create_combo_box(size_policy=SMAXMAX) + width_placeholder = create_combo_box2(self.theme, size_policy=SMAXMAX) width_placeholder.addItem('Engineering / Miracle Worker') width_placeholder_sizepolicy = width_placeholder.sizePolicy() width_placeholder_sizepolicy.setRetainSizeWhenHidden(True) @@ -544,19 +947,19 @@ def setup_space_build_frame(self): rep_trait_layout = self.create_build_section('Reputation Traits', 5, 'space', 'rep_traits') trait_layout.addLayout(rep_trait_layout, 2, 0) active_trait_layout = self.create_build_section( - 'Active Reputation Traits', 5, 'space', 'active_rep_traits') + 'Active Reputation Traits', 5, 'space', 'active_rep_traits') trait_layout.addLayout(active_trait_layout, 3, 0) layout.addLayout(trait_layout, 0, 9, 6, 1, alignment=ATOP) # Doffs - spacing = self.theme['defaults']['bw'] * self.config['ui_scale'] - doff_container = self.create_frame(size_policy=SMINMAX) + spacing = self.theme['defaults']['bw'] * self.theme.scale + doff_container = create_frame2(self.theme, size_policy=SMINMAX) doff_container_layout = VBoxLayout(spacing=spacing * 2) - doff_label = self.create_label('Space Duty Officers') + doff_label = create_label2(self.theme, 'Space Duty Officers') doff_container_layout.addWidget(doff_label, alignment=ALEFT) - doff_frame = self.create_frame('doff_frame', size_policy=SMINMAX) + doff_frame = create_frame2(self.theme, 'doff_frame', size_policy=SMINMAX) doff_frame_layout = VBoxLayout() - doff_style_nullifier = self.create_frame(size_policy=SMINMAX) + doff_style_nullifier = create_frame2(self.theme, size_policy=SMINMAX) doff_frame_layout.addWidget(doff_style_nullifier) doff_layout = self.create_doff_section('space') doff_style_nullifier.setLayout(doff_layout) @@ -571,35 +974,35 @@ def setup_ground_build_frame(self): """ Creates Ground build frame """ - frame = self.widgets.build_frames[1] - isp = self.theme['defaults']['isp'] * 2 * self.config['ui_scale'] + frame = self.tabbers.build_frames[1] + isp = self.theme['defaults']['isp'] * 2 * self.theme.scale layout = GridLayout(margins=isp, spacing=isp) layout.setColumnStretch(0, 1) layout.setColumnStretch(8, 1) layout.setRowStretch(5, 1) # Equipment - modules_layout = self.create_build_section('Kit Modules:', 6, 'ground', 'kit_modules', True) + modules_layout = self.create_build_section('Kit Modules', 6, 'ground', 'kit_modules', True) layout.addLayout(modules_layout, 0, 1, alignment=ALEFT) - weapons_layout = self.create_build_section('Weapons:', 2, 'ground', 'weapons', True) + weapons_layout = self.create_build_section('Weapons', 2, 'ground', 'weapons', True) layout.addLayout(weapons_layout, 1, 1, alignment=ALEFT) - devices_layout = self.create_build_section('Devices:', 5, 'ground', 'ground_devices', True) + devices_layout = self.create_build_section('Devices', 5, 'ground', 'ground_devices', True) layout.addLayout(devices_layout, 2, 1, alignment=ALEFT) - sep1 = self.create_frame(size_policy=SMAXMIN, style_override={ + sep1 = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ 'background-color': '@bg', 'margin-top': '@isp', 'margin-bottom': '@isp'}) - sep1.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + sep1.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) layout.addWidget(sep1, 0, 2) - kit_layout = self.create_build_section('Kit Frame:', 1, 'ground', 'kit', True) + kit_layout = self.create_build_section('Kit Frame', 1, 'ground', 'kit', True) layout.addLayout(kit_layout, 0, 3, alignment=ALEFT) - armor_layout = self.create_build_section('Armor:', 1, 'ground', 'armor', True) + armor_layout = self.create_build_section('Armor', 1, 'ground', 'armor', True) layout.addLayout(armor_layout, 1, 3, alignment=ALEFT) - ev_layout = self.create_build_section('EV Suit:', 1, 'ground', 'ev_suit', True) + ev_layout = self.create_build_section('EV Suit', 1, 'ground', 'ev_suit', True) layout.addLayout(ev_layout, 2, 3, alignment=ALEFT) - shield_layout = self.create_build_section('Shield:', 1, 'ground', 'personal_shield', True) + shield_layout = self.create_build_section('Shield', 1, 'ground', 'personal_shield', True) layout.addLayout(shield_layout, 3, 3, alignment=ALEFT) - sep2 = self.create_frame(size_policy=SMAXMIN, style_override={ + sep2 = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ 'background-color': '@bg', 'margin-top': '@isp', 'margin-bottom': '@isp'}) - sep2.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + sep2.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) layout.addWidget(sep2, 0, 4) # Boffs @@ -611,9 +1014,9 @@ def setup_ground_build_frame(self): layout.addLayout(boff_3_layout, 2, 5, alignment=ALEFT) boff_4_layout = self.create_boff_station_ground(boff_id=3) layout.addLayout(boff_4_layout, 3, 5, alignment=ALEFT) - sep3 = self.create_frame(size_policy=SMAXMIN, style_override={ + sep3 = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ 'background-color': '@bg', 'margin-top': '@isp', 'margin-bottom': '@isp'}) - sep3.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + sep3.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) layout.addWidget(sep3, 0, 6) # Traits @@ -623,19 +1026,19 @@ def setup_ground_build_frame(self): rep_trait_layout = self.create_build_section('Reputation Traits', 5, 'ground', 'rep_traits') trait_layout.addLayout(rep_trait_layout, 1, 0) active_trait_layout = self.create_build_section( - 'Active Reputation Traits', 5, 'ground', 'active_rep_traits') + 'Active Reputation Traits', 5, 'ground', 'active_rep_traits') trait_layout.addLayout(active_trait_layout, 2, 0) layout.addLayout(trait_layout, 0, 7, 4, 1, alignment=ATOP) # Doffs - spacing = self.theme['defaults']['bw'] * self.config['ui_scale'] - doff_container = self.create_frame(size_policy=SMINMAX) + spacing = self.theme['defaults']['bw'] * self.theme.scale + doff_container = create_frame2(self.theme, size_policy=SMINMAX) doff_container_layout = VBoxLayout(spacing=spacing * 2) - doff_label = self.create_label('Ground Duty Officers') + doff_label = create_label2(self.theme, 'Ground Duty Officers') doff_container_layout.addWidget(doff_label, alignment=ALEFT) - doff_frame = self.create_frame('doff_frame', size_policy=SMINMAX) + doff_frame = create_frame2(self.theme, 'doff_frame', size_policy=SMINMAX) doff_frame_layout = VBoxLayout() - doff_style_nullifier = self.create_frame(size_policy=SMINMAX) + doff_style_nullifier = create_frame2(self.theme, size_policy=SMINMAX) doff_frame_layout.addWidget(doff_style_nullifier) doff_layout = self.create_doff_section('ground') doff_style_nullifier.setLayout(doff_layout) @@ -647,19 +1050,19 @@ def setup_ground_build_frame(self): frame.setLayout(layout) # sidebar - sidebar_frame = self.widgets.sidebar_frames[1] - csp = self.theme['defaults']['csp'] * self.config['ui_scale'] + sidebar_frame = self.tabbers.sidebar_frames[1] + csp = self.theme['defaults']['csp'] * self.theme.scale sidebar_layout = GridLayout(margins=(csp, isp, csp, csp), spacing=csp) sidebar_layout.setColumnStretch(0, 1) - desc_label = self.create_label('Build Description:') + desc_label = create_label2(self.theme, 'Build Description:') sidebar_layout.addWidget(desc_label, 0, 0) desc_edit = QPlainTextEdit() - desc_edit.setStyleSheet(self.get_style_class('QPlainTextEdit', 'textedit')) - desc_edit.setFont(self.theme_font('textedit')) + desc_edit.setStyleSheet(self.theme.get_style_class('QPlainTextEdit', 'textedit')) + desc_edit.setFont(self.theme.get_font('textedit')) desc_edit.setWordWrapMode(QTextOption.WrapMode.WordWrap) - desc_edit.textChanged.connect(lambda: self.set_build_item( - self.build['ground'], 'ground_desc', desc_edit.toPlainText(), autosave=False)) - self.widgets.ground_desc = desc_edit + desc_edit.textChanged.connect(lambda: self.build.set( + 'ground', 'ground_desc', value=desc_edit.toPlainText(), autosave=False)) + self.build.ground.desc = desc_edit sidebar_layout.addWidget(desc_edit, 1, 0) sidebar_frame.setLayout(sidebar_layout) @@ -667,79 +1070,79 @@ def setup_character_frame(self, frame: QFrame): """ Creates character customization area. """ - csp = self.theme['defaults']['csp'] * self.config['ui_scale'] + csp = self.theme['defaults']['csp'] * self.theme.scale layout = GridLayout(margins=csp, spacing=csp) layout.setColumnStretch(1, 1) - seperator = self.create_frame(size_policy=SMINMAX, style_override={ - 'background-color': '@sets', 'margin': '@isp'}) - sep = self.theme['defaults']['sep'] * self.config['ui_scale'] - seperator.setFixedHeight(sep) + seperator = create_frame2(self.theme, size_policy=SMINMAX, style_override={ + 'background-color': '@sets', 'margin': '@isp'}) + seperator.setFixedHeight(self.theme['defaults']['sep'] * self.theme.scale) layout.addWidget(seperator, 0, 0, 1, 2, alignment=ATOP) # ATOP makes it respect the margin? - char_name = self.create_entry(placeholder='NAME') + char_name = create_entry2(self.theme, placeholder='NAME') char_name.setAlignment(AHCENTER) char_name.setSizePolicy(SMINMAX) char_name.editingFinished.connect( - lambda: self.set_build_item(self.build['captain'], 'name', char_name.text())) + lambda: self.build.set('captain', 'name', value=char_name.text())) layout.addWidget(char_name, 1, 0, 1, 2) - elite_label = self.create_label('Elite Captain') + self.build.character.name = char_name + elite_label = create_label2(self.theme, 'Elite Captain') layout.addWidget(elite_label, 2, 0, alignment=ARIGHT) - elite_checkbox = self.create_checkbox() - elite_checkbox.checkStateChanged.connect(self.elite_callback) + elite_checkbox = create_checkbox2(self.theme) + elite_checkbox.checkStateChanged.connect(self.build.elite_callback) layout.addWidget(elite_checkbox, 2, 1, alignment=ALEFT) - career_label = self.create_label('Captain Career') + self.build.character.elite = elite_checkbox + career_label = create_label2(self.theme, 'Captain Career') layout.addWidget(career_label, 3, 0, alignment=ARIGHT) - career_combo = self.create_combo_box() + career_combo = create_combo_box2(self.theme) career_combo.addItems({''} | CAREERS) career_combo.currentTextChanged.connect( - lambda t: self.set_build_item(self.build['captain'], 'career', t)) + lambda new_career: self.build.set('captain', 'career', value=new_career)) layout.addWidget(career_combo, 3, 1) - faction_label = self.create_label('Faction') + self.build.character.career = career_combo + faction_label = create_label2(self.theme, 'Faction') layout.addWidget(faction_label, 4, 0, alignment=ARIGHT) - faction_combo = self.create_combo_box() + faction_combo = create_combo_box2(self.theme) faction_combo.addItems({''} | FACTIONS) - faction_combo.currentTextChanged.connect(self.faction_combo_callback) + faction_combo.currentTextChanged.connect(self.build.faction_combo_callback) layout.addWidget(faction_combo, 4, 1) - species_label = self.create_label('Species') + self.build.character.faction = faction_combo + species_label = create_label2(self.theme, 'Species') layout.addWidget(species_label, 5, 0, alignment=ARIGHT) - species_combo = self.create_combo_box() + species_combo = create_combo_box2(self.theme) species_combo.addItems({''}) - species_combo.currentTextChanged.connect(lambda t: self.species_combo_callback(t)) + species_combo.currentTextChanged.connect(self.build.species_combo_callback) layout.addWidget(species_combo, 5, 1) - primary_label = self.create_label('Primary Spec') + self.build.character.species = species_combo + primary_label = create_label2(self.theme, 'Primary Spec') layout.addWidget(primary_label, 6, 0, alignment=ARIGHT) - primary_combo = self.create_combo_box() + primary_combo = create_combo_box2(self.theme) primary_combo.addItems({''} | PRIMARY_SPECS) - primary_combo.currentTextChanged.connect(lambda t: self.spec_combo_callback(True, t)) + primary_combo.currentTextChanged.connect( + lambda new_spec: self.build.spec_combo_callback(True, new_spec)) layout.addWidget(primary_combo, 6, 1) - secondary_label = self.create_label('Secondary Spec', style_override={'margin-bottom': 0}) + self.build.character.primary = primary_combo + secondary_label = create_label2( + self.theme, 'Secondary Spec', style_override={'margin-bottom': 0}) layout.addWidget(secondary_label, 7, 0, alignment=ARIGHT) - secondary_combo = self.create_combo_box() + secondary_combo = create_combo_box2(self.theme) secondary_combo.addItems({''} | PRIMARY_SPECS | SECONDARY_SPECS) - secondary_combo.currentTextChanged.connect(lambda t: self.spec_combo_callback(False, t)) + secondary_combo.currentTextChanged.connect( + lambda new_spec: self.build.spec_combo_callback(False, new_spec)) layout.addWidget(secondary_combo, 7, 1) + self.build.character.secondary = secondary_combo frame.setLayout(layout) - self.widgets.character = { - 'name': char_name, - 'elite': elite_checkbox, - 'career': career_combo, - 'faction': faction_combo, - 'species': species_combo, - 'primary': primary_combo, - 'secondary': secondary_combo, - } def setup_space_skill_frame(self): """ Creates Space skill GUI """ - frame = self.widgets.build_frames[2] - isp = self.theme['defaults']['isp'] * self.config['ui_scale'] - csp = self.theme['defaults']['csp'] * self.config['ui_scale'] + frame = self.tabbers.build_frames[2] + isp = self.theme['defaults']['isp'] * self.theme.scale + csp = self.theme['defaults']['csp'] * self.theme.scale col_layout = GridLayout(margins=isp, spacing=csp) col_layout.setRowStretch(0, 1) col_layout.setColumnStretch(0, 3) col_layout.setColumnStretch(2, 1) - scroll_frame = self.create_frame() + scroll_frame = create_frame2(self.theme) scroll_area = QScrollArea() scroll_area.setSizePolicy(SMINMIN) scroll_area.setHorizontalScrollBarPolicy(SCROLLOFF) @@ -754,6 +1157,7 @@ def setup_space_skill_frame(self): scroll_layout.setColumnStretch(3, 1) scroll_layout.setColumnStretch(4, 1) scroll_layout.setColumnStretch(5, 1) + # skill tree rank_texts = ( 'Lieutenant
(0 points required)', @@ -762,16 +1166,16 @@ def setup_space_skill_frame(self): 'Captain
(25 points required)', 'Admiral
(35 points required)' ) - sep_height = self.theme['hr']['height'] * self.config['ui_scale'] - for rank, skill_groups in enumerate(self.cache.skills['space']): + sep_height = self.theme['hr']['height'] * self.theme.scale + for rank, skill_groups in enumerate(self.cargo.skills['space']): header_layout = GridLayout(spacing=isp) - left_sep = self.create_frame('hr', size_policy=SMINMAX) + left_sep = create_frame2(self.theme, 'hr', size_policy=SMINMAX) left_sep.setFixedHeight(sep_height) header_layout.addWidget(left_sep, 0, 0, alignment=AVCENTER) - rank_label = self.create_label(rank_texts[rank], 'label_subhead') + rank_label = create_label2(self.theme, rank_texts[rank], 'label_subhead') rank_label.setAlignment(AHCENTER) header_layout.addWidget(rank_label, 0, 1) - right_sep = self.create_frame('hr', size_policy=SMINMAX) + right_sep = create_frame2(self.theme, 'hr', size_policy=SMINMAX) right_sep.setFixedHeight(sep_height) header_layout.addWidget(right_sep, 0, 2, alignment=AVCENTER) scroll_layout.addLayout(header_layout, rank * 3, 0, 1, 6) @@ -779,65 +1183,65 @@ def setup_space_skill_frame(self): id_offset = rank * 6 + (group_id % 2) * 3 group_layout = self.create_skill_group_space(group_data, id_offset) scroll_layout.addLayout(group_layout, rank * 3 + 1, group_id) - spacer = self.create_frame() + spacer = create_frame2(self.theme) spacer.setFixedHeight(isp) scroll_layout.addWidget(spacer, rank * 3 + 2, 0) VBoxLayout().addWidget(spacer) - scroll_frame.setLayout(scroll_layout) scroll_area.setWidget(scroll_frame) - seperator = self.create_frame(size_policy=SMAXMIN, style_override={ - 'background-color': '@sets'}) - seperator.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + seperator = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ + 'background-color': '@sets'}) + seperator.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) col_layout.addWidget(seperator, 0, 1) - bonus_bar_container = self.create_frame(size_policy=SMINMIN) + bonus_bar_container = create_frame2(self.theme, size_policy=SMINMIN) + # bonus bars bonus_bar_layout = GridLayout(margins=isp) bonus_bar_layout.setRowStretch(0, 1) bonus_bar_layout.setRowStretch(32, 1) self.create_bonus_bar_space('eng', bonus_bar_layout, 1) - eng_label = self.create_label('', style='unlock_label') - eng_label.setPixmap(self.cache.icons['eng']) + eng_label = create_label2(self.theme, '', style='unlock_label') + eng_label.setPixmap(self.theme.icons['eng']) bonus_bar_layout.addWidget(eng_label, 30, 1, alignment=AHCENTER) - eng_count = self.create_label('0', 'label_subhead') + eng_count = create_label2(self.theme, '0', 'label_subhead') bonus_bar_layout.addWidget(eng_count, 31, 1, alignment=AHCENTER) - self.widgets.skill_counts_space['eng'] = eng_count + self.build.skills.count_labels['eng'] = eng_count self.create_bonus_bar_space('sci', bonus_bar_layout, 2) - sci_label = self.create_label('', style='unlock_label') - sci_label.setPixmap(self.cache.icons['sci']) + sci_label = create_label2(self.theme, '', style='unlock_label') + sci_label.setPixmap(self.theme.icons['sci']) bonus_bar_layout.addWidget(sci_label, 30, 2, alignment=AHCENTER) - sci_count = self.create_label('0', 'label_subhead') + sci_count = create_label2(self.theme, '0', 'label_subhead') bonus_bar_layout.addWidget(sci_count, 31, 2, alignment=AHCENTER) - self.widgets.skill_counts_space['sci'] = sci_count + self.build.skills.count_labels['sci'] = sci_count self.create_bonus_bar_space('tac', bonus_bar_layout, 3) - tac_label = self.create_label('', style='unlock_label') - tac_label.setPixmap(self.cache.icons['tac']) + tac_label = create_label2(self.theme, '', style='unlock_label') + tac_label.setPixmap(self.theme.icons['tac']) bonus_bar_layout.addWidget(tac_label, 30, 3, alignment=AHCENTER) - tac_count = self.create_label('0', 'label_subhead') + tac_count = create_label2(self.theme, '0', 'label_subhead') bonus_bar_layout.addWidget(tac_count, 31, 3, alignment=AHCENTER) - self.widgets.skill_counts_space['tac'] = tac_count + self.build.skills.count_labels['tac'] = tac_count bonus_bar_container.setLayout(bonus_bar_layout) col_layout.addWidget(bonus_bar_container, 0, 2) frame.setLayout(col_layout) # sidebar - sidebar_frame = self.widgets.sidebar_frames[2] + sidebar_frame = self.tabbers.sidebar_frames[2] sidebar_layout = GridLayout(margins=(csp, isp * 2, csp, csp), spacing=csp) - desc_label = self.create_label('Space Skill Notes:') + desc_label = create_label2(self.theme, 'Space Skill Notes:') sidebar_layout.addWidget(desc_label, 0, 0, 1, 2) desc_edit = QPlainTextEdit() - desc_edit.setStyleSheet(self.get_style_class('QPlainTextEdit', 'textedit')) - desc_edit.setFont(self.theme_font('textedit')) + desc_edit.setStyleSheet(self.theme.get_style_class('QPlainTextEdit', 'textedit')) + desc_edit.setFont(self.theme.get_font('textedit')) desc_edit.setWordWrapMode(QTextOption.WrapMode.WordWrap) - desc_edit.textChanged.connect(lambda: self.set_build_item( - self.build['skill_desc'], 'space', desc_edit.toPlainText(), autosave=False)) - self.widgets.build['skill_desc']['space'] = desc_edit + desc_edit.textChanged.connect(lambda: self.build.set( + 'space', 'skill_desc', value=desc_edit.toPlainText(), autosave=False)) + self.build.skills.space_desc = desc_edit sidebar_layout.addWidget(desc_edit, 1, 0, 1, 2) - load_skills_button = self.create_button('Load Skills') - load_skills_button.clicked.connect(self.load_skills_callback) + load_skills_button = create_button2(self.theme, 'Load Skills') + load_skills_button.clicked.connect(self.build_loader.load_skills_callback) sidebar_layout.addWidget(load_skills_button, 2, 0, alignment=AHCENTER) - save_skills_button = self.create_button('Save Skills') - save_skills_button.clicked.connect(self.save_skills_callback) + save_skills_button = create_button2(self.theme, 'Save Skills') + save_skills_button.clicked.connect(self.build_loader.save_skills_callback) sidebar_layout.addWidget(save_skills_button, 2, 1, alignment=AHCENTER) sidebar_frame.setLayout(sidebar_layout) @@ -845,22 +1249,23 @@ def setup_ground_skill_frame(self): """ Creates Ground skill GUI """ - frame = self.widgets.build_frames[3] - isp = self.theme['defaults']['isp'] * self.config['ui_scale'] - csp = self.theme['defaults']['csp'] * self.config['ui_scale'] + frame = self.tabbers.build_frames[3] + isp = self.theme['defaults']['isp'] * self.theme.scale + csp = self.theme['defaults']['csp'] * self.theme.scale col_layout = GridLayout(margins=isp, spacing=csp) col_layout.setRowStretch(0, 1) col_layout.setColumnStretch(0, 3) col_layout.setColumnStretch(2, 1) - tree_frame = self.create_frame(size_policy=SMINMIN) + tree_frame = create_frame2(self.theme, size_policy=SMINMIN) col_layout.addWidget(tree_frame, 0, 0) + # skill tree tree_layout = GridLayout(spacing=5 * isp) tree_layout.setColumnStretch(0, 1) tree_layout.setColumnStretch(3, 1) tree_layout.setRowStretch(0, 1) tree_layout.setRowStretch(3, 1) - skills = self.cache.skills['ground'] + skills = self.cargo.skills['ground'] group_layout = GridLayout(spacing=csp) group_layout.addWidget(self.create_skill_button_ground(skills[0], 0, 0), 0, 1) group_layout.addWidget(self.create_skill_button_ground(skills[0], 1, 1), 1, 1) @@ -879,31 +1284,31 @@ def setup_ground_skill_frame(self): tree_layout.addLayout(group_layout, 1, 2) group_layout = GridLayout(spacing=csp) group_layout.addWidget( - self.create_skill_button_ground(skills[6], 0, 0), 0, 0, 1, 2, alignment=AHCENTER) + self.create_skill_button_ground(skills[6], 0, 0), 0, 0, 1, 2, alignment=AHCENTER) group_layout.addWidget( - self.create_skill_button_ground(skills[6], 1, 1), 1, 0, alignment=ARIGHT) + self.create_skill_button_ground(skills[6], 1, 1), 1, 0, alignment=ARIGHT) group_layout.addWidget( - self.create_skill_button_ground(skills[7], 2, 0), 1, 1, alignment=ALEFT) + self.create_skill_button_ground(skills[7], 2, 0), 1, 1, alignment=ALEFT) group_layout.addWidget( - self.create_skill_button_ground(skills[7], 3, 1), 2, 1, alignment=ALEFT) + self.create_skill_button_ground(skills[7], 3, 1), 2, 1, alignment=ALEFT) tree_layout.addLayout(group_layout, 2, 1) group_layout = GridLayout(spacing=csp) group_layout.addWidget( - self.create_skill_button_ground(skills[8], 0, 0), 0, 0, 1, 2, alignment=AHCENTER) + self.create_skill_button_ground(skills[8], 0, 0), 0, 0, 1, 2, alignment=AHCENTER) group_layout.addWidget( - self.create_skill_button_ground(skills[8], 1, 1), 1, 1, alignment=ALEFT) + self.create_skill_button_ground(skills[8], 1, 1), 1, 1, alignment=ALEFT) group_layout.addWidget( - self.create_skill_button_ground(skills[9], 2, 0), 1, 0, alignment=ARIGHT) + self.create_skill_button_ground(skills[9], 2, 0), 1, 0, alignment=ARIGHT) group_layout.addWidget( - self.create_skill_button_ground(skills[9], 3, 1), 2, 0, alignment=ARIGHT) + self.create_skill_button_ground(skills[9], 3, 1), 2, 0, alignment=ARIGHT) tree_layout.addLayout(group_layout, 2, 2) - tree_frame.setLayout(tree_layout) - seperator = self.create_frame(size_policy=SMAXMIN, style_override={ - 'background-color': '@sets'}) - seperator.setFixedWidth(self.theme['defaults']['sep'] * self.config['ui_scale']) + seperator = create_frame2(self.theme, size_policy=SMAXMIN, style_override={ + 'background-color': '@sets'}) + seperator.setFixedWidth(self.theme['defaults']['sep'] * self.theme.scale) col_layout.addWidget(seperator, 0, 1) - bonus_bar_container = self.create_frame(size_policy=SMINMIN) + bonus_bar_container = create_frame2(self.theme, size_policy=SMINMIN) + # bonus bars bonus_bar_layout = GridLayout(margins=isp) bonus_bar_layout.setRowStretch(0, 1) @@ -914,38 +1319,39 @@ def setup_ground_skill_frame(self): bonus_bar_layout.addWidget(seg1, row, 1, alignment=AHCENTER) seg2 = self.create_bonus_bar_segment('ground', i * 2 + 1) bonus_bar_layout.addWidget(seg2, row - 1, 1, alignment=AHCENTER) - button = self.create_item_button() - button.clicked.connect(lambda i=i: self.skill_unlock_callback('ground', i)) + button = create_item_button2(self.theme) + button.clicked.connect(lambda i=i: self.build.skill_unlock_callback('ground', i)) bonus_bar_layout.addWidget(button, row - 2, 1, alignment=AHCENTER) - self.widgets.build['skill_unlocks']['ground'][i] = button + self.build.skills.unlocks['ground'][i] = button row -= 3 - icon_label = self.create_label('', style='unlock_label') - icon_label.setPixmap(self.cache.icons['ground']) + icon_label = create_label2(self.theme, '', style='unlock_label') + icon_label.setPixmap(self.theme.icons['ground']) bonus_bar_layout.addWidget(icon_label, 16, 1, alignment=AHCENTER) - self.widgets.skill_count_ground = self.create_label('0', 'label_subhead') - bonus_bar_layout.addWidget(self.widgets.skill_count_ground, 17, 1, alignment=AHCENTER) + count_label = create_label2(self.theme, '0', 'label_subhead') + bonus_bar_layout.addWidget(count_label, 17, 1, alignment=AHCENTER) + self.build.skills.count_labels['ground'] = count_label bonus_bar_container.setLayout(bonus_bar_layout) col_layout.addWidget(bonus_bar_container, 0, 2) frame.setLayout(col_layout) # sidebar - sidebar_frame = self.widgets.sidebar_frames[3] + sidebar_frame = self.tabbers.sidebar_frames[3] sidebar_layout = GridLayout(margins=(csp, isp * 2, csp, csp), spacing=csp) - desc_label = self.create_label('Ground Skill Notes:') + desc_label = create_label2(self.theme, 'Ground Skill Notes:') sidebar_layout.addWidget(desc_label, 0, 0, 1, 2) desc_edit = QPlainTextEdit() - desc_edit.setStyleSheet(self.get_style_class('QPlainTextEdit', 'textedit')) - desc_edit.setFont(self.theme_font('textedit')) + desc_edit.setStyleSheet(self.theme.get_style_class('QPlainTextEdit', 'textedit')) + desc_edit.setFont(self.theme.get_font('textedit')) desc_edit.setWordWrapMode(QTextOption.WrapMode.WordWrap) - desc_edit.textChanged.connect(lambda: self.set_build_item( - self.build['skill_desc'], 'ground', desc_edit.toPlainText(), autosave=False)) - self.widgets.build['skill_desc']['ground'] = desc_edit + desc_edit.textChanged.connect(lambda: self.build.set( + 'ground', 'skill_desc', value=desc_edit.toPlainText(), autosave=False)) + self.build.skills.ground_desc = desc_edit sidebar_layout.addWidget(desc_edit, 1, 0, 1, 2) - load_skills_button = self.create_button('Load Skills') - load_skills_button.clicked.connect(self.load_skills_callback) + load_skills_button = create_button2(self.theme, 'Load Skills') + load_skills_button.clicked.connect(self.build_loader.load_skills_callback) sidebar_layout.addWidget(load_skills_button, 2, 0, alignment=AHCENTER) - save_skills_button = self.create_button('Save Skills') - save_skills_button.clicked.connect(self.save_skills_callback) + save_skills_button = create_button2(self.theme, 'Save Skills') + save_skills_button.clicked.connect(self.build_loader.save_skills_callback) sidebar_layout.addWidget(save_skills_button, 2, 1, alignment=AHCENTER) sidebar_frame.setLayout(sidebar_layout) @@ -953,33 +1359,23 @@ def setup_splash(self, frame: QFrame): """ Creates Splash screen. """ - layout = GridLayout(margins=0, spacing=0) + layout = GridLayout() layout.setRowStretch(0, 1) - layout.setRowStretch(3, 1) + layout.setRowStretch(4, 1) layout.setColumnStretch(0, 3) layout.setColumnStretch(1, 2) layout.setColumnStretch(2, 3) - loading_image = ImageLabel(get_asset_path('sets_loading.png', self.app_dir), (1, 1)) + loading_image = ImageLabel(self.app_dir2 / 'local' / 'sets_loading.png', (1, 1)) layout.addWidget(loading_image, 1, 1) - loading_label = self.create_label('Loading: ...', 'label_subhead') - self.widgets.loading_label = loading_label + loading_label = create_label2(self.theme, 'Loading: ...', 'label_subhead') + self.splash.loading_label = loading_label layout.addWidget(loading_label, 2, 0, 1, 3, alignment=AHCENTER) + progress_label = create_label2( + self.theme, '', 'label_subhead', style_override={'font': ('Roboto Mono', 11, 'normal')}) + self.splash.progress_label = progress_label + layout.addWidget(progress_label, 3, 0, 1, 3, alignment=AHCENTER) frame.setLayout(layout) - def create_context_menu(self) -> ContextMenu: - """ - Creates context menu for rightclick operations on equipment items - """ - menu = ContextMenu() - menu.setStyleSheet(self.get_style_class('ContextMenu', 'context_menu')) - menu.setFont(self.theme_font('context_menu')) - menu.addAction(self.cache.icons['copy'], 'Copy Item', self.copy_equipment_item) - menu.addAction(self.cache.icons['paste'], 'Paste Item', self.paste_equipment_item) - menu.addAction(self.cache.icons['clear'], 'Clear Slot', self.clear_slot) - menu.addAction(self.cache.icons['link'], 'Open Wiki', self.open_wiki_context) - menu.addAction(self.cache.icons['edit'], 'Edit Slot', self.edit_equipment_item) - return menu - def hide_tooltips(self): """ Hides tooltip windows when main window isn't the active window anymore. @@ -989,128 +1385,162 @@ def hide_tooltips(self): if window.type() == Qt.WindowType.ToolTip: window.hide() + def set_library_path(self, entry_widget: QLineEdit): + """ + Formats and stores new library path to `library_path`. + + Parameters: + - :param entry_widget: the entry that holds the path + """ + formatted_path = format_path(entry_widget.text()) + self.settings.library_path = formatted_path + entry_widget.setText(formatted_path) + + def browse_library_path(self, entry_widget: QLineEdit): + """ + Browses for new library path, formats and stores new library path to `library_path`. + + Parameters: + - :param entry_widget: the entry that holds the path + """ + new_path = browse_path(self.config.home_dir, folder=True, parent_window=self.window) + if new_path is not None: + formatted_path = format_path(str(new_path)) + self.settings.library_path = formatted_path + entry_widget.setText(formatted_path) + def setup_settings_frame(self): """ Populates the settings frame. """ - settings_frame = self.widgets.build_frames[5] - isp = self.theme['defaults']['isp'] * self.config['ui_scale'] + settings_frame = self.tabbers.build_frames[5] + isp = self.theme['defaults']['isp'] * self.theme.scale settings_layout = HBoxLayout(margins=(2 * isp, isp, isp, isp), spacing=isp) scroll_layout = VBoxLayout(margins=(0, isp, 0, 0), spacing=isp) scroll_layout.setSpacing(isp) - scroll_frame = self.create_frame() + scroll_frame = create_frame2(self.theme) scroll_area = QScrollArea() scroll_area.setSizePolicy(SMINMIN) scroll_area.setHorizontalScrollBarPolicy(SCROLLOFF) scroll_area.setVerticalScrollBarPolicy(SCROLLON) - # scroll_area.setAlignment(AHCENTER) settings_layout.addWidget(scroll_area) settings_frame.setLayout(settings_layout) # first section - settings_header = self.create_label('Settings:', 'label_heading') + settings_header = create_label2(self.theme, 'Settings:', 'label_heading') scroll_layout.addWidget(settings_header, alignment=ALEFT) sec_1 = GridLayout(spacing=isp) sec_1.setColumnMinimumWidth(1, 3 * isp) sec_1.setColumnMinimumWidth(2, 12 * isp) sec_1.setColumnMinimumWidth(3, 3 * isp) sec_1.setColumnStretch(5, 1) - ui_scale_label = self.create_label('UI Scale') + ui_scale_label = create_label2(self.theme, 'UI Scale') sec_1.addWidget(ui_scale_label, 0, 0, alignment=ALEFT) - ui_scale_slider = self.create_annotated_slider( - default_value=round(self.settings.value('ui_scale', type=float) * 50, 0), - min=25, max=75, callback=self.set_ui_scale_setting) + ui_scale_slider = create_annotated_slider2( + self.theme, default_value=round(self.settings.ui_scale * 50, 0), min=25, max=75, + callback=self.settings.set_ui_scale) sec_1.addLayout(ui_scale_slider, 0, 2, alignment=ALEFT) - ui_scale_desc = self.create_label('Requires restart.', 'hint_label') + ui_scale_desc = create_label2(self.theme, 'Requires restart.', 'hint_label') sec_1.addWidget(ui_scale_desc, 0, 4, alignment=ALEFT) - mark_label = self.create_label('Default Mark') + mark_label = create_label2(self.theme, 'Default Mark') sec_1.addWidget(mark_label, 1, 0, alignment=ALEFT) - mark_combo = self.create_combo_box(style_override={'font': '@small_text'}) + mark_combo = create_combo_box2(self.theme, style_override={'font': '@small_text'}) mark_combo.addItems(('',) + MARKS) - mark_combo.setCurrentText(self.settings.value('default_mark')) + mark_combo.setCurrentText(self.settings.default_mark) mark_combo.currentTextChanged.connect( - lambda new_mark: self.settings.setValue('default_mark', new_mark)) + lambda new_mark: self.settings.set('default_mark', new_mark)) sec_1.addWidget(mark_combo, 1, 2, alignment=ALEFT) - rarity_label = self.create_label('Default Rarity') + rarity_label = create_label2(self.theme, 'Default Rarity') sec_1.addWidget(rarity_label, 2, 0, alignment=ALEFT) - rarity_combo = self.create_combo_box(style_override={'font': '@small_text'}) + rarity_combo = create_combo_box2(self.theme, style_override={'font': '@small_text'}) rarity_combo.addItems(RARITIES.keys()) - rarity_combo.setCurrentText(self.settings.value('default_rarity')) + rarity_combo.setCurrentText(self.settings.default_rarity) rarity_combo.currentTextChanged.connect( - lambda new_rarity: self.settings.setValue('default_rarity', new_rarity)) + lambda new_rarity: self.settings.set('default_rarity', new_rarity)) sec_1.addWidget(rarity_combo, 2, 2, alignment=ALEFT | AVCENTER) - picker_rel_label = self.create_label('Picker Position') + picker_rel_label = create_label2(self.theme, 'Picker Position') sec_1.addWidget(picker_rel_label, 3, 0, alignment=ALEFT) - picker_rel_combo = self.create_combo_box(style_override={'font': '@small_text'}) + picker_rel_combo = create_combo_box2(self.theme, style_override={'font': '@small_text'}) picker_rel_combo.addItems(('Absolute', 'Relative')) - picker_rel_combo.setCurrentIndex(self.settings.value('picker_relative', type=int)) + picker_rel_combo.setCurrentIndex(self.settings.picker_relative) picker_rel_combo.currentIndexChanged.connect( - lambda new_i: self.settings.setValue('picker_relative', new_i)) + lambda new_i: self.settings.set('picker_relative', new_i)) sec_1.addWidget(picker_rel_combo, 3, 2, alignment=ALEFT | AVCENTER) - picker_rel_label = self.create_label('Default Save Format') + picker_rel_label = create_label2(self.theme, 'Default Save Format') sec_1.addWidget(picker_rel_label, 4, 0, alignment=ALEFT) - picker_rel_combo = self.create_combo_box(style_override={'font': '@small_text'}) + picker_rel_combo = create_combo_box2(self.theme, style_override={'font': '@small_text'}) picker_rel_combo.addItems(('JSON', 'PNG')) - picker_rel_combo.setCurrentText(self.settings.value('default_save_format')) + picker_rel_combo.setCurrentText(self.settings.default_save_format) picker_rel_combo.currentTextChanged.connect( - lambda new_t: self.settings.setValue('default_save_format', new_t)) + lambda new_t: self.settings.set('default_save_format', new_t)) sec_1.addWidget(picker_rel_combo, 4, 2, alignment=ALEFT | AVCENTER) - backup_label = self.create_label('Preferred Backup') + backup_label = create_label2(self.theme, 'Preferred Backup') sec_1.addWidget(backup_label, 5, 0, alignment=ALEFT) - backup_combo = self.create_combo_box(style_override={'font': '@small_text'}) + backup_combo = create_combo_box2(self.theme, style_override={'font': '@small_text'}) backup_combo.addItems(('Auto', 'Manual')) - backup_combo.setCurrentIndex(self.settings.value('pref_backup', type=int)) + backup_combo.setCurrentIndex(self.settings.pref_backup) backup_combo.currentIndexChanged.connect( - lambda new_i: self.settings.setValue('pref_backup', new_i)) + lambda new_i: self.settings.set('pref_backup', new_i)) sec_1.addWidget(backup_combo, 5, 2, alignment=ALEFT | AVCENTER) + library_path_label = create_label2(self.theme, 'Library Folder') + sec_1.addWidget(library_path_label, 6, 0, alignment=ALEFT) + library_path_entry = create_entry2( + self.theme, self.settings.library_path, style_override={'font': '@small_text'}) + library_path_entry.setSizePolicy(SMIXMAX) + library_path_entry.editingFinished.connect( + lambda: self.set_library_path(library_path_entry)) + sec_1.addWidget(library_path_entry, 6, 2) + library_path_button = create_button2(self.theme, 'Browse') + library_path_button.clicked.connect(lambda: self.browse_library_path(library_path_entry)) + sec_1.addWidget(library_path_button, 6, 4) scroll_layout.addLayout(sec_1) # second section - sep = self.create_frame() + sep = create_frame2(self.theme) sep.setFixedHeight(isp) scroll_layout.addWidget(sep) - maintenance_header = self.create_label('Maintenance:', 'label_heading') + maintenance_header = create_label2(self.theme, 'Maintenance:', 'label_heading') scroll_layout.addWidget(maintenance_header, alignment=ALEFT) sec_2 = GridLayout(spacing=isp) sec_2.setColumnMinimumWidth(1, 3 * isp) sec_2.setColumnStretch(3, 1) - cargo_clear_button = self.create_button('Clear Cargo Data') + cargo_clear_button = create_button2(self.theme, 'Clear Cargo Data') cargo_clear_button.clicked.connect( - lambda: delete_folder_contents(self.config['config_subfolders']['cargo'])) + lambda: delete_folder_contents(self.config.config_subfolders['cargo'])) sec_2.addWidget(cargo_clear_button, 0, 0, alignment=ALEFT) - cargo_clear_label = self.create_label( - 'Clears cargo data. Restart to refresh data.', 'hint_label') + cargo_clear_label = create_label2( + self.theme, 'Clears cargo data. Restart to refresh data.', 'hint_label') sec_2.addWidget(cargo_clear_label, 0, 2, alignment=ALEFT) - cache_clear_button = self.create_button('Clear Cache') + cache_clear_button = create_button2(self.theme, 'Clear Cache') cache_clear_button.clicked.connect( - lambda: delete_folder_contents(self.config['config_subfolders']['cache'])) + lambda: delete_folder_contents(self.config.config_subfolders['cache'])) sec_2.addWidget(cache_clear_button, 1, 0, alignment=ALEFT) - cache_clear_label = self.create_label( - 'Clears cache. Restart to rebuild cache.', 'hint_label') + cache_clear_label = create_label2( + self.theme, 'Clears cache. Restart to rebuild cache.', 'hint_label') sec_2.addWidget(cache_clear_label, 1, 2, alignment=ALEFT) - backup_cargo_button = self.create_button('Backup Cargo Data') - backup_cargo_button.clicked.connect(self.backup_cargo_data) + backup_cargo_button = create_button2(self.theme, 'Backup Cargo Data') + backup_cargo_button.clicked.connect(self.cargo.backup_cargo_data) sec_2.addWidget(backup_cargo_button, 2, 0, alignment=ALEFT) - backup_cargo_label = self.create_label( - 'Creates cargo backup to protect against download failures.', 'hint_label') + backup_cargo_label = create_label2( + self.theme, 'Creates cargo backup to protect against download failures.', 'hint_label') sec_2.addWidget(backup_cargo_label, 2, 2, alignment=ALEFT) scroll_layout.addLayout(sec_2) # third section - sep = self.create_frame() + sep = create_frame2(self.theme) sep.setFixedHeight(isp) scroll_layout.addWidget(sep) - compatibility_header = self.create_label('Compatibility:', 'label_heading') + compatibility_header = create_label2(self.theme, 'Compatibility:', 'label_heading') scroll_layout.addWidget(compatibility_header, alignment=ALEFT) sec_3 = GridLayout(spacing=isp) sec_3.setColumnMinimumWidth(1, 3 * isp) sec_3.setColumnStretch(3, 1) - build_image_button = self.create_button('Convert Legacy Build Image') - build_image_button.clicked.connect(self.load_legacy_build_image) + build_image_button = create_button2(self.theme, 'Convert Legacy Build Image') + build_image_button.clicked.connect(self.build_loader.load_legacy_build_image) sec_3.addWidget(build_image_button, 0, 0, alignment=ALEFT) - build_image_label = self.create_label( - 'Loads build from legacy build image. Use the "Load" button to load legacy ' - 'JSON build files.', 'hint_label') + build_image_label = create_label2( + self.theme, 'Loads build from legacy build image. Use the "Load" button to load ' + 'legacy JSON build files.', 'hint_label') sec_3.addWidget(build_image_label, 0, 2, alignment=ALEFT) scroll_layout.addLayout(sec_3) @@ -1118,46 +1548,47 @@ def setup_settings_frame(self): scroll_area.setWidget(scroll_frame) # sidebar - sidebar_frame = self.widgets.sidebar_frames[5] - csp = self.theme['defaults']['csp'] * self.config['ui_scale'] + sidebar_frame = self.tabbers.sidebar_frames[5] + csp = self.theme['defaults']['csp'] * self.theme.scale sidebar_layout = VBoxLayout(margins=csp, spacing=isp) sidebar_layout.setAlignment(ATOP) - sidebar_layout.addWidget(self.create_label('About SETS:', 'label_heading'), alignment=ALEFT) - about_label = self.create_label( - 'Thank you for using the STO Equipment and Trait Selector (SETS)! Make sure to ' - 'check out other projects of the STO Community Developers on our Github page and ' - 'contact us on Discord for support.') + sidebar_layout.addWidget( + create_label2(self.theme, 'About SETS:', 'label_heading'), alignment=ALEFT) + about_label = create_label2( + self.theme, 'Thank you for using the STO Equipment and Trait Selector (SETS)! Make ' + 'sure to check out other projects of the STO Community Developers on our Github page ' + 'and contact us on Discord for support.') about_label.setWordWrap(True) about_label.setMinimumWidth(50) # to fix the word wrap about_label.setSizePolicy(SMINMAX) sidebar_layout.addWidget(about_label) link_button_style = { 'Website': { - 'callback': lambda: open_url(self.config['link_website']), 'align': AHCENTER}, + 'callback': lambda: open_url(self.config.link_website), 'align': AHCENTER}, 'Github': { - 'callback': lambda: open_url(self.config['link_github']), 'align': AHCENTER}, + 'callback': lambda: open_url(self.config.link_github), 'align': AHCENTER}, 'STOBuilds Discord': { - 'callback': lambda: open_url(self.config['link_discord']), 'align': AHCENTER}, + 'callback': lambda: open_url(self.config.link_discord), 'align': AHCENTER}, 'Downloads': { - 'callback': lambda: open_url(self.config['link_downloads']), 'align': AHCENTER} + 'callback': lambda: open_url(self.config.link_downloads), 'align': AHCENTER} } - button_layout, buttons = self.create_button_series( - link_button_style, 'button', shape='column', ret=True) - buttons[0].setToolTip(self.config['link_website']) - buttons[1].setToolTip(self.config['link_github']) - buttons[2].setToolTip(self.config['link_discord']) - buttons[3].setToolTip(self.config['link_downloads']) - link_button_frame = self.create_frame() + button_layout, buttons = create_button_series2( + self.theme, link_button_style, 'button', shape='column', ret=True) + buttons[0].setToolTip(self.config.link_website) + buttons[1].setToolTip(self.config.link_github) + buttons[2].setToolTip(self.config.link_discord) + buttons[3].setToolTip(self.config.link_downloads) + link_button_frame = create_frame2(self.theme) link_button_frame.setLayout(button_layout) sidebar_layout.addWidget(link_button_frame, alignment=AHCENTER) sidebar_frame.setLayout(sidebar_layout) - footer_frame = self.widgets.character_frames[2] + footer_frame = self.tabbers.character_frames[2] footer_layout = GridLayout(margins=csp, spacing=isp) - version_label = self.create_label( - f"Version: {self.versions[0]}\n({self.versions[1]})", 'hint_label') + version_label = create_label2( + self.theme, f"Version: {self.version}", 'hint_label') footer_layout.addWidget(version_label, 0, 0, alignment=ALEFT | ABOTTOM) - stocd_label = self.create_label('') - stocd_label.setPixmap(self.cache.icons['STOCD']) + stocd_label = create_label2(self.theme, '') + stocd_label.setPixmap(self.theme.icons['STOCD']) footer_layout.addWidget(stocd_label, 0, 1, alignment=ARIGHT | ABOTTOM) footer_frame.setLayout(footer_layout) diff --git a/src/buildhelpers.py b/src/buildhelpers.py new file mode 100644 index 0000000..a05f9a4 --- /dev/null +++ b/src/buildhelpers.py @@ -0,0 +1,168 @@ +from .constants import BOFF_RANKS, BUILD_VERSION + + +def empty_build(build_type: str = 'full') -> dict[str, int | dict[str]]: + """ + Creates empty build and returns it. + + Parameters: + - :param build_type: `build` -> space and ground build; `skills` -> space and ground skills; + `full` -> space and ground build and skills + """ + # None means not available on the build; empty string means empty slot + new_build = { + '_version': BUILD_VERSION, + 'space': { + 'active_rep_traits': [None] * 5, + 'aft_weapons': [None] * 5, + 'boffs': [[None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4], + 'boff_specs': [[None, None]] * 6, + 'core': [''], + 'deflector': [''], + 'devices': [None] * 6, + 'doffs_spec': [''] * 6, + 'doffs_variant': [''] * 6, + 'eng_consoles': [None] * 5, + 'engines': [''], + 'experimental': [None], + 'fore_weapons': [None] * 5, + 'hangars': [None] * 2, + 'rep_traits': [None] * 5, + 'sci_consoles': [None] * 5, + 'sec_def': [None], + 'shield': [''], + 'ship': '', + 'ship_name': '', + 'ship_desc': '', + 'starship_traits': [None] * 7, + 'tac_consoles': [None] * 5, + 'tier': '', + 'traits': ['', '', '', '', '', '', '', '', '', None, None, ''], + 'uni_consoles': [None] * 3, + }, + 'ground': { + 'active_rep_traits': [None] * 5, + 'armor': [''], + 'boffs': [[''] * 4, [''] * 4, [''] * 4, [''] * 4], + 'boff_profs': ['Tactical'] * 4, + 'boff_specs': ['Command'] * 4, + 'ground_desc': '', + 'ground_devices': ['', '', '', '', None], + 'doffs_spec': [''] * 6, + 'doffs_variant': [''] * 6, + 'ev_suit': [''], + 'kit': [''], + 'kit_modules': ['', '', '', '', '', None], + 'rep_traits': [''] * 5, + 'personal_shield': [''], + 'traits': ['', '', '', '', '', '', '', '', '', None, None, ''], + 'weapons': [''] * 2, + }, + 'captain': { + 'career': '', + 'elite': False, + 'faction': '', + 'name': '', + 'primary_spec': '', + 'secondary_spec': '', + 'species': '', + }, + } + + new_skills = { + '_version': BUILD_VERSION, + 'space_skills': { + 'eng': [False] * 30, + 'sci': [False] * 30, + 'tac': [False] * 30, + }, + 'skill_unlocks': { + 'eng': [None] * 5, + 'sci': [None] * 5, + 'tac': [None] * 5, + 'ground': [None] * 5 + }, + 'ground_skills': [ + [False] * 6, + [False] * 6, + [False] * 4, + [False] * 4 + ], + 'skill_desc': { + 'space': '', + 'ground': '' + } + } + + if build_type == 'build': + return new_build + elif build_type == 'full': + new_build.update(new_skills) + return new_build + elif build_type == 'skills': + return new_skills + + +def get_variable_slot_counts(ship_data: dict[str], ship_tier: str) -> tuple[int]: + """ + returns the number of universal consoles, devices and starship traits the given ship build + should have + + Parameters: + - :param ship_data: ship specifications + - :param ship_tier: selected ship tier + + :return: 6-tuple containing universal consoles, engineering consoles, science consoles, \ + tactical consoles, devices, starship traits + """ + if ship_data['name'] == '': + uni_consoles = 3 + starship_traits = 7 + devices = 6 + eng_consoles = 5 + sci_consoles = 5 + tac_consoles = 5 + else: + uni_consoles = 0 + starship_traits = 5 + devices = ship_data['devices'] + eng_consoles = ship_data['consoleseng'] + sci_consoles = ship_data['consolessci'] + tac_consoles = ship_data['consolestac'] + if 'Innovation Effects' in ship_data['abilities']: + uni_consoles += 1 + elif ship_data['name'] == 'Federation Intel Holoship': + uni_consoles += 1 + if '-X2' in ship_tier: + uni_consoles += 2 + starship_traits += 2 + devices += 2 + elif '-X' in ship_tier: + uni_consoles += 1 + starship_traits += 1 + devices += 1 + if ship_tier.startswith(('T5-U', 'T5-X')): + if ship_data['t5uconsole'] == 'eng': + eng_consoles += 1 + elif ship_data['t5uconsole'] == 'sci': + sci_consoles += 1 + elif ship_data['t5uconsole'] == 'tac': + tac_consoles += 1 + return uni_consoles, eng_consoles, sci_consoles, tac_consoles, devices, starship_traits + + +def get_boff_spec(seat_details: str) -> tuple[int, str, str]: + """ + Returns rank, profession and specialization from cargo string + + Parameters: + - :param seat_details: contains rank, profession and specialization: + " -" + """ + if '-' in seat_details: + rank_and_profession, spec = seat_details.split('-') + else: + rank_and_profession = seat_details + spec = '' + rank_name, _, profession = rank_and_profession.rpartition(' ') + return (BOFF_RANKS[rank_name], profession, spec) diff --git a/src/buildloader.py b/src/buildloader.py new file mode 100644 index 0000000..b1cb513 --- /dev/null +++ b/src/buildloader.py @@ -0,0 +1,554 @@ +from json import dumps as json__dumps, JSONDecodeError, loads as json__loads +from numpy import ( + array as np__array, append as np__append, fromiter as np__fromiter, packbits as np__packbits, + uint8, unpackbits as np__unpackbits, zeros as np__zeros) +from pathlib import Path +from zlib import compress as zlib_compress, decompress as zlib_decompress + +from PySide6.QtGui import QImage +from PySide6.QtWidgets import QWidget + +from .buildhelpers import empty_build, get_boff_spec +from .buildmanager import BuildManager +from .cargomanager import CargoManager +from .config import SETSConfig, SETSSettings +from .constants import BUILD_CONVERSION, BUILD_VERSION, SETS_FILE_FILTER +from .iofunc import browse_path, load_json, store_json +from .widgets import bundle, pixel_range + + +class BuildLoader(): + """Loads/saves build files from/to disk.""" + + def __init__( + self, build: BuildManager, cargo: CargoManager, config: SETSConfig, + settings: SETSSettings, window: QWidget): + self._build: BuildManager = build + self._cargo: CargoManager = cargo + self._config: SETSConfig = config + self._settings: SETSSettings = settings + self._window: QWidget = window + self._current_build_path: Path | None = None + + def load_build_callback(self): + """ + Loads build from file + """ + load_path = browse_path( + self.get_library_path(), SETS_FILE_FILTER, parent_window=self._window) + if load_path is not None: + self.load_build_file(load_path) + self._current_build_path = load_path + + def save_build_callback(self): + """ + Saves build to file it was opened from, overwriting that file. + """ + if self._current_build_path is None: + self.save_build_as_callback() + else: + self.save_build_file(self._current_build_path) + + def save_build_as_callback(self): + """ + Saves build to file + """ + if self._build.ship.button.text() == '': + proposed_filename = '(Ship Template)' + else: + proposed_filename = f"({self._build['space']['ship']})" + if self._build['space']['ship_name'] != '': + proposed_filename = f"{self._build['space']['ship_name']} {proposed_filename}" + if self._settings.default_save_format == 'PNG': + file_types = 'PNG image (*.png);;JSON file (*.json);;Any File (*.*)' + proposed_filename += '.png' + else: + file_types = 'JSON file (*.json);;PNG image (*.png);;Any File (*.*)' + proposed_filename += '.json' + preset_path = self.get_library_path() / proposed_filename + save_path = browse_path(preset_path, file_types, save=True, parent_window=self._window) + if save_path is not None: + self.save_build_file(save_path) + self._current_build_path = save_path + + def load_skills_callback(self): + """ + Loads skills from file + """ + load_path = browse_path( + self.get_library_path(), SETS_FILE_FILTER, parent_window=self._window) + if load_path is not None: + self.load_skill_tree_file(load_path) + + def save_skills_callback(self): + """ + Save skills to file + """ + if self._settings.default_save_format == 'PNG': + file_types = 'PNG image (*.png);;JSON file (*.json);;Any File (*.*)' + extension = '.png' + else: + file_types = 'JSON file (*.json);;PNG image (*.png);;Any File (*.*)' + extension = '.json' + preset_path = self.get_library_path() / f'Skill Tree{extension}' + save_path = browse_path(preset_path, file_types, save=True, parent_window=self._window) + if save_path is not None: + self.save_skill_tree_file(save_path) + + def load_build_file(self, filepath: Path, update_ui: bool = True): + """ + Loads build from json or png file and puts it into self.build + + Parameters: + - :param filepath: path to build file + """ + extension = filepath.suffix.lower() + if extension == '.json': + build_data = load_json(filepath) + elif extension == '.png': + decoded_str = self.decode_from_image(QImage(filepath)) + if decoded_str == '': + return + build_data = json__loads(decoded_str) + else: + return + new_build = empty_build() + if build_data.get('_version', -1) == BUILD_VERSION: + self.merge_build(new_build, build_data) + elif 'versionJSON' in build_data: + build_data = json__loads(self.compensate_old_build(json__dumps(build_data))) + new_build.update(self.convert_old_build(build_data)) + self.update_build_version(new_build) + else: + self.merge_build(new_build, build_data) + self.update_build_version(new_build) + self._build.data = new_build + if update_ui: + try: + self._build.load_build() + except KeyError: + self.remove_invalid_build_items(self._build.data) + self._build.load_build() + + def save_build_file(self, filepath: Path): + """ + Saves build to json or png file + + Parameters: + - :param filepath: path to build file + """ + extension = filepath.suffix.lower() + if extension == '.json': + store_json(self._build.data, filepath) + elif extension == '.png': + image = self._window.grab().toImage() + self.encode_in_image(image, json__dumps(self._build.data)) + image.save(filepath) + + def load_skill_tree_file(self, filepath: Path): + """ + Loads skill tree from json or png file and puts it into self.build + + Parameters: + - :param filepath: path to skill tree file + """ + extension = filepath.suffix.lower() + if extension == '.json': + build_data = load_json(filepath) + elif extension == '.png': + decoded_str = self.decode_from_image(QImage(filepath)) + if decoded_str == '': + return + build_data = json__loads(decoded_str) + else: + return + new_build = empty_build('skills') + self.merge_build(new_build, build_data) + self._build.data['space_skills'] = new_build['space_skills'] + self._build.data['ground_skills'] = new_build['ground_skills'] + self._build.data['skill_unlocks'] = new_build['skill_unlocks'] + self._build.data['skill_desc'] = new_build['skill_desc'] + self._build.load_skill_pages() + + def save_skill_tree_file(self, filepath: Path): + """ + Saves skill tree to json or png file + + Parameters: + - :param filepath: path to skill tree file + """ + extension = filepath.suffix.lower() + skill_tree = { + 'space_skills': self._build['space_skills'], + 'ground_skills': self._build['ground_skills'], + 'skill_unlocks': self._build['skill_unlocks'], + 'skill_desc': self._build['skill_desc'], + } + if extension == '.json': + store_json(skill_tree, filepath) + elif extension == '.png': + image = self._window.grab().toImage() + self.encode_in_image(image, json__dumps(skill_tree)) + image.save(filepath) + + def get_library_path(self) -> Path: + """ + Returns current library path. + """ + if self._settings.library_path != '': + path = Path(self._settings.library_path) + if path.is_dir(): + return path + return self._config.config_subfolders['library'] + + def merge_build(self, original_build: dict[str, dict[str]], new_build: dict[str, dict[str]]): + """ + updates `original_build` with contents of `new_build` + """ + for build_segment in original_build: + subdict = new_build.get(build_segment, None) + if subdict is None: + continue + if isinstance(subdict, dict): + original_build[build_segment].update(subdict) + else: + original_build[build_segment] = subdict + + def update_build_version(self, build: dict[str]): + """ + Updates contents of `build` to match the newest version. + + Parameters: + - :param build: contains build data of outdated version + """ + def _fix_boff_seat(environment): + for rank_id in range(4): + if isinstance(boff_seat[rank_id], dict) and 'rank' not in boff_seat[rank_id]: + ability_name = boff_seat[rank_id]['item'] + prof_abilities = self._cargo.boff_abilities[environment][prof] + spec_abilities = self._cargo.boff_abilities[environment].get(spec, None) + for rank in ('III', 'II', 'I'): + if f'{ability_name} {rank}' in prof_abilities[rank_id]: + boff_seat[rank_id]['rank'] = rank + break + elif (spec_abilities is not None + and f'{ability_name} {rank}' in spec_abilities[rank_id]): + boff_seat[rank_id]['rank'] = rank + break + else: + boff_seat[rank_id] = '' + + for boff_seat, (prof, spec) in zip(build['space']['boffs'], build['space']['boff_specs']): + _fix_boff_seat('space') + for station_id, boff_seat in enumerate(build['ground']['boffs']): + prof = build['ground']['boff_profs'][station_id] + spec = build['ground']['boff_specs'][station_id] + _fix_boff_seat('ground') + + alt_images_inverted = {image: key for key, image in self._cargo.alt_images.items()} + alt_image_items = bundle( + build['space']['traits'], build['ground']['traits'], build['ground']['rep_traits']) + for trait in alt_image_items: + if isinstance(trait, dict) and trait['item'] in alt_images_inverted: + trait['item'] = alt_images_inverted[trait['item']].split('__', 1)[0] + + build['_version'] = BUILD_VERSION + + def remove_invalid_build_items(self, build: dict[str, int | dict[str]]): + """ + Checks build for invalid items and removes these to maintain compatibility. + + Parameters: + - :param build: build to remove items from (in place) + """ + for environment in ('space', 'ground'): + for category, category_items in build[environment].items(): + if isinstance(category_items, str): + continue + elif category == 'boffs': + for station in category_items: + for index, ability in enumerate(station): + if (isinstance(ability, dict) + and ability['item'] not in self._cargo.image_set): + station[index] = '' + elif (category.startswith('doff') + or category == 'boff_specs' + or category == 'boff_profs'): + continue + elif isinstance(category_items, list): + for index, item in enumerate(category_items): + if isinstance(item, dict) and item['item'] not in self._cargo.image_set: + category_items[index] = '' + + def encode_in_image(self, image: QImage, data: str): + """ + Embeds data into image + + Parameters: + - :param image: image to edit + - :param data: data string to embed into image + """ + data_bytes = zlib_compress(bytes(data, encoding='utf-8')) + total_characters = len(data_bytes) + bits = np__zeros(total_characters * 8 + 32 + 8, dtype=uint8) + prefix = np__array( + [167, total_characters >> 8, total_characters & 0b11111111, 167], dtype=uint8) + bits[0:32] = np__unpackbits(prefix) + bits[32:-8] = np__unpackbits(np__fromiter(data_bytes, dtype=uint8, count=total_characters)) + bits[-8:] = np__unpackbits(np__array([167], dtype=uint8)) + total_characters += 5 # prefix and suffix length + w = image.width() + total_bits = total_characters * 8 + full_rows = total_bits // (w * 3) + additional_pixels = (total_bits - full_rows * w * 3) // 3 + additional_subpixels = total_bits % 3 + i = -1 + row = -1 + for row in range(full_rows): + row_data = image.scanLine(row) + for i, subpixel in pixel_range(w, i + 1): + row_data[subpixel] = row_data[subpixel] & 0b11111110 | bits[i] + row_data = image.scanLine(row + 1) + for i, subpixel in pixel_range(additional_pixels, i + 1): + row_data[subpixel] = row_data[subpixel] & 0b11111110 | bits[i] + if additional_pixels == 0: + subpixel = -2 + if additional_subpixels == 1: + row_data[subpixel + 2] = row_data[subpixel + 2] & 0b11111110 | bits[i + 1] + elif additional_subpixels == 2: + row_data[subpixel + 2] = row_data[subpixel + 2] & 0b11111110 | bits[i + 1] + row_data[subpixel + 3] = row_data[subpixel + 3] & 0b11111110 | bits[i + 2] + + def decode_from_image(self, image: QImage) -> str: + """ + Extracts embedded data from image; returns empty string if no data was found + + Parameters: + - :param image: image with embedded data + """ + # prefix: §15000§ where 15000 is the number (as uint16) of bytes the encoded data occupies + prefix_bits = np__zeros(32, dtype=uint8) + first_row = image.constScanLine(0) + for i, subpixel in pixel_range(10): + prefix_bits[i] = first_row[subpixel] & 0b1 + prefix_bits[30] = first_row[40] & 0b1 + prefix_bits[31] = first_row[41] & 0b1 + prefix_bytes = np__packbits(prefix_bits) + if prefix_bytes[0] != 167 or prefix_bytes[3] != 167: # ord('§') == 167 + return '' + total_characters = int(prefix_bytes[1]) << 8 | int(prefix_bytes[2]) # constructs 16-bit int + total_characters += 5 # prefix and suffix length + w = image.width() + total_bits = total_characters * 8 + bits = np__zeros(total_bits, dtype=uint8) + full_rows = total_bits // (w * 3) + additional_pixels = (total_bits - full_rows * w * 3) // 3 + additional_subpixels = total_bits % 3 + i = -1 + row = -1 + for row in range(full_rows): + row_data = image.constScanLine(row) + for i, subpixel in pixel_range(w, i + 1): + bits[i] = row_data[subpixel] & 0b1 + row_data = image.constScanLine(row + 1) + for i, subpixel in pixel_range(additional_pixels, i + 1): + bits[i] = row_data[subpixel] & 0b1 + if additional_pixels == 0: + subpixel = -2 + if additional_subpixels == 1: + bits[i + 1] = row_data[subpixel + 2] & 0b1 + elif additional_subpixels == 2: + bits[i + 1] = row_data[subpixel + 2] & 0b1 + bits[i + 2] = row_data[subpixel + 3] & 0b1 + decoded_bytes = bytes(np__packbits(bits)) + if decoded_bytes[-1] != 167: + return '' + return str(zlib_decompress(decoded_bytes[4:-1]), 'utf-8') + + def map_build_items(self, old_build: dict, new_build: dict, mapping): + """ + Inserts items from old build into new build according to mapping; in-place + + Parameters: + - :param old_build: source + - :param new_build: target + - :param mapping: iterable of 2-tuples containing source and target key + """ + for source_key, target_key in mapping: + try: + if isinstance(new_build[target_key], list): + for index, element in enumerate(old_build[source_key]): + try: + if isinstance(element, dict) and 'modifiers' in element: + element['modifiers'] += [None] * (5 - len(element['modifiers'])) + new_build[target_key][index] = element + except IndexError: + break + else: + new_build[target_key] = old_build[source_key] + except KeyError: + continue + + def load_legacy_build_image(self): + """ + Loads legacy build from image file + """ + load_path = browse_path( + self.get_library_path(), + 'PNG image (*.png);;Any File (*.*)', parent_window=self._window) + if load_path is not None: + if load_path.suffix.lower() != '.png': + return + raw_build = self.legacy_decode_from_image(load_path) + try: + build_data = json__loads(self.compensate_old_build(raw_build)) + except JSONDecodeError: + return + if 'versionJSON' in build_data: + new_build = empty_build() + new_build.update(self.convert_old_build(build_data)) + self.update_build_version(new_build) + self._build.data = new_build + try: + self._build.load_build() + except KeyError: + self.remove_invalid_build_items(self._build.data) + self._build.load_build() + + def legacy_decode_from_image(self, image_path: str) -> str: + """ + Decodes build from image using old embedding specification. + + Parameters: + - :param image_path: path to image + """ + message = '' + image = QImage(image_path) + width = image.width() + pixel_num = width * 3 + bit_diff = pixel_num % 8 + decoded_binary = np__zeros(pixel_num, dtype=uint8) + extra_bits = np__zeros(0, dtype=uint8) + for line in range(image.height()): + data = image.constScanLine(line) + for col in range(width): + pixel_index = col * 4 + bin_index = col * 3 + decoded_binary[bin_index] = data[pixel_index + 2] & 0b1 + decoded_binary[bin_index + 1] = data[pixel_index + 1] & 0b1 + decoded_binary[bin_index + 2] = data[pixel_index] & 0b1 + if bit_diff == 0: + decoded_bytes = np__packbits(np__append(extra_bits, decoded_binary)) + extra_bits = np__zeros(0, dtype=uint8) + bit_diff = pixel_num % 8 + else: + decoded_bytes = np__packbits(np__append(extra_bits, decoded_binary[:-1 * bit_diff])) + extra_bits = decoded_binary[-1 * bit_diff:].copy() + bit_diff = (pixel_num + len(extra_bits)) % 8 + new_message = ''.join(map(chr, decoded_bytes)) + message += new_message + if '$t3g0' in new_message: + break + return message.split('$t3g0', maxsplit=1)[0] + + def compensate_old_build(self, build: str): + """ + replaces known wrong terms in build string + """ + build = build.replace('Ultra rare', 'Ultra Rare') + build = build.replace('Very rare', 'Very Rare') + return build + + def convert_old_build(self, build: dict) -> dict: + """ + converts build from old spec to current spec + """ + new_build = empty_build() + + # space + self.map_build_items(build, new_build['space'], BUILD_CONVERSION['space']) + + new_build['space']['traits'] = build['personalSpaceTrait'] + build['personalSpaceTrait2'] + if len(new_build['space']['traits']) < 12: + new_build['space']['traits'] += [None] * (12 - len(new_build['space']['traits'])) + elite_captain_trait = new_build['space']['traits'][5] + new_build['space']['traits'][5] = new_build['space']['traits'][9] + new_build['space']['traits'][9] = elite_captain_trait + + ship_data = self._cargo.ships[new_build['space']['ship']] + boff_data = sorted(map(lambda s: get_boff_spec(s), ship_data['boffs']), reverse=True) + boff_data_old = [] + for boff_id, boff_profession in enumerate(build['boffseats']['space']): + if f'spaceBoff_{boff_id}' in build['boffs'] and boff_profession is not None: + abilities = build['boffs'][f'spaceBoff_{boff_id}'] + boff_data_old.append((len(abilities), boff_profession, abilities)) + boff_data_old.sort(reverse=True) + for boff_id, (new_station, old_station) in enumerate(zip(boff_data, boff_data_old)): + if new_station[1] == old_station[1] or new_station[1] == 'Universal': + continue + for i, test_station in enumerate(boff_data): + if old_station[0] == test_station[0] and old_station[1] == test_station[1]: + boff_data_old[boff_id] = boff_data_old[i] + boff_data_old[i] = old_station + break + else: + for i, test_station in enumerate(boff_data): + if old_station[0] == test_station[0] and test_station[1] == 'Universal': + boff_data_old[boff_id] = boff_data_old[i] + boff_data_old[i] = old_station + break + for boff_id, station in enumerate(boff_data_old): + new_build['space']['boff_specs'][boff_id] = [station[1], boff_data[boff_id][2]] + for i, ability in enumerate(station[2]): + if ability is None or ability == '': + new_build['space']['boffs'][boff_id][i] = '' + else: + new_build['space']['boffs'][boff_id][i] = {'item': ability} + + # ground + self.map_build_items(build, new_build['ground'], BUILD_CONVERSION['ground']) + + try: + for boff_id in range(4): + new_build['ground']['boff_profs'][boff_id] = build['boffseats']['ground'][boff_id] + new_build['ground']['boff_specs'][boff_id] = ( + build['boffseats']['ground_spec'][boff_id]) + if new_build['ground']['boff_specs'][boff_id] is None: + new_build['ground']['boff_specs'][boff_id] = 'Command' + for i, ability in enumerate(build['boffs'][f'groundBoff_{boff_id}']): + if ability is None or ability == '': + new_build['ground']['boffs'][boff_id][i] + else: + new_build['ground']['boffs'][boff_id][i] = {'item': ability} + except KeyError: + pass + + new_build['ground']['traits'] = build['personalGroundTrait'] + build['personalGroundTrait2'] + if len(new_build['ground']['traits']) < 12: + new_build['ground']['traits'] += [None] * (12 - len(new_build['ground']['traits'])) + elite_captain_trait = new_build['ground']['traits'][5] + new_build['ground']['traits'][5] = new_build['ground']['traits'][9] + new_build['ground']['traits'][9] = elite_captain_trait + + # captain + self.map_build_items(build, new_build['captain'], BUILD_CONVERSION['captain']) + try: + new_build['captain']['name'] = build['playerName'] + build['playerHandle'] + new_build['captain']['faction'] = build['captain']['faction'] + except KeyError: + pass + + # doffs + for environment in ('space', 'ground'): + for doff_index, doff in enumerate(build['doffs'][environment]): + if doff is not None and doff != '': + new_build[environment]['doffs_spec'][doff_index] = doff['spec'] + try: + for variant in getattr(self._cargo, f'{environment}_doffs')[doff['spec']]: + if doff['effect'] in variant: + new_build[environment]['doffs_variant'][doff_index] = variant + break + except KeyError: + pass + + return new_build diff --git a/src/buildmanager.py b/src/buildmanager.py new file mode 100644 index 0000000..b29e1b2 --- /dev/null +++ b/src/buildmanager.py @@ -0,0 +1,1469 @@ +from pathlib import Path + +from PySide6.QtCore import Qt, Slot +from PySide6.QtWidgets import QCheckBox, QComboBox, QLabel, QLineEdit, QPlainTextEdit, QPushButton + +from .buildhelpers import get_boff_spec, get_variable_slot_counts, empty_build +from .cargomanager import CargoManager +from .constants import ( + EQUIPMENT_TYPES, PRIMARY_SPECS, SECONDARY_SPECS, SHIP_TEMPLATE, SKILL_POINTS_FOR_RANK, SPECIES, + SPECIES_TRAITS) +from .imagemanager import ImageManager +from .iofunc import open_wiki_page, store_json +from .textedit import add_equipment_tooltip_header, get_ultimate_skill_unlock_tooltip +from .theme import TooltipCSS +from .widgets import ItemButton, ItemSlot, ShipButton, ShipImage, Thread, TooltipLabel + + +class SpaceBuild(): + """Stores widgets for space build""" + def __init__(self): + self.active_rep_traits: list[ItemButton] = [None] * 5 + self.aft_weapons: list[ItemButton] = [None] * 5 + self.aft_weapons_label: QLabel = None + self.boffs: list[list[ItemButton]] = [ + [None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4] + self.boff_labels: list[QComboBox] = [None] * 6 + self.boff_label_icons: list[TooltipLabel] = [None] * 6 + self.core: list[ItemButton] = [None] + self.deflector: list[ItemButton] = [None] + self.devices: list[ItemButton] = [None] * 6 + self.doffs_spec: list[QComboBox] = [None] * 6 + self.doffs_variant: list[QComboBox] = [None] * 6 + self.eng_consoles: list[ItemButton] = [None] * 5 + self.eng_consoles_label: QLabel = None + self.engines: list[ItemButton] = [None] + self.experimental: list[ItemButton] = [None] + self.experimental_label: QLabel = [None] + self.fore_weapons: list[ItemButton] = [None] * 5 + self.hangars: list[ItemButton] = [None] * 2 + self.hangars_label: QLabel = None + self.rep_traits: list[ItemButton] = [None] * 5 + self.sci_consoles: list[ItemButton] = [None] * 5 + self.sci_consoles_label: QLabel = None + self.sec_def: list[ItemButton] = [None] + self.sec_def_label: QLabel = [None] + self.shield: list[ItemButton] = [None] + self.starship_traits: list[ItemButton] = [None] * 7 + self.tac_consoles: list[ItemButton] = [None] * 5 + self.tac_consoles_label: QLabel = None + self.traits: list[ItemButton] = [None] * 12 + self.uni_consoles: list[ItemButton] = [None] * 3 + self.uni_consoles_label: QLabel = None + + +class GroundBuild(): + """Stores widgets for ground build""" + def __init__(self): + self.active_rep_traits: list[ItemButton] = [None] * 5 + self.armor: list[ItemButton] = [None] + self.boffs: list[list[ItemButton]] = [[None] * 4, [None] * 4, [None] * 4, [None] * 4] + self.boff_profs: list[QComboBox] = [None] * 4 + self.boff_specs: list[QComboBox] = [None] * 4 + self.ground_devices: list[ItemButton] = [None] * 5 + self.desc: QPlainTextEdit = None + self.doffs_spec: list[QComboBox] = [None] * 6 + self.doffs_variant: list[QComboBox] = [None] * 6 + self.ev_suit: list[ItemButton] = [None] + self.kit: list[ItemButton] = [None] + self.kit_modules: list[ItemButton] = [None] * 6 + self.rep_traits: list[ItemButton] = [None] * 5 + self.personal_shield: list[ItemButton] = [None] + self.traits: list[ItemButton] = [None] * 12 + self.weapons: list[ItemButton] = [None] * 2 + + +class SkillTree(): + """Stores widgets for space and ground skill tree""" + def __init__(self): + self.space: dict[str, list[ItemButton]] = { + 'eng': [None] * 30, + 'sci': [None] * 30, + 'tac': [None] * 30 + } + self.ground: list[list[ItemButton]] = [ + [False] * 6, + [False] * 6, + [False] * 4, + [False] * 4, + ] + self.unlocks: dict[str, list[ItemButton]] = { + 'eng': [None] * 5, + 'sci': [None] * 5, + 'tac': [None] * 5, + 'ground': [None] * 5 + } + self.bonus_bars: dict[str, list[QPushButton]] = { + 'eng': [None] * 24, + 'sci': [None] * 24, + 'tac': [None] * 24, + 'ground': [None] * 10, + } + self.count_labels: dict[str, QLabel] = { + 'eng': None, + 'sci': None, + 'tac': None, + 'ground': None + } + self.space_desc: QPlainTextEdit + self.ground_desc: QPlainTextEdit + + +class ShipBuild(): + """Stores widgets for ship description.""" + def __init__(self): + self.image: ShipImage + self.button: ShipButton + self.tier: QComboBox + self.dc: TooltipLabel + self.name: QLineEdit + self.desc: QPlainTextEdit + + +class CharacterBuild(): + """Stores WIdgets for character building.""" + def __init__(self): + self.name: QLineEdit + self.elite: QCheckBox + self.career: QComboBox + self.faction: QComboBox + self.species: QComboBox + self.primary: QComboBox + self.secondary: QComboBox + + +class BuildManager(): + """Manages build data and widgets""" + + def __init__( + self, cache: CargoManager, images: ImageManager, autosave_path: Path, + tooltip_styles: TooltipCSS): + self._building: bool = False # disables side-effects (including autosave) + self._cache: CargoManager = cache + self._images: ImageManager = images + self._image_thread: Thread = Thread(target=self._images.get_ship_image) + self._image_thread.result.connect(lambda image: self.ship.image.set_image(image)) + self._autosave_path: Path = autosave_path + self._tooltip_styles: TooltipCSS = tooltip_styles + self.space: SpaceBuild = SpaceBuild() + self.ground: GroundBuild = GroundBuild() + self.skills: SkillTree = SkillTree() + self.ship: ShipBuild = ShipBuild() + self.character: CharacterBuild = CharacterBuild() + self._build_data: dict[str, int | dict[str]] = empty_build() + self._skill_state: dict[str, int | list[int]] = { + 'space_points_total': 0, + 'space_points_eng': 0, + 'space_points_sci': 0, + 'space_points_tac': 0, + 'space_points_rank': [0] * 5, + 'ground_points_total': 0 + } + + @property + def data(self) -> dict[str, int | dict[str]]: + """Raw build data""" + return self._build_data + + @data.setter + def data(self, build_data: dict[str, int | dict[str]]): + self._build_data = build_data + + def autosave(self): + """ + Saves build to autosave file. + """ + if not self._building: + store_json(self._build_data, self._autosave_path) + + def __getitem__(self, key: str): + return self._build_data[key] + + def set( + self, category: str, key: str, subkey: int = -1, value: str | dict = '', + autosave: bool = True): + """ + Sets build data for situations in which assignment is not possible. + + Parameters: + - :param category: build category, e.g. `space`, `ground`, ... + - :param key: build key, e.g. `aft_weapons`, `armor`, ... + - :param subkey: build subkey, i.e. index of item under build key + - :param value: data to be set + - :param autosave: set to `False` to prevent autosaving + """ + if subkey == -1: + self._build_data[category][key] = value + else: + self._build_data[category][key][subkey] = value + if autosave: + self.autosave() + + def load_build(self): + """ + Updates UI to show the build currently in self._build_data. + """ + self._building = True + # ship section + ship = self._build_data['space']['ship'] + if ship == '' or ship == '': + ship_data = SHIP_TEMPLATE + self.ship.button.setText('') + self.ship.tier.clear() + self.ship.image.set_image(self._images.empty) + self.ship.dc.hide() + else: + self.ship.button.setText(ship) + ship_data = self._cache.ships[ship] + self.set_ship_image(ship_data['image'][5:]) + tier = self._build_data['space']['tier'] + ship_tier = ship_data['tier'] + self.ship.tier.clear() + if ship_tier == 6: + self.ship.tier.addItems(('T6', 'T6-X', 'T6-X2')) + elif ship_tier == 5: + self.ship.tier.addItems(('T5', 'T5-U', 'T5-X', 'T5-X2')) + else: + self.ship.tier.addItem(f'T{ship_tier}') + self.ship.tier.setCurrentText(tier) + if ship_data['equipcannons'] == 'yes': + self.ship.dc.show() + else: + self.ship.dc.hide() + self.ship.name.setText(self._build_data['space']['ship_name']) + self.ship.desc.setPlainText(self._build_data['space']['ship_desc']) + + # Character section + elite_captain = self._build_data['captain']['elite'] + self.character.name.setText(self._build_data['captain']['name']) + elite_state = Qt.CheckState.Checked if elite_captain else Qt.CheckState.Unchecked + self.character.elite.setCheckState(elite_state) + self.character.career.setCurrentText(self._build_data['captain']['career']) + species = self._build_data['captain']['species'] + self.character.faction.setCurrentText(self._build_data['captain']['faction']) + self.character.species.setCurrentText(species) + if species != 'Alien': + self.space.traits[10].hide() + self.ground.traits[10].hide() + self.character.primary.setCurrentText(self._build_data['captain']['primary_spec']) + self.character.secondary.setCurrentText(self._build_data['captain']['secondary_spec']) + + # Space Build Section + self.align_space_frame(ship_data) + self.load_equipment_cat('fore_weapons', 'space') + self.load_equipment_cat('aft_weapons', 'space') + self.load_equipment_cat('experimental', 'space') + self.load_equipment_cat('devices', 'space') + self.load_equipment_cat('hangars', 'space') + self.load_equipment_cat('deflector', 'space') + self.load_equipment_cat('sec_def', 'space') + self.load_equipment_cat('engines', 'space') + self.load_equipment_cat('core', 'space') + self.load_equipment_cat('shield', 'space') + self.load_equipment_cat('uni_consoles', 'space') + self.load_equipment_cat('eng_consoles', 'space') + self.load_equipment_cat('sci_consoles', 'space') + self.load_equipment_cat('tac_consoles', 'space') + self.load_boff_stations('space') + self.load_trait_cat('traits', 'space') + if not elite_captain: + self.space.traits[9].hide() + self.load_trait_cat('starship_traits', 'space') + self.load_trait_cat('rep_traits', 'space') + self.load_trait_cat('active_rep_traits', 'space') + self.load_doffs('space') + + # Ground Build Section + self.ground.desc.setPlainText(self._build_data['ground']['ground_desc']) + self.load_equipment_cat('kit_modules', 'ground') + if not elite_captain: + self.ground.kit_modules[5].hide() + self.load_equipment_cat('weapons', 'ground') + self.load_equipment_cat('ground_devices', 'ground') + if not elite_captain: + self.ground.ground_devices[4].hide() + self.load_equipment_cat('kit', 'ground') + self.load_equipment_cat('armor', 'ground') + self.load_equipment_cat('ev_suit', 'ground') + self.load_equipment_cat('personal_shield', 'ground') + self.load_boff_stations('ground') + self.load_trait_cat('traits', 'ground') + if not elite_captain: + self.ground.traits[9].hide() + self.load_trait_cat('rep_traits', 'ground') + self.load_trait_cat('active_rep_traits', 'ground') + self.load_doffs('ground') + + self.load_skill_pages() + + self._building = False + self.autosave() + + def set_ship_image(self, image_name: str): + """ + Updates ship image with image specified by `image_name`. + + Parameters: + - :param image_name: name of the image to obtain and show + """ + if self._image_thread.isRunning(): + self._image_thread.finished.connect( + lambda name=image_name: self.set_ship_image(name), + type=Qt.ConnectionType.SingleShotConnection) + else: + self._image_thread.set_args((image_name,)) + self._image_thread.start() + + def align_space_frame(self, ship_data: dict, clear: bool = False): + """ + Hides / shows the appropriate buttons of the ship build. Updates Boff stations. + + Parameters: + - :param ship_data: ship specifications + - :param clear: set to True to clear build + """ + uni, eng, sci, tac, devices, starship_traits = get_variable_slot_counts( + ship_data, self._build_data['space']['tier']) + + self.update_equipment_cat('fore_weapons', ship_data['fore'], clear) + self.update_equipment_cat('aft_weapons', ship_data['aft'], clear, can_hide=True) + self.update_equipment_cat('experimental', ship_data['experimental'], clear, can_hide=True) + self.update_equipment_cat('devices', devices, clear) + self.update_equipment_cat('hangars', ship_data['hangars'], clear, can_hide=True) + self.update_equipment_cat('sec_def', ship_data['secdeflector'], clear, can_hide=True) + if clear: + self.space.deflector[0].clear() + self._build_data['space']['deflector'][0] = '' + self.space.engines[0].clear() + self._build_data['space']['engines'][0] = '' + self.space.core[0].clear() + self._build_data['space']['core'][0] = '' + self.space.shield[0].clear() + self._build_data['space']['shield'][0] = '' + self.update_equipment_cat('uni_consoles', uni, clear, can_hide=True) + self.update_equipment_cat('eng_consoles', eng, clear, can_hide=True) + self.update_equipment_cat('sci_consoles', sci, clear, can_hide=True) + self.update_equipment_cat('tac_consoles', tac, clear, can_hide=True) + + self.update_starship_traits(starship_traits, clear) + + boff_specs = map(lambda s: get_boff_spec(s), ship_data['boffs']) + if 'Science Destroyer' in ship_data['type']: + for boff_num, boff_details in enumerate(sorted(boff_specs, reverse=True)): + if (boff_details[0] == 3 and boff_details[1] == 'Tactical' + or boff_details[0] == 4 and boff_details[1] == 'Science'): + self.update_boff_seat(boff_num, *boff_details, clear, sci_destroyer_seat=True) + else: + self.update_boff_seat(boff_num, *boff_details, clear) + else: + for boff_num, boff_details in enumerate(sorted(boff_specs, reverse=True)): + self.update_boff_seat(boff_num, *boff_details, clear) + for boff_to_hide in range(boff_num + 1, 6): + self.update_boff_seat(boff_to_hide, rank=0, profession='', clear=clear, hide_seat=True) + + def clear_all(self): + """ + Clears space and ground build, skills and captain info + """ + self._building = True + self.clear_space_build() + self.clear_ground_build() + self.clear_captain() + self.clear_space_skills() + self.clear_ground_skills() + self._building = False + self.autosave() + + def clear_build_callback(self, current_tab: int): + """ + Clears current build section + """ + self._building = True + if current_tab == 0: + self.clear_space_build() + elif current_tab == 1: + self.clear_ground_build() + elif current_tab == 2: + self.clear_space_skills() + elif current_tab == 3: + self.clear_ground_skills() + self._building = False + self.autosave() + + def clear_space_build(self): + """ + clears space build + """ + self.clear_ship() + self.align_space_frame(SHIP_TEMPLATE, clear=True) + self.clear_traits('space') + self.clear_doffs('space') + + def clear_ship(self): + """ + Clears ship section of sidebar + """ + self.ship.image.set_image(self._images.empty) + self.ship.button.setText('') + self._build_data['space']['ship'] = '' + self.ship.tier.clear() + self.ship.dc.hide() + self.ship.name.setText('') + self._build_data['space']['ship_name'] = '' + self.ship.desc.setPlainText('') + self._build_data['space']['ship_desc'] = '' + + def clear_ground_build(self): + """ + Clears ground build + """ + self.ground.desc.clear() + self._build_data['ground']['ground_desc'] = '' + self.clear_equipment_cat_ground('kit_modules') + self.clear_equipment_cat_ground('weapons') + self.clear_equipment_cat_ground('ground_devices') + self.clear_equipment_cat_ground('kit') + self.clear_equipment_cat_ground('armor') + self.clear_equipment_cat_ground('ev_suit') + self.clear_equipment_cat_ground('personal_shield') + self.clear_boff_seat_ground(0) + self.clear_boff_seat_ground(1) + self.clear_boff_seat_ground(2) + self.clear_boff_seat_ground(3) + self.clear_traits('ground') + self.clear_doffs('ground') + + def clear_traits(self, environment: str = 'both'): + """ + Clears traits from build and UI + + Parameters: + - :param environment: environment to clear the traits from (space/ground/both) + """ + if environment == 'space' or environment == 'both': + for i, trait_button in enumerate(self.space.traits): + trait_button.clear() + self._build_data['space']['traits'][i] = '' + for i, trait_button in enumerate(self.space.starship_traits): + trait_button.clear() + self._build_data['space']['starship_traits'][i] = '' + for i, trait_button in enumerate(self.space.rep_traits): + trait_button.clear() + self._build_data['space']['rep_traits'][i] = '' + for i, trait_button in enumerate(self.space.active_rep_traits): + trait_button.clear() + self._build_data['space']['active_rep_traits'][i] = '' + if environment == 'ground' or environment == 'both': + for i, trait_button in enumerate(self.ground.traits): + trait_button.clear() + self._build_data['ground']['traits'][i] = '' + for i, trait_button in enumerate(self.ground.rep_traits): + trait_button.clear() + self._build_data['ground']['rep_traits'][i] = '' + for i, trait_button in enumerate(self.ground.active_rep_traits): + trait_button.clear() + self._build_data['ground']['active_rep_traits'][i] = '' + + def clear_doffs(self, environment: str = 'both'): + """ + Clears doff frame(s) + + Parameters: + - :param environment: "space" / "ground" / "both" + """ + if environment == 'space' or environment == 'both': + for i in range(6): + self.space.doffs_spec[i].setCurrentText('') + self.space.doffs_variant[i].clear() + self._build_data['space']['doffs_spec'][i] = '' + self._build_data['space']['doffs_variant'][i] = '' + if environment == 'ground' or environment == 'both': + for i in range(6): + self.ground.doffs_spec[i].setCurrentText('') + self.ground.doffs_variant[i].clear() + self._build_data['ground']['doffs_spec'][i] = '' + self._build_data['ground']['doffs_variant'][i] = '' + + def clear_space_skills(self): + """ + resets space skill tree + """ + self.skills.space_desc.clear() + self._build_data['skill_desc']['space'] = '' + self._build_data['space_skills'] = { + 'eng': [False] * 30, + 'sci': [False] * 30, + 'tac': [False] * 30 + } + self._skill_state['space_points_total'] = 0 + self._skill_state['space_points_eng'] = 0 + self.skills.count_labels['eng'].setText('0') + self._skill_state['space_points_sci'] = 0 + self.skills.count_labels['sci'].setText('0') + self._skill_state['space_points_tac'] = 0 + self.skills.count_labels['tac'].setText('0') + self._skill_state['space_points_rank'] = [0] * 5 + for career in ('eng', 'sci', 'tac'): + for skill_button in self.skills.space[career]: + skill_button.clear_overlay() + skill_button.highlight = False + self._build_data['skill_unlocks'][career] = [None] * 5 + for bar_segment in self.skills.bonus_bars[career]: + bar_segment.setChecked(False) + for unlock_button in self.skills.unlocks[career]: + unlock_button.clear() + + def clear_ground_skills(self): + """ + resets ground skill tree + """ + self.skills.ground_desc.clear() + self._build_data['skill_desc']['ground'] = '' + self._build_data['ground_skills'] = [ + [False] * 6, + [False] * 6, + [False] * 4, + [False] * 4 + ] + self._build_data['skill_unlocks']['ground'] = [None] * 5 + self._skill_state['ground_points_total'] = 0 + self.skills.count_labels['ground'].setText('0') + for skill_subtree in self.skills.ground: + for skill_button in skill_subtree: + skill_button.clear_overlay() + skill_button.highlight = False + for unlock_button in self.skills.unlocks['ground']: + unlock_button.clear() + for bar_segment in self.skills.bonus_bars['ground']: + bar_segment.setChecked(False) + + def clear_captain(self): + """ + Clears Captain information from build and UI + """ + self.character.name.clear() + self._build_data['captain']['name'] = '' + self.character.elite.setCheckState(Qt.CheckState.Unchecked) + self._build_data['captain']['elite'] = False + self.character.career.setCurrentText('') + self._build_data['captain']['career'] = '' + self.character.faction.setCurrentText('') + self._build_data['captain']['faction'] = '' + self.character.species.setCurrentText('') + self._build_data['captain']['species'] = '' + self.character.primary.setCurrentText('') + self._build_data['captain']['primary_spec'] = '' + self.character.secondary.setCurrentText('') + self._build_data['captain']['secondary_spec'] = '' + + def update_equipment_cat( + self, build_key: str, target_quantity: int | None, clear: bool = False, + can_hide: bool = False): + """ + Shows/hides appropriate amount of buttons of the given category; updates build; space build + only + + Parameters: + - :param build_key: key to self._build_data + - :param target_quantity: number of slots that should be available in this category + - :param clear: True to clear build + - :param can_hide: hides/shows category label when target_quantity is 0/None + """ + if target_quantity is None or target_quantity == 0: + target_quantity = 0 + getattr(self.space, build_key + '_label').hide() + elif can_hide: + getattr(self.space, build_key + '_label').show() + buttons: list[ItemButton] = getattr(self.space, build_key) + max_quantity = len(buttons) + for show_index in range(target_quantity): + buttons[show_index].show() + if clear: + buttons[show_index].clear() + self._build_data['space'][build_key][show_index] = '' + for hide_index in range(target_quantity, max_quantity): + buttons[hide_index].clear() + buttons[hide_index].hide() + self._build_data['space'][build_key][hide_index] = None + + def update_starship_traits(self, target_quantity: int, clear: bool = False): + """ + Shows/hides appropriate amount of starship trait buttons; updates `self._build_data` + + Parameters: + - :param target_quantity: number of slots that should be available in this category + - :param clear: True to clear build + """ + buttons = self.space.starship_traits + for show_index in range(target_quantity): + buttons[show_index].show() + if clear: + buttons[show_index].clear() + self._build_data['space']['starship_traits'][show_index] = '' + for hide_index in range(target_quantity, 7): + buttons[hide_index].clear() + buttons[hide_index].hide() + self._build_data['space']['starship_traits'][hide_index] = None + + def update_boff_seat( + self, boff_id: int, rank: int, profession: str, specialization: str = '', + clear: bool = False, hide_seat: bool = False, sci_destroyer_seat: bool = False): + """ + Shows/hides appropriate amount of buttons of the boff seat; updates build; space build only + + Parameters: + - :param boff_id: boff number counted from the top/beginning + - :param rank: number of slots that should be available in this category + - :param profession: seat profession + - :param specialization: seat specialization + - :param clear: set to True to clear build + - :param hide_seat: hides/shows seat label + - :param sci_destroyer_seat: set to `True` to upgrade seat to commander and show info label + """ + buttons = self.space.boffs[boff_id] + max_quantity = 4 + if sci_destroyer_seat: + rank = 4 + for show_index in range(rank): + buttons[show_index].show() + if clear: + buttons[show_index].clear() + self._build_data['space']['boffs'][boff_id][show_index] = '' + for hide_index in range(rank, max_quantity): + buttons[hide_index].clear() + buttons[hide_index].hide() + self._build_data['space']['boffs'][boff_id][hide_index] = None + label = self.space.boff_labels[boff_id] + label.clear() + if hide_seat: + label.hide() + else: + label.show() + if specialization != '': + spec_label = f' / {specialization}' + else: + spec_label = '' + if profession == 'Universal': + label_options = ( + f'Tactical{spec_label}', + f'Science{spec_label}', + f'Engineering{spec_label}' + ) + label.setDisabled(False) + else: + label_options = (profession + spec_label,) + label.setDisabled(True) + label.addItems(label_options) + icon_label = self.space.boff_label_icons[boff_id] + if sci_destroyer_seat: + if profession == 'Science': + icon_label.setPixmap(self._images.icons['sci-small']) + icon_label._tooltip.setText('Commander slot only available in science mode.') + elif profession == 'Tactical': + icon_label.setPixmap(self._images.icons['tac-small']) + icon_label._tooltip.setText('Commander slot only available in tactical mode.') + icon_label.show() + else: + icon_label.hide() + if clear: + default_profession = 'Tactical' if profession == 'Universal' else profession + self._build_data['space']['boff_specs'][boff_id] = [default_profession, specialization] + + def load_equipment_cat(self, build_key: str, environment: str): + """ + Updates equipment category buttons to show items from build. + + Parameters: + - :param build_key: equipment category + - :param environment: space/ground + """ + for subkey, item in enumerate(self._build_data[environment][build_key]): + if item is not None and item != '': + self.slot_equipment_item(item, environment, build_key, subkey) + else: + getattr(getattr(self, environment), build_key)[subkey].clear() + + def clear_equipment_cat_ground(self, build_key: str): + """ + Clears buttons and build; ground build only + + Parameters: + - :param build_key: key to self._build_data + """ + category: list[ItemButton] = getattr(self.ground, build_key) + for subkey, button in enumerate(category): + button.clear() + self._build_data['ground'][build_key][subkey] = '' + + def load_trait_cat(self, build_key: str, environment: str): + """ + Updates trait category buttons to show items from build. + + Parameters: + - :param build_key: trait category + - :param environment: space/ground + """ + for subkey, item in enumerate(self._build_data[environment][build_key]): + if item is not None and item != '': + self.slot_trait_item(item, environment, build_key, subkey) + else: + getattr(getattr(self, environment), build_key)[subkey].clear() + + def slot_equipment_item( + self, item: dict[str, str], environment: str, build_key: str, build_subkey: int): + """ + Updates build and UI with item + + Parameters: + - :param item: item to be slotted + - :param environment: space/ground + - :param build_key: key to self._build_data[environment] + - :param build_subkey: index of the item within its build_key (category) + """ + self._build_data[environment][build_key][build_subkey] = item + overlay = getattr(self._images.overlays, item['rarity'].lower().replace(' ', '')) + tooltip = add_equipment_tooltip_header( + item, self._cache.equipment[build_key][item['item']], self._tooltip_styles) + item_button: ItemButton = getattr(getattr(self, environment), build_key)[build_subkey] + item_button.set_item_full(self._images.get(item['item']), overlay, tooltip) + + def slot_trait_item( + self, item: dict[str, str], environment: str, build_key: str, build_subkey: int): + """ + Updates build and UI with item + + Parameters: + - :param item: item to be slotted + - :param environment: space/ground + - :param build_key: key to self._build_data[environment] + - :param build_subkey: index of the item within its build_key (category) + """ + item_name = item['item'] + self._build_data[environment][build_key][build_subkey] = item + alt_image_key = f"{item_name}__{environment}__{build_key}" + if alt_image_key in self._cache.alt_images: + item_image = self._images.get(self._cache.alt_images[alt_image_key]) + else: + item_image = self._images.get(item_name) + item_button: ItemButton = getattr(getattr(self, environment), build_key)[build_subkey] + if build_key == 'starship_traits': + tooltip = self._cache.starship_traits[item_name]['tooltip'] + elif environment == 'space': + tooltip = self._cache.space_traits[build_key][item_name]['tooltip'] + else: + tooltip = self._cache.ground_traits[build_key][item_name]['tooltip'] + item_button.set_item_full(item_image, None, tooltip) + + def unslot_item(self, environment: str, build_key: str, build_subkey: int, boff_id: int = -1): + """ + Updates build and UI with item + + Parameters: + - :param item: item to be slotted + - :param environment: space/ground + - :param build_key: key to self._build_data[environment] + - :param build_subkey: index of the item within its build_key (category) + - :param boff_id: id of the boff seat; assumes non-boff item when `-1` or not supplied + """ + if boff_id == -1: + self._build_data[environment][build_key][build_subkey] = '' + item_button: ItemButton = getattr(getattr(self, environment), build_key)[build_subkey] + item_button.clear() + else: + self._build_data[environment][build_key][boff_id][build_subkey] = '' + item_button: ItemButton = getattr( + getattr(self, environment), build_key)[boff_id][build_subkey] + item_button.clear() + + @Slot(dict, ItemSlot) + def handle_picker_result(self, new_item: dict[str, str | list[str]], slot: ItemSlot): + """ + Inserts picked item into given slot if picking was not cancelled. + + Parameters: + - :param new_item: contains picked item, or empty item if picking was cancelled + - :param slot: information about the slot + """ + if new_item['item'] != '': + widget_storage = self.space if slot.environment == 'space' else self.ground + if slot.is_equipment: + if 'consoles' in slot.type: + item_data = self._cache.equipment[slot.type][new_item['item']] + type_ = EQUIPMENT_TYPES[item_data['type']] + for i, mod in enumerate(new_item['modifiers']): + if mod not in self._cache.modifiers[type_]: + new_item['modifiers'][i] = '' + self.slot_equipment_item(new_item, slot.environment, slot.type, slot.index) + else: + if slot.boff_id is None: + self.slot_trait_item( + {'item': new_item['item']}, slot.environment, slot.type, slot.index) + elif slot.type == 'boffs': + ability_name, _, ability_rank = new_item['item'].rpartition(' ') + self._build_data[slot.environment]['boffs'][slot.boff_id][slot.index] = { + 'item': ability_name, + 'rank': ability_rank + } + button: ItemButton = widget_storage.boffs[slot.boff_id][slot.index] + button.set_item(self._images.get(ability_name)) + button.tooltip = (self._cache.boff_abilities['all'][ability_name][ability_rank]) + self.autosave() + + @Slot(str) + def finish_ship_pick(self, ship_name: str): + """ + Switches to selected ship. + + Parameters: + - :param ship_name: name of the selected ship, or empty + """ + if ship_name == '': + return + self._building = True + self.ship.button.setText(ship_name) + ship_data = self._cache.ships[ship_name] + self.set_ship_image(ship_data['image'][5:]) + tier = ship_data['tier'] + self.ship.tier.clear() + if tier == 6: + self.ship.tier.addItems(('T6', 'T6-X', 'T6-X2')) + elif tier == 5: + self.ship.tier.addItems(('T5', 'T5-U', 'T5-X', 'T5-X2')) + else: + self.ship.tier.addItem(f'T{tier}') + self._build_data['space']['ship'] = ship_name + self._build_data['space']['tier'] = f'T{tier}' + if ship_data['equipcannons'] == 'yes': + self.ship.dc.show() + else: + self.ship.dc.hide() + self.align_space_frame(ship_data, clear=True) + self._building = False + self.autosave() + + @Slot(dict, ItemSlot) + def finish_item_edit(self, new_item: dict[str], slot: ItemSlot): + """ + Updates item after editing if editing was not cancelled. Autosaves. + + Parameters: + - :param new_item: contains the new item + - :param slot: information about the slot + """ + if new_item['item'] != '': + self.slot_equipment_item(new_item, slot.environment, slot.type, slot.index) + self.autosave() + + def load_boff_stations(self, environment: str): + """ + Updates boff stations to show items from build + + Parameters: + - :param environment: "space" / "ground" + """ + if environment == 'space': + for boff_id, boff_data in enumerate(self._build_data['space']['boffs']): + boff_spec = self._build_data['space']['boff_specs'][boff_id] + if boff_spec[1] == '': + boff_text = boff_spec[0] + else: + boff_text = f'{boff_spec[0]} / {boff_spec[1]}' + self.space.boff_labels[boff_id].setCurrentText(boff_text) + for ability, slot in zip(boff_data, self.space.boffs[boff_id]): + if ability is not None and ability != '': + slot.set_item_full( + self._images.get(ability['item']), None, + self._cache.boff_abilities['all'][ability['item']][ability['rank']]) + else: + slot.clear() + elif environment == 'ground': + for boff_id, boff_data in enumerate(self._build_data['ground']['boffs']): + self.ground.boff_profs[boff_id].setCurrentText( + self._build_data['ground']['boff_profs'][boff_id]) + self.ground.boff_specs[boff_id].setCurrentText( + self._build_data['ground']['boff_specs'][boff_id]) + for ability, slot in zip(boff_data, self.ground.boffs[boff_id]): + if ability is not None and ability != '': + slot.set_item_full( + self._images.get(ability['item']), None, + self._cache.boff_abilities['all'][ability['item']][ability['rank']]) + else: + slot.clear() + + def clear_boff_seat_ground(self, boff_id: int): + """ + Resets boff seat. + + Parameters: + - :param boff_id: boff number counted from the top/beginning + """ + boff_station: list[ItemButton] = self.ground.boffs[boff_id] + for subkey, button in enumerate(boff_station): + button.clear() + self._build_data['ground']['boffs'][boff_id][subkey] = '' + self.ground.boff_profs[boff_id].setCurrentText('Tactical') + self._build_data['ground']['boff_profs'][boff_id] = 'Tactical' + self.ground.boff_specs[boff_id].setCurrentText('Command') + self._build_data['ground']['boff_specs'][boff_id] = 'Command' + + def load_doffs(self, environment: str): + """ + Updates UI to show doffs in self._build_data + + Parameters: + - :param environment: "space" / "ground" + """ + if environment == 'space': + doff_zipper = zip( + self.space.doffs_spec, self._build_data['space']['doffs_spec'], + self.space.doffs_variant, self._build_data['space']['doffs_variant']) + elif environment == 'ground': + doff_zipper = zip( + self.ground.doffs_spec, self._build_data['ground']['doffs_spec'], + self.ground.doffs_variant, self._build_data['ground']['doffs_variant']) + for spec_combo, spec, variant_combo, variant in doff_zipper: + spec_combo.setCurrentText(spec) + if spec != '': + variants = getattr(self._cache, f'{environment}_doffs')[spec].keys() + variant_combo.addItems({''} | variants) + variant_combo.setCurrentText(variant) + + def load_skill_pages(self): + """ + Updates UI to show skill trees in self._build_data + """ + self.skills.space_desc.setPlainText(self._build_data['skill_desc']['space']) + self._skill_state['space_points_eng'] = 0 + self._skill_state['space_points_sci'] = 0 + self._skill_state['space_points_tac'] = 0 + self._skill_state['space_points_rank'] = [0] * 5 + self._skill_state['space_points_total'] = 0 + for career in ('eng', 'sci', 'tac'): + for skill_id, (button, enable) in enumerate(zip( + self.skills.space[career], self._build_data['space_skills'][career])): + if enable: + button.set_overlay(self._images.overlays.check) + button.highlight = True + self._skill_state[f'space_points_{career}'] += 1 + self._skill_state['space_points_rank'][int(skill_id / 6)] += 1 + else: + button.clear_overlay() + button.highlight = False + self._skill_state['space_points_total'] = sum(self._skill_state['space_points_rank']) + for career in ('eng', 'sci', 'tac'): + skill_points = self._skill_state[f'space_points_{career}'] + self.skills.count_labels[career].setText(str(skill_points)) + for unlock_id, unlock_choice in enumerate(self._build_data['skill_unlocks'][career]): + self.set_skill_unlock_space(career, unlock_id, unlock_choice, skill_points) + if skill_points > 24: + skill_points = 24 + for i in range(skill_points): + self.skills.bonus_bars[career][i].setChecked(True) + for i in range(skill_points, 24, 1): + self.skills.bonus_bars[career][i].setChecked(False) + + self.skills.ground_desc.setPlainText(self._build_data['skill_desc']['ground']) + self._skill_state['ground_points_total'] = 0 + ground_skills: list[list[bool]] = self._build_data['ground_skills'] + for skill_buttons, skill_data in zip(self.skills.ground, ground_skills): + for skill_button, enable in zip(skill_buttons, skill_data): + if enable: + skill_button.set_overlay(self._images.overlays.check) + skill_button.highlight = True + self._skill_state['ground_points_total'] += 1 + else: + skill_button.clear_overlay() + skill_button.highlight = False + self.skills.count_labels['ground'].setText(str(self._skill_state['ground_points_total'])) + for i in range(self._skill_state['ground_points_total']): + self.skills.bonus_bars['ground'][i].setChecked(True) + for i in range(self._skill_state['ground_points_total'], 10, 1): + self.skills.bonus_bars['ground'][i].setChecked(False) + for unlock_id, unlock_choice in enumerate(self._build_data['skill_unlocks']['ground']): + self.set_skill_unlock_ground(unlock_id, unlock_choice) + + def set_skill_unlock_space( + self, career: str, id: int, state: int | None = None, points_spent: int = -1): + """ + Sets unlock button to state and updates build + + Parameters: + - :param career: "eng" / "sci" / "tac" + - :param id: id of the unlock, counted from the unlock with the lowest requirement + - :param state: `0`, `1` set the button to the respective unlock, `None` clears + """ + unlock_button = self.skills.unlocks[career][id] + if id == 4: + if points_spent > 27 and state == self._build_data['skill_unlocks'][career][id]: + return + if state is None: + unlock_button.clear() + self._build_data['skill_unlocks'][career][id] = None + else: + unlock_data = self._cache.skills['space_unlocks'][career][4] + unlock_button.set_item(self._images.get(unlock_data['name'])) + if points_spent > 26: + unlock_button.tooltip = get_ultimate_skill_unlock_tooltip( + unlock_data, state, 3, self._tooltip_styles) + self._build_data['skill_unlocks'][career][id] = 3 + else: + unlock_button.tooltip = get_ultimate_skill_unlock_tooltip( + unlock_data, state, points_spent - 24, self._tooltip_styles) + self._build_data['skill_unlocks'][career][id] = state + if not self._building: + unlock_button.force_tooltip_update() + else: + if state is None: + unlock_button.clear() + self._build_data['skill_unlocks'][career][id] = None + else: + unlock_data = self._cache.skills['space_unlocks'][career][id]['nodes'][state] + if state == 0: + unlock_button.set_item(self._images.get('arrow-up')) + elif state == 1: + unlock_button.set_item(self._images.get('arrow-down')) + unlock_button.tooltip = ( + f"

" + f"{unlock_data['name']}

" + f"

" + f"Space Skill

{unlock_data['desc']}

") + self._build_data['skill_unlocks'][career][id] = state + if not self._building: + unlock_button.force_tooltip_update() + + def set_skill_unlock_ground(self, id: int, state: int | None): + """ + Sets unlock button to state and updates build + + Parameters: + - :param id: id of the unlock, counted from the unlock with the lowest requirement + - :param state: `0`, `1` set the button to the respective unlock, `None` clears + """ + unlock_button = self.skills.unlocks['ground'][id] + if state is None: + unlock_button.clear() + self._build_data['skill_unlocks']['ground'][id] = None + else: + unlock_data = self._cache.skills['ground_unlocks'][id]['nodes'][state] + if state == 0: + unlock_button.set_item(self._images.get('arrow-up')) + elif state == 1: + unlock_button.set_item(self._images.get('arrow-down')) + unlock_button.tooltip = ( + f"

" + f"{unlock_data['name']}

" + f"

" + f"Space Skill

{unlock_data['desc']}

") + self._build_data['skill_unlocks']['ground'][id] = state + if not self._building: + unlock_button.force_tooltip_update() + + def faction_combo_callback(self, new_faction: str): + """ + Saves new faction and changes species selector choices. + + Parameters: + - :param new_faction: name of the new faction + """ + self._build_data['captain']['faction'] = new_faction + self.character.species.clear() + if new_faction != '': + self.character.species.addItems(('', *SPECIES[new_faction])) + self._build_data['captain']['species'] = '' + self.autosave() + + def species_combo_callback(self, new_species: str): + """ + Saves new species to build and changes species trait + + Parameters: + - :param new_species: name of the new species + """ + self._build_data['captain']['species'] = new_species + if new_species == 'Alien': + if not self._building: + self._build_data['space']['traits'][10] = '' + self._build_data['ground']['traits'][10] = '' + self._build_data['space']['traits'][11] = '' + self._build_data['ground']['traits'][11] = '' + self.space.traits[10].show() + self.ground.traits[10].show() + self.space.traits[11].clear() + self.ground.traits[11].clear() + else: + self.space.traits[10].hide() + self.ground.traits[10].hide() + self.space.traits[10].clear() + self.ground.traits[10].clear() + self._build_data['space']['traits'][10] = None + self._build_data['ground']['traits'][10] = None + new_space_trait = SPECIES_TRAITS['space'].get(new_species, '') + new_ground_trait = SPECIES_TRAITS['ground'].get(new_species, '') + if new_space_trait == '': + self.space.traits[11].clear() + self._build_data['space']['traits'][11] = '' + else: + self.slot_trait_item({'item': new_space_trait}, 'space', 'traits', 11) + if new_ground_trait == '': + self.ground.traits[11].clear() + self._build_data['ground']['traits'][11] = '' + else: + self.slot_trait_item({'item': new_ground_trait}, 'ground', 'traits', 11) + self.autosave() + + def spec_combo_callback(self, primary: bool, new_spec: str): + """ + Saves new spec to build and adjusts choices in other spec combo box. + + Parameters: + - :param primary: `True` when editing primary spec, `False` when editing secondary spec + - :param new_spec: name of the new specialization + """ + if primary: + self._build_data['captain']['primary_spec'] = new_spec + secondary_combo = self.character.secondary + secondary_specs = set() + remove_index = None + for i in range(secondary_combo.count()): + secondary_specs.add(secondary_combo.itemText(i)) + if secondary_combo.itemText(i) == new_spec and new_spec != '': + remove_index = i + if remove_index is not None: + secondary_combo.removeItem(remove_index) + secondary_combo.addItems((PRIMARY_SPECS | SECONDARY_SPECS) - secondary_specs) + else: + self._build_data['captain']['secondary_spec'] = new_spec + primary_combo = self.character.primary + primary_specs = set() + remove_index = None + for i in range(primary_combo.count()): + primary_specs.add(primary_combo.itemText(i)) + if primary_combo.itemText(i) == new_spec and new_spec != '': + remove_index = i + if remove_index is not None: + primary_combo.removeItem(remove_index) + primary_combo.addItems(PRIMARY_SPECS - primary_specs) + self.autosave() + + def elite_callback(self, state: Qt.CheckState): + """ + Saves new state and updates build. + + Parameters: + - :param state: new state of the checkbox + """ + if state == Qt.CheckState.Checked: + if not self._building: + self._build_data['captain']['elite'] = True + self._build_data['space']['traits'][9] = '' + self._build_data['ground']['traits'][9] = '' + self._build_data['ground']['kit_modules'][5] = '' + self._build_data['ground']['ground_devices'][4] = '' + self.space.traits[9].show() + self.ground.traits[9].show() + self.ground.kit_modules[5].show() + self.ground.ground_devices[4].show() + else: + if not self._building: + self._build_data['captain']['elite'] = False + self._build_data['space']['traits'][9] = None + self._build_data['ground']['traits'][9] = None + self._build_data['ground']['kit_modules'][5] = None + self._build_data['ground']['ground_devices'][4] = None + self.space.traits[9].hide() + self.space.traits[9].clear() + self.ground.traits[9].hide() + self.ground.traits[9].clear() + self.ground.kit_modules[5].hide() + self.ground.kit_modules[5].clear() + self.ground.ground_devices[4].hide() + self.ground.ground_devices[4].clear() + self.autosave() + + def boff_profession_callback_space(self, boff_id: int, new_spec: str): + """ + updates build with newly assigned profession; clears abilities of the old profession + + Parameters: + - :param boff_id: identifies the boff station + - :param new_spec: new profession and specialization + """ + if self._building: + return + if ' / ' in new_spec: + profession, specialization = new_spec.split(' / ') + if specialization == 'Temporal Operative': + specialization = 'Temporal' + # Lt. Commander rank contains all abilities + all_abilities = self._cache.boff_abilities['space'][specialization][2] + for ability_num, ability in enumerate(self._build_data['space']['boffs'][boff_id]): + if ability is not None and ability != '' and ability['item'] not in all_abilities: + self._build_data['space']['boffs'][boff_id][ability_num] = '' + self.space.boffs[boff_id][ability_num].clear() + else: + profession = new_spec + specialization = '' + for ability_num, ability in enumerate(self._build_data['space']['boffs'][boff_id]): + if ability is not None and ability != '': + self._build_data['space']['boffs'][boff_id][ability_num] = '' + self.space.boffs[boff_id][ability_num].clear() + self._build_data['space']['boff_specs'][boff_id] = [profession, specialization] + self.autosave() + + def boff_label_callback_ground(self, boff_id: int, type_: str, new_text: str): + """ + updates build with newly assigned profession or specialization; clears invalid abilities + + Parameters: + - :param boff_id: number of the boff station + - :param type_: "boff_profs" / "boff_specs" + - :param new_text: new profession / specialization + """ + if self._building: + return + self._build_data['ground'][type_][boff_id] = new_text + other_type = 'boff_profs' if type_ == 'boff_specs' else 'boff_specs' + other_text = self._build_data['ground'][other_type][boff_id] + ground_abilities = self._cache.boff_abilities['ground'] + for ability_num, ability in enumerate(self._build_data['ground']['boffs'][boff_id]): + if ability is not None and ability != '': + # Lt. Commander and Commander rank combined contain all abilities + if (ability['item'] not in ground_abilities[new_text][2] + and ability['item'] not in ground_abilities[new_text][3] + and ability['item'] not in ground_abilities[other_text][2] + and ability['item'] not in ground_abilities[other_text][3]): + self._build_data['ground']['boffs'][boff_id][ability_num] = '' + self.ground.boffs[boff_id][ability_num].clear() + self.autosave() + + def tier_callback(self, new_tier: str): + """ + Updates build according to new tier + """ + if self._building: + return + self._build_data['space']['tier'] = new_tier + ship_name = self._build_data['space']['ship'] + if ship_name == '': + ship_data = SHIP_TEMPLATE + else: + ship_data = self._cache.ships[ship_name] + uni, eng, sci, tac, devices, starship_traits = get_variable_slot_counts(ship_data, new_tier) + self.update_equipment_cat('uni_consoles', uni, can_hide=True) + self.update_equipment_cat('eng_consoles', eng) + self.update_equipment_cat('sci_consoles', sci) + self.update_equipment_cat('tac_consoles', tac) + self.update_equipment_cat('devices', devices) + self.update_starship_traits(starship_traits) + self.autosave() + + def ship_info_callback(self): + """ + Opens wiki page of ship if ship is slotted + """ + if self._build_data['space']['ship'] != '': + open_wiki_page(self._cache.ships[self._build_data['space']['ship']]['Page']) + + def doff_spec_callback(self, new_spec: str, environment: str, doff_id: int): + """ + Callback for duty officer specialization combobox. + + Parameters: + - :param new_spec: selected specialization + - :param environment: "space" / "ground" + - :param doff_id: index of the doff + """ + if self._building: + return + self._build_data[environment]['doffs_spec'][doff_id] = new_spec + self._build_data[environment]['doffs_variant'][doff_id] = '' + widget_storage = self.space if environment == 'space' else self.ground + widget_storage.doffs_variant[doff_id].clear() + if new_spec != '': + variants = getattr(self._cache, f'{environment}_doffs')[new_spec].keys() + widget_storage.doffs_variant[doff_id].addItems({''} | variants) + self.autosave() + + def doff_variant_callback(self, new_variant: str, environment: str, doff_id: int): + """ + Callback for duty officer variant combobox. + + Parameters: + - :param new_variant: selected variant + - :param environment: "space" / "ground" + - :param doff_id: index of the doff + """ + if self._building: + return + self._build_data[environment]['doffs_variant'][doff_id] = new_variant + self.autosave() + + def toggle_space_skill(self, current_state: bool, career: str, skill_id: int): + """ + Activates space skill if it's deactivated, deactivates skill if it's activated. + + Parameters: + - :param current_state: state of the button before toggling + - :param career: "eng" / "tac" / "sci" + - :param skill_id: id of the skill node + """ + if current_state: + self.skills.space[career][skill_id].clear_overlay() + self.skills.space[career][skill_id].highlight = False + self._build_data['space_skills'][career][skill_id] = False + self._skill_state['space_points_total'] -= 1 + self._skill_state[f'space_points_{career}'] -= 1 + self._skill_state['space_points_rank'][int(skill_id / 6)] -= 1 + segment_index: int = self._skill_state[f'space_points_{career}'] + if segment_index < 24: + self.skills.bonus_bars[career][segment_index].setChecked(False) + if segment_index % 5 == 4: + button_index = (segment_index - 4) // 5 + self.set_skill_unlock_space(career, button_index, None) + elif segment_index == 23: + self.set_skill_unlock_space(career, 4, None) + elif 24 <= segment_index <= 26: + self.set_skill_unlock_space(career, 4, 0, segment_index) + else: + self.skills.space[career][skill_id].set_overlay(self._images.overlays.check) + self.skills.space[career][skill_id].highlight = True + self._build_data['space_skills'][career][skill_id] = True + self._skill_state['space_points_total'] += 1 + self._skill_state[f'space_points_{career}'] += 1 + self._skill_state['space_points_rank'][int(skill_id / 6)] += 1 + segment_index: int = self._skill_state[f'space_points_{career}'] - 1 + if segment_index < 24: + self.skills.bonus_bars[career][segment_index].setChecked(True) + if segment_index % 5 == 4: + button_index = (segment_index - 4) // 5 + self.set_skill_unlock_space(career, button_index, 0) + elif segment_index == 23: + self.set_skill_unlock_space(career, 4, -1, 24) + elif 24 <= segment_index <= 25: + self.set_skill_unlock_space(career, 4, 0, segment_index + 1) + elif segment_index == 26: + self.set_skill_unlock_space(career, 4, 3, 27) + self.skills.count_labels[career].setText(str(self._skill_state[f'space_points_{career}'])) + self.autosave() + + def skill_unlock_callback(self, bar: str, unlock_id: int): + """ + Callback for skill unlock buttons + + Parameters: + - :param bar: "eng" / "sci" / "tac" / "ground" + - :param unlock_id: index of the unlock button + """ + current_state = self._build_data['skill_unlocks'][bar][unlock_id] + if current_state is None: + return + if bar == 'ground': + if current_state == 0: + self.set_skill_unlock_ground(unlock_id, 1) + elif current_state == 1: + self.set_skill_unlock_ground(unlock_id, 0) + self.autosave() + else: + if unlock_id < 4: + if current_state == 0: + self.set_skill_unlock_space(bar, unlock_id, 1) + elif current_state == 1: + self.set_skill_unlock_space(bar, unlock_id, 0) + self.autosave() + else: + points_spent = self._skill_state[f'space_points_{bar}'] + if 25 <= points_spent <= 26: + self.set_skill_unlock_space(bar, 4, (current_state + 1) % 3, points_spent) + self.autosave() + + def toggle_ground_skill(self, current_state: bool, skill_group: int, skill_id: int): + """ + Activates ground skill if it's deactivated, deactivates skill if it's activated. + + Parameters: + - :param current_state: state of the button before toggling + - :param skill_group: number [0, 3] identifying the skill group + - :param skill_id: index of the skill within the group + """ + if current_state: + self.skills.ground[skill_group][skill_id].clear_overlay() + self.skills.ground[skill_group][skill_id].highlight = False + self._build_data['ground_skills'][skill_group][skill_id] = False + self._skill_state['ground_points_total'] -= 1 + segment_index = self._skill_state['ground_points_total'] + self.skills.bonus_bars['ground'][segment_index].setChecked(False) + if segment_index % 2 == 1: + button_index = (segment_index - 1) // 2 + self.set_skill_unlock_ground(button_index, None) + else: + self.skills.ground[skill_group][skill_id].set_overlay(self._images.overlays.check) + self.skills.ground[skill_group][skill_id].highlight = True + self._build_data['ground_skills'][skill_group][skill_id] = True + self._skill_state['ground_points_total'] += 1 + segment_index = self._skill_state['ground_points_total'] - 1 + self.skills.bonus_bars['ground'][segment_index].setChecked(True) + if segment_index % 2 == 1: + button_index = (segment_index - 1) // 2 + self.set_skill_unlock_ground(button_index, 0) + self.skills.count_labels['ground'].setText(str(self._skill_state['ground_points_total'])) + self.autosave() + + def skill_callback_space(self, career: str, skill_id: int, grouping: str): + """ + Callback for space skill node + + Parameters: + - :param career: "eng" / "tac" / "sci" + - :param skill_id: id of the skill node (index in self._build_data) + - :param grouping: type of skill grouping: "column" / "pair+1" / "separate" + """ + space_skills = self._build_data['space_skills'] + skill_active = space_skills[career][skill_id] + skill_lvl = skill_id % 3 + skill_rank = int(skill_id / 6) + if skill_active: # check for valid deselect + if (skill_lvl == 2 + or grouping != 'column' and skill_lvl == 1 + or not space_skills[career][skill_id + 1]): + skill_count = sum(self._skill_state['space_points_rank'][:skill_rank + 1]) + for offset, points_required in enumerate(SKILL_POINTS_FOR_RANK[skill_rank + 1:]): + if (skill_count - 1 < points_required + and self._skill_state['space_points_total'] - skill_count > 0): + return + skill_count += self._skill_state['space_points_rank'][skill_rank + offset + 1] + self.toggle_space_skill(skill_active, career, skill_id) + else: # check for valid select + if 46 > self._skill_state['space_points_total'] >= SKILL_POINTS_FOR_RANK[skill_rank]: + if skill_lvl == 0: + self.toggle_space_skill(skill_active, career, skill_id) + elif (grouping == 'column' and space_skills[career][skill_id - 1]): + self.toggle_space_skill(skill_active, career, skill_id) + elif (grouping != 'column' and space_skills[career][skill_id - skill_lvl]): + self.toggle_space_skill(skill_active, career, skill_id) + + def skill_callback_ground(self, skill_group: int, skill_id: int): + """ + Callback for ground skill node + + Parameters: + - :param skill_group: number [0, 3] identifying the skill group + - :param skill_id: index of the skill within the group + """ + ground_skills = self._build_data['ground_skills'] + skill_active = ground_skills[skill_group][skill_id] + if skill_active: # check for valid deselect + if skill_id == 0 and ( + ground_skills[skill_group][1] or ground_skills[skill_group][2] + or skill_group <= 1 and ground_skills[skill_group][4]): + return + elif skill_id % 2 == 0 and ground_skills[skill_group][skill_id + 1]: + return + self.toggle_ground_skill(skill_active, skill_group, skill_id) + else: # check for valid select + if self._skill_state['ground_points_total'] < 10: + if skill_id % 2 == 1 and ground_skills[skill_group][skill_id - 1]: + self.toggle_ground_skill(skill_active, skill_group, skill_id) + elif skill_id == 0: + self.toggle_ground_skill(skill_active, skill_group, skill_id) + elif (skill_id == 2 or skill_id == 4) and ground_skills[skill_group][0]: + self.toggle_ground_skill(skill_active, skill_group, skill_id) diff --git a/src/buildupdater.py b/src/buildupdater.py deleted file mode 100644 index 425cdba..0000000 --- a/src/buildupdater.py +++ /dev/null @@ -1,744 +0,0 @@ -from PySide6.QtCore import Qt - -from .constants import BOFF_RANKS, SHIP_TEMPLATE -from .iofunc import get_ship_image, image -from .textedit import ( - add_equipment_tooltip_header, get_tooltip, get_skill_unlock_tooltip_ground, - get_skill_unlock_tooltip_space, get_ultimate_skill_unlock_tooltip) -from .widgets import exec_in_thread - - -def load_build(self): - """ - Updates UI to show the build currently in self.build - """ - self.building = True - # ship section - ship = self.build['space']['ship'] - if ship == '' or ship == '': - ship_data = SHIP_TEMPLATE - self.widgets.ship['button'].setText('') - self.widgets.ship['tier'].clear() - self.widgets.ship['image'].set_image(self.cache.empty_image) - self.widgets.ship['dc'].hide() - else: - self.widgets.ship['button'].setText(ship) - ship_data = self.cache.ships[ship] - exec_in_thread( - self, self.images.get_ship_image, ship_data['image'][5:], - result=lambda img: self.widgets.ship['image'].set_image(*img)) - tier = self.build['space']['tier'] - ship_tier = ship_data['tier'] - self.widgets.ship['tier'].clear() - if ship_tier == 6: - self.widgets.ship['tier'].addItems(('T6', 'T6-X', 'T6-X2')) - elif ship_tier == 5: - self.widgets.ship['tier'].addItems(('T5', 'T5-U', 'T5-X', 'T5-X2')) - else: - self.widgets.ship['tier'].addItem(f'T{ship_tier}') - self.widgets.ship['tier'].setCurrentText(tier) - if ship_data['equipcannons'] == 'yes': - self.widgets.ship['dc'].show() - else: - self.widgets.ship['dc'].hide() - self.widgets.ship['name'].setText(self.build['space']['ship_name']) - self.widgets.ship['desc'].setPlainText(self.build['space']['ship_desc']) - - # Character section - elite_captain = self.build['captain']['elite'] - self.widgets.character['name'].setText(self.build['captain']['name']) - elite_state = Qt.CheckState.Checked if elite_captain else Qt.CheckState.Unchecked - self.widgets.character['elite'].setCheckState(elite_state) - self.widgets.character['career'].setCurrentText(self.build['captain']['career']) - species = self.build['captain']['species'] - self.widgets.character['faction'].setCurrentText(self.build['captain']['faction']) - self.widgets.character['species'].setCurrentText(species) - if species != 'Alien': - self.widgets.build['space']['traits'][10].hide() - self.widgets.build['ground']['traits'][10].hide() - self.widgets.character['primary'].setCurrentText(self.build['captain']['primary_spec']) - self.widgets.character['secondary'].setCurrentText(self.build['captain']['secondary_spec']) - - # Space Build Section - if ship == '' or ship == '': - align_space_frame(self, ship_data, clear=True) - else: - align_space_frame(self, ship_data) - load_equipment_cat(self, 'fore_weapons', 'space') - load_equipment_cat(self, 'aft_weapons', 'space') - load_equipment_cat(self, 'experimental', 'space') - load_equipment_cat(self, 'devices', 'space') - load_equipment_cat(self, 'hangars', 'space') - load_equipment_cat(self, 'deflector', 'space') - load_equipment_cat(self, 'sec_def', 'space') - load_equipment_cat(self, 'engines', 'space') - load_equipment_cat(self, 'core', 'space') - load_equipment_cat(self, 'shield', 'space') - load_equipment_cat(self, 'uni_consoles', 'space') - load_equipment_cat(self, 'eng_consoles', 'space') - load_equipment_cat(self, 'sci_consoles', 'space') - load_equipment_cat(self, 'tac_consoles', 'space') - load_boff_stations(self, 'space') - load_trait_cat(self, 'traits', 'space') - if not elite_captain: - self.widgets.build['space']['traits'][9].hide() - load_trait_cat(self, 'starship_traits', 'space') - load_trait_cat(self, 'rep_traits', 'space') - load_trait_cat(self, 'active_rep_traits', 'space') - load_doffs(self, 'space') - - # Ground Build Section - self.widgets.ground_desc.setPlainText(self.build['ground']['ground_desc']) - load_equipment_cat(self, 'kit_modules', 'ground') - if not elite_captain: - self.widgets.build['ground']['kit_modules'][5].hide() - load_equipment_cat(self, 'weapons', 'ground') - load_equipment_cat(self, 'ground_devices', 'ground') - if not elite_captain: - self.widgets.build['ground']['ground_devices'][4].hide() - load_equipment_cat(self, 'kit', 'ground') - load_equipment_cat(self, 'armor', 'ground') - load_equipment_cat(self, 'ev_suit', 'ground') - load_equipment_cat(self, 'personal_shield', 'ground') - load_boff_stations(self, 'ground') - load_trait_cat(self, 'traits', 'ground') - if not elite_captain: - self.widgets.build['ground']['traits'][9].hide() - load_trait_cat(self, 'rep_traits', 'ground') - load_trait_cat(self, 'active_rep_traits', 'ground') - load_doffs(self, 'ground') - - load_skill_pages(self) - - self.building = False - self.autosave() - - -def load_skill_pages(self): - """ - Updates UI to show skill trees in self.build - """ - # space skills - self.widgets.build['skill_desc']['space'].setPlainText(self.build['skill_desc']['space']) - self.cache.skills['space_points_eng'] = 0 - self.cache.skills['space_points_sci'] = 0 - self.cache.skills['space_points_tac'] = 0 - self.cache.skills['space_points_rank'] = [0] * 5 - self.cache.skills['space_points_total'] = 0 - for career in ('eng', 'sci', 'tac'): - for skill_id, (button, enable) in enumerate(zip( - self.widgets.build['space_skills'][career], self.build['space_skills'][career])): - if enable: - button.set_overlay(self.cache.overlays.check) - button.highlight = True - self.cache.skills[f'space_points_{career}'] += 1 - self.cache.skills['space_points_rank'][int(skill_id / 6)] += 1 - else: - button.clear_overlay() - button.highlight = False - self.cache.skills['space_points_total'] = sum(self.cache.skills['space_points_rank']) - for career in ('eng', 'sci', 'tac'): - skill_points = self.cache.skills[f'space_points_{career}'] - self.widgets.skill_counts_space[career].setText(str(skill_points)) - for unlock_id, unlock_choice in enumerate(self.build['skill_unlocks'][career]): - set_skill_unlock_space(self, career, unlock_id, unlock_choice, skill_points) - if skill_points > 24: - skill_points = 24 - for i in range(skill_points): - self.widgets.skill_bonus_bars[career][i].setChecked(True) - for i in range(skill_points, 24, 1): - self.widgets.skill_bonus_bars[career][i].setChecked(False) - - # ground skills - self.widgets.build['skill_desc']['ground'].setPlainText(self.build['skill_desc']['ground']) - self.cache.skills['ground_points_total'] = 0 - for skill_data, skill_buttons in zip( - self.build['ground_skills'], self.widgets.build['ground_skills']): - for enable, skill_button in zip(skill_data, skill_buttons): - if enable: - skill_button.set_overlay(self.cache.overlays.check) - skill_button.highlight = True - self.cache.skills['ground_points_total'] += 1 - else: - skill_button.clear_overlay() - skill_button.highlight = False - self.widgets.skill_count_ground.setText(str(self.cache.skills['ground_points_total'])) - for i in range(self.cache.skills['ground_points_total']): - self.widgets.skill_bonus_bars['ground'][i].setChecked(True) - for i in range(self.cache.skills['ground_points_total'], 10, 1): - self.widgets.skill_bonus_bars['ground'][i].setChecked(False) - for unlock_id, unlock_choice in enumerate(self.build['skill_unlocks']['ground']): - set_skill_unlock_ground(self, unlock_id, unlock_choice) - - -def get_boff_spec(self, seat_details: str) -> tuple[int, str, str]: - """ - Returns rank, profession and specialization from cargo string - - Parameters: - - :param seat_details: contains rank, profession and specialization: - " -" - """ - if '-' in seat_details: - rank_and_profession, spec = seat_details.split('-') - else: - rank_and_profession = seat_details - spec = '' - rank_name, _, profession = rank_and_profession.rpartition(' ') - return (BOFF_RANKS[rank_name], profession, spec) - - -def align_space_frame(self, ship_data: dict, clear: bool = False): - """ - Hides / shows the appropriate buttons of the ship build. Updates Boff stations. - - Parameters: - - :param ship_data: ship specifications - - :param clear: set to True to clear build - """ - uni, eng, sci, tac, devices, starship_traits = get_variable_slot_counts(self, ship_data) - - # Equipment - update_equipment_cat(self, 'fore_weapons', ship_data['fore'], clear) - update_equipment_cat(self, 'aft_weapons', ship_data['aft'], clear, can_hide=True) - update_equipment_cat(self, 'experimental', ship_data['experimental'], clear, can_hide=True) - update_equipment_cat(self, 'devices', devices, clear) - update_equipment_cat(self, 'hangars', ship_data['hangars'], clear, can_hide=True) - update_equipment_cat(self, 'sec_def', ship_data['secdeflector'], clear, can_hide=True) - if clear: - self.widgets.build['space']['deflector'][0].clear() - self.build['space']['deflector'][0] = '' - self.widgets.build['space']['engines'][0].clear() - self.build['space']['engines'][0] = '' - self.widgets.build['space']['core'][0].clear() - self.build['space']['core'][0] = '' - self.widgets.build['space']['shield'][0].clear() - self.build['space']['shield'][0] = '' - update_equipment_cat(self, 'uni_consoles', uni, clear, can_hide=True) - update_equipment_cat(self, 'eng_consoles', eng, clear, can_hide=True) - update_equipment_cat(self, 'sci_consoles', sci, clear, can_hide=True) - update_equipment_cat(self, 'tac_consoles', tac, clear, can_hide=True) - - # Starship Traits - update_starship_traits(self, starship_traits, clear) - - # Boffs - boff_specs = map(lambda s: get_boff_spec(self, s), ship_data['boffs']) - if 'Science Destroyer' in ship_data['type']: - for boff_num, boff_details in enumerate(sorted(boff_specs, reverse=True)): - if (boff_details[0] == 3 and boff_details[1] == 'Tactical' - or boff_details[0] == 4 and boff_details[1] == 'Science'): - update_boff_seat(self, boff_num, *boff_details, clear, sci_destroyer_seat=True) - else: - update_boff_seat(self, boff_num, *boff_details, clear) - else: - for boff_num, boff_details in enumerate(sorted(boff_specs, reverse=True)): - update_boff_seat(self, boff_num, *boff_details, clear) - for boff_to_hide in range(boff_num + 1, 6): - update_boff_seat(self, boff_to_hide, rank=0, profession='', clear=clear, hide_seat=True) - - -def get_variable_slot_counts(self, ship_data: dict): - """ - returns the number of universal consoles, devices and starship traits the current build should - have - - Parameters: - - :param ship_data: ship specifications - - :return: 6-tuple containing universal consoles, engineering consoles, science consoles, \ - tactical consoles, devices, starship traits - """ - if ship_data['name'] == '': - uni_consoles = 3 - starship_traits = 7 - devices = 6 - eng_consoles = 5 - sci_consoles = 5 - tac_consoles = 5 - else: - uni_consoles = 0 - starship_traits = 5 - devices = ship_data['devices'] - eng_consoles = ship_data['consoleseng'] - sci_consoles = ship_data['consolessci'] - tac_consoles = ship_data['consolestac'] - if 'Innovation Effects' in ship_data['abilities']: - uni_consoles += 1 - elif ship_data['name'] == 'Federation Intel Holoship': - uni_consoles += 1 - if '-X2' in self.build['space']['tier']: - uni_consoles += 2 - starship_traits += 2 - devices += 2 - elif '-X' in self.build['space']['tier']: - uni_consoles += 1 - starship_traits += 1 - devices += 1 - if self.build['space']['tier'].startswith(('T5-U', 'T5-X')): - if ship_data['t5uconsole'] == 'eng': - eng_consoles += 1 - elif ship_data['t5uconsole'] == 'sci': - sci_consoles += 1 - elif ship_data['t5uconsole'] == 'tac': - tac_consoles += 1 - return uni_consoles, eng_consoles, sci_consoles, tac_consoles, devices, starship_traits - - -def update_equipment_cat( - self, build_key: str, target_quantity: int | None, clear: bool = False, - can_hide: bool = False): - """ - Shows/hides appropriate amount of buttons of the given category; updates build; space build only - - Parameters: - - :param build_key: key to self.build and self.widgets - - :param target_quantity: number of slots that should be available in this category - - :param clear: True to clear build - - :param can_hide: hides/shows category label when target_quantity is 0/None - """ - if target_quantity is None or target_quantity == 0: - target_quantity = 0 - self.widgets.build['space'][build_key + '_label'].hide() - elif can_hide: - self.widgets.build['space'][build_key + '_label'].show() - buttons = self.widgets.build['space'][build_key] - max_quantity = len(buttons) - for show_index in range(target_quantity): - buttons[show_index].show() - if clear: - buttons[show_index].clear() - self.build['space'][build_key][show_index] = '' - for hide_index in range(target_quantity, max_quantity): - buttons[hide_index].clear() - buttons[hide_index].hide() - self.build['space'][build_key][hide_index] = None - - -def clear_equipment_cat(self, build_key: str): - """ - Clears buttons and build; ground build only - - Parameters: - - :param build_key: key to self.build and self.widgets - """ - for subkey, button in enumerate(self.widgets.build['ground'][build_key]): - button.clear() - self.build['ground'][build_key][subkey] = '' - - -def update_starship_traits(self, target_quantity: int, clear: bool = False): - """ - Shows/hides appropriate amount of starship trait buttons; updates `self.build` - - Parameters: - - :param target_quantity: number of slots that should be available in this category - - :param clear: True to clear build - """ - buttons = self.widgets.build['space']['starship_traits'] - for show_index in range(target_quantity): - buttons[show_index].show() - if clear: - buttons[show_index].clear() - self.build['space']['starship_traits'][show_index] = '' - for hide_index in range(target_quantity, 7): - buttons[hide_index].clear() - buttons[hide_index].hide() - self.build['space']['starship_traits'][hide_index] = None - - -def update_boff_seat( - self, boff_id: str, rank: int, profession: str, specialization: str = '', - clear: bool = False, hide_seat: bool = False, sci_destroyer_seat: bool = False): - """ - Shows/hides appropriate amount of buttons of the boff seat; updates build; space build only - - Parameters: - - :param boff_id: boff number counted from the top/beginning - - :param rank: number of slots that should be available in this category - - :param profession: seat profession - - :param specialization: seat specialization - - :param clear: set to True to clear build - - :param hide_seat: hides/shows seat label - - :param sci_destroyer_seat: set to `True` to upgrade seat to commander and show info label - """ - buttons = self.widgets.build['space']['boffs'][boff_id] - max_quantity = 4 - if sci_destroyer_seat: - rank = 4 - for show_index in range(rank): - buttons[show_index].show() - if clear: - buttons[show_index].clear() - self.build['space']['boffs'][boff_id][show_index] = '' - for hide_index in range(rank, max_quantity): - buttons[hide_index].clear() - buttons[hide_index].hide() - self.build['space']['boffs'][boff_id][hide_index] = None - label = self.widgets.build['space']['boff_labels'][boff_id] - label.clear() - if hide_seat: - label.hide() - else: - label.show() - if specialization != '': - spec_label = f' / {specialization}' - else: - spec_label = '' - if profession == 'Universal': - label_options = ( - f'Tactical{spec_label}', - f'Science{spec_label}', - f'Engineering{spec_label}' - ) - label.setDisabled(False) - else: - label_options = (profession + spec_label,) - label.setDisabled(True) - label.addItems(label_options) - icon_label = self.widgets.build['space']['boff_label_icons'][boff_id] - if sci_destroyer_seat: - if profession == 'Science': - icon_label.setPixmap(self.cache.icons['sci-small']) - icon_label._tooltip.setText('Commander slot only available in science mode.') - elif profession == 'Tactical': - icon_label.setPixmap(self.cache.icons['tac-small']) - icon_label._tooltip.setText('Commander slot only available in tactical mode.') - icon_label.show() - else: - icon_label.hide() - if clear: - default_profession = 'Tactical' if profession == 'Universal' else profession - self.build['space']['boff_specs'][boff_id] = [default_profession, specialization] - - -def clear_boff_seat_ground(self, boff_id: int): - """ - Resets boff seat. - - Parameters: - - :param boff_id: boff number counted from the top/beginning - """ - for subkey, button in enumerate(self.widgets.build['ground']['boffs'][boff_id]): - button.clear() - self.build['ground']['boffs'][boff_id][subkey] = '' - self.widgets.build['ground']['boff_profs'][boff_id].setCurrentText('Tactical') - self.build['ground']['boff_profs'][boff_id] = 'Tactical' - self.widgets.build['ground']['boff_specs'][boff_id].setCurrentText('Command') - self.build['ground']['boff_specs'][boff_id] = 'Command' - - -def load_equipment_cat(self, build_key: str, environment: str): - """ - Updates equipment category buttons to show items from build. - - Parameters: - - :param build_key: equipment category - - :param environment: space/ground - """ - for subkey, item in enumerate(self.build[environment][build_key]): - if item is not None and item != '': - slot_equipment_item(self, item, environment, build_key, subkey) - else: - self.widgets.build[environment][build_key][subkey].clear() - - -def load_trait_cat(self, build_key: str, environment: str): - """ - Updates trait category buttons to show items from build. - - Parameters: - - :param build_key: trait category - - :param environment: space/ground - """ - for subkey, item in enumerate(self.build[environment][build_key]): - if item is not None and item != '': - slot_trait_item(self, item, environment, build_key, subkey) - else: - self.widgets.build[environment][build_key][subkey].clear() - - -def load_boff_stations(self, environment: str): - """ - Updates boff stations to show items from build - - Parameters: - - :param environment: "space" / "ground" - """ - if environment == 'space': - for boff_id, boff_data in enumerate(self.build['space']['boffs']): - boff_spec = self.build['space']['boff_specs'][boff_id] - if boff_spec[1] == '': - boff_text = boff_spec[0] - else: - boff_text = f'{boff_spec[0]} / {boff_spec[1]}' - self.widgets.build['space']['boff_labels'][boff_id].setCurrentText(boff_text) - for ability, slot in zip(boff_data, self.widgets.build['space']['boffs'][boff_id]): - if ability is not None and ability != '': - tooltip = self.cache.boff_abilities['all'][ability['item']][ability['rank']] - slot.set_item_full(image(self, ability['item']), None, tooltip) - else: - slot.clear() - elif environment == 'ground': - for boff_id, boff_data in enumerate(self.build['ground']['boffs']): - self.widgets.build['ground']['boff_profs'][boff_id].setCurrentText( - self.build['ground']['boff_profs'][boff_id]) - self.widgets.build['ground']['boff_specs'][boff_id].setCurrentText( - self.build['ground']['boff_specs'][boff_id]) - for ability, slot in zip(boff_data, self.widgets.build['ground']['boffs'][boff_id]): - if ability is not None and ability != '': - tooltip = self.cache.boff_abilities['all'][ability['item']][ability['rank']] - slot.set_item_full(image(self, ability['item']), None, tooltip) - else: - slot.clear() - - -def slot_equipment_item(self, item: dict, environment: str, build_key: str, build_subkey: int): - """ - Updates build and UI with item - - Parameters: - - :param item: item to be slotted - - :param environment: space/ground - - :param build_key: key to self.build[environment] - - :param build_subkey: index of the item within its build_key (category) - """ - self.build[environment][build_key][build_subkey] = item - item_image = image(self, item['item']) - overlay = getattr(self.cache.overlays, item['rarity'].lower().replace(' ', '')) - tooltip = add_equipment_tooltip_header( - self, item, self.cache.equipment[build_key][item['item']]['tooltip'], build_key) - self.widgets.build[environment][build_key][build_subkey].set_item_full( - item_image, overlay, tooltip) - - -def slot_trait_item(self, item: dict, environment: str, build_key: str, build_subkey: int): - """ - Updates build and UI with item - - Parameters: - - :param item: item to be slotted - - :param environment: space/ground - - :param build_key: key to self.build[environment] - - :param build_subkey: index of the item within its build_key (category) - """ - self.build[environment][build_key][build_subkey] = item - alt_image_key = f"{item['item']}__{environment}__{build_key}" - if alt_image_key in self.cache.alt_images: - image_name = self.cache.alt_images[alt_image_key] - else: - image_name = item['item'] - item_image = image(self, image_name) - self.widgets.build[environment][build_key][build_subkey].set_item_full( - item_image, None, get_tooltip(self, item['item'], build_key, environment)) - - -def set_skill_unlock_ground(self, id: int, state: int | None): - """ - Sets unlock button to state and updates build - - Parameters: - - :param id: id of the unlock, counted from the unlock with the lowest requirement - - :param state: `0`, `1` set the button to the respective unlock, `None` clears - """ - unlock_button = self.widgets.build['skill_unlocks']['ground'][id] - if state == 0: - unlock_button.set_item( - self.cache.images['arrow-up']) - unlock_button.tooltip = get_skill_unlock_tooltip_ground(self, id, 0) - self.build['skill_unlocks']['ground'][id] = 0 - if not self.building: - unlock_button.force_tooltip_update() - elif state == 1: - unlock_button.set_item( - self.cache.images['arrow-down']) - unlock_button.tooltip = get_skill_unlock_tooltip_ground(self, id, 1) - self.build['skill_unlocks']['ground'][id] = 1 - if not self.building: - unlock_button.force_tooltip_update() - else: - unlock_button.clear() - self.build['skill_unlocks']['ground'][id] = None - - -def set_skill_unlock_space( - self, career: str, id: int, state: int | None = None, points_spent: int = -1): - """ - Sets unlock button to state and updates build - - Parameters: - - :param career: "eng" / "sci" / "tac" - - :param id: id of the unlock, counted from the unlock with the lowest requirement - - :param state: `0`, `1` set the button to the respective unlock, `None` clears - """ - unlock_button = self.widgets.build['skill_unlocks'][career][id] - if id == 4: - if points_spent > 27 and state == self.build['skill_unlocks'][career][id]: - return - if state is None: - unlock_button.clear() - self.build['skill_unlocks'][career][id] = None - else: - unlock_button.set_item( - self.cache.images[self.cache.skills['space_unlocks']['_icons'][career]]) - if points_spent == 24: - unlock_button.tooltip = get_ultimate_skill_unlock_tooltip(self, career, -1, 0) - self.build['skill_unlocks'][career][id] = -1 - elif points_spent == 25: - unlock_button.tooltip = get_ultimate_skill_unlock_tooltip(self, career, state, 1) - self.build['skill_unlocks'][career][id] = state - elif points_spent == 26: - unlock_button.tooltip = get_ultimate_skill_unlock_tooltip(self, career, state, 2) - self.build['skill_unlocks'][career][id] = state - else: - unlock_button.tooltip = get_ultimate_skill_unlock_tooltip(self, career, 4, 3) - self.build['skill_unlocks'][career][id] = 3 - if not self.building: - unlock_button.force_tooltip_update() - else: - if state == 0: - unlock_button.set_item( - self.cache.images['arrow-up']) - unlock_button.tooltip = get_skill_unlock_tooltip_space(self, career, id, 0) - self.build['skill_unlocks'][career][id] = 0 - if not self.building: - unlock_button.force_tooltip_update() - elif state == 1: - unlock_button.set_item( - self.cache.images['arrow-down']) - unlock_button.tooltip = get_skill_unlock_tooltip_space(self, career, id, 1) - self.build['skill_unlocks'][career][id] = 1 - if not self.building: - unlock_button.force_tooltip_update() - else: - unlock_button.clear() - self.build['skill_unlocks'][career][id] = None - - -def clear_traits(self, environment: str = 'both'): - """ - Clears traits from build and UI - - Parameters: - - :param environment: environment to clear the traits from (space/ground/both) - """ - if environment == 'space' or environment == 'both': - for i, trait_button in enumerate(self.widgets.build['space']['traits']): - trait_button.clear() - self.build['space']['traits'][i] = '' - for i, trait_button in enumerate(self.widgets.build['space']['starship_traits']): - trait_button.clear() - self.build['space']['starship_traits'][i] = '' - for i, trait_button in enumerate(self.widgets.build['space']['rep_traits']): - trait_button.clear() - self.build['space']['rep_traits'][i] = '' - for i, trait_button in enumerate(self.widgets.build['space']['active_rep_traits']): - trait_button.clear() - self.build['space']['active_rep_traits'][i] = '' - if environment == 'ground' or environment == 'both': - for i, trait_button in enumerate(self.widgets.build['ground']['traits']): - trait_button.clear() - self.build['ground']['traits'][i] = '' - for i, trait_button in enumerate(self.widgets.build['ground']['rep_traits']): - trait_button.clear() - self.build['ground']['rep_traits'][i] = '' - for i, trait_button in enumerate(self.widgets.build['ground']['active_rep_traits']): - trait_button.clear() - self.build['ground']['active_rep_traits'][i] = '' - - -def clear_captain(self): - """ - Clears Captain information from build and UI - """ - self.widgets.character['name'].clear() - self.build['captain']['name'] = '' - self.widgets.character['elite'].setCheckState(Qt.CheckState.Unchecked) - self.build['captain']['elite'] = False - self.widgets.character['career'].setCurrentText('') - self.build['captain']['career'] = '' - self.widgets.character['faction'].setCurrentText('') - self.build['captain']['faction'] = '' - self.widgets.character['species'].setCurrentText('') - self.build['captain']['species'] = '' - self.widgets.character['primary'].setCurrentText('') - self.build['captain']['primary_spec'] = '' - self.widgets.character['secondary'].setCurrentText('') - self.build['captain']['secondary_spec'] = '' - - -def clear_ship(self): - """ - Clears ship section of sidebar - """ - self.widgets.ship['image'].set_image(self.cache.empty_image) - self.widgets.ship['button'].setText('') - self.build['space']['ship'] = '' - self.widgets.ship['tier'].clear() - self.widgets.ship['dc'].hide() - self.widgets.ship['name'].setText('') - self.build['space']['ship_name'] = '' - self.widgets.ship['desc'].setPlainText('') - self.build['space']['ship_desc'] = '' - - -def clear_ground_build(self): - """ - Clears ground build - """ - self.widgets.ground_desc.clear() - self.build['ground']['ground_desc'] = '' - clear_equipment_cat(self, 'kit_modules') - clear_equipment_cat(self, 'weapons') - clear_equipment_cat(self, 'ground_devices') - clear_equipment_cat(self, 'kit') - clear_equipment_cat(self, 'armor') - clear_equipment_cat(self, 'ev_suit') - clear_equipment_cat(self, 'personal_shield') - clear_boff_seat_ground(self, 0) - clear_boff_seat_ground(self, 1) - clear_boff_seat_ground(self, 2) - clear_boff_seat_ground(self, 3) - clear_traits(self, 'ground') - clear_doffs(self, 'ground') - - -def load_doffs(self, environment: str): - """ - Updates UI to show doffs in self.build - - Parameters: - - :param environment: "space" / "ground" - """ - doff_zipper = zip( - self.widgets.build[environment]['doffs_spec'], - self.build[environment]['doffs_spec'], - self.widgets.build[environment]['doffs_variant'], - self.build[environment]['doffs_variant']) - for spec_combo, spec, variant_combo, variant in doff_zipper: - spec_combo.setCurrentText(spec) - if spec != '': - variants = getattr(self.cache, f'{environment}_doffs')[spec].keys() - variant_combo.addItems({''} | variants) - variant_combo.setCurrentText(variant) - - -def clear_doffs(self, environment: str = 'both'): - """ - Clears doff frame(s) - - Parameters: - - :param environment: "space" / "ground" / "both" - """ - if environment == 'space' or environment == 'both': - for i in range(6): - self.widgets.build['space']['doffs_spec'][i].setCurrentText('') - self.widgets.build['space']['doffs_variant'][i].clear() - self.build['space']['doffs_spec'][i] = '' - self.build['space']['doffs_variant'][i] = '' - if environment == 'ground' or environment == 'both': - for i in range(6): - self.widgets.build['ground']['doffs_spec'][i].setCurrentText('') - self.widgets.build['ground']['doffs_variant'][i].clear() - self.build['ground']['doffs_spec'][i] = '' - self.build['ground']['doffs_variant'][i] = '' diff --git a/src/callbacks.py b/src/callbacks.py deleted file mode 100644 index d423536..0000000 --- a/src/callbacks.py +++ /dev/null @@ -1,843 +0,0 @@ -import os - -from .buildupdater import ( - align_space_frame, clear_captain, clear_doffs, clear_ground_build, clear_ship, clear_traits, - get_variable_slot_counts, set_skill_unlock_ground, set_skill_unlock_space, - slot_equipment_item, slot_trait_item, update_equipment_cat, update_starship_traits) -from .constants import ( - EQUIPMENT_TYPES, PRIMARY_SPECS, SECONDARY_SPECS, SHIP_TEMPLATE, SKILL_POINTS_FOR_RANK, - SPECIES, SPECIES_TRAITS) -from .datafunctions import ( - load_build_file, load_skill_tree_file, save_build_file, save_skill_tree_file) -from .iofunc import browse_path, get_ship_image, image, open_wiki_page -from .widgets import exec_in_thread - -from PySide6.QtCore import Qt - - -def switch_main_tab(self, index): - """ - Callback to switch between tabs. Switches build and both sidebar tabs. - - Parameters: - - :param index: index to switch to (0: space build, 1: ground build, 2: space skills, - 3: ground skills, 4: library, 5: settings) - """ - CHAR_TAB_MAP = { - 0: 0, - 1: 0, - 2: 0, - 3: 0, - 4: 1, - 5: 2 - } - self.widgets.build_tabber.setCurrentIndex(index) - self.widgets.sidebar_tabber.setCurrentIndex(index) - self.widgets.character_tabber.setCurrentIndex(CHAR_TAB_MAP[index]) - if index == 4: - self.widgets.sidebar.setVisible(False) - else: - self.widgets.sidebar.setVisible(True) - - -def faction_combo_callback(self, new_faction: str): - """ - Saves new faction to build and changes species selector choices. - """ - self.build['captain']['faction'] = new_faction - self.widgets.character['species'].clear() - if new_faction != '': - self.widgets.character['species'].addItems(('', *SPECIES[new_faction])) - self.build['captain']['species'] = '' - self.autosave() - - -def species_combo_callback(self, new_species: str): - """ - Saves new species to build and changes species trait - """ - self.build['captain']['species'] = new_species - if new_species == 'Alien': - if not self.building: - self.build['space']['traits'][10] = '' - self.build['ground']['traits'][10] = '' - self.build['space']['traits'][11] = '' - self.build['ground']['traits'][11] = '' - self.widgets.build['space']['traits'][10].show() - self.widgets.build['ground']['traits'][10].show() - self.widgets.build['space']['traits'][11].clear() - self.widgets.build['ground']['traits'][11].clear() - else: - self.widgets.build['space']['traits'][10].hide() - self.widgets.build['ground']['traits'][10].hide() - self.widgets.build['space']['traits'][10].clear() - self.widgets.build['ground']['traits'][10].clear() - self.build['space']['traits'][10] = None - self.build['ground']['traits'][10] = None - new_space_trait = SPECIES_TRAITS['space'].get(new_species, '') - new_ground_trait = SPECIES_TRAITS['ground'].get(new_species, '') - if new_space_trait == '': - self.widgets.build['space']['traits'][11].clear() - self.build['space']['traits'][11] = '' - else: - slot_trait_item(self, {'item': new_space_trait}, 'space', 'traits', 11) - if new_ground_trait == '': - self.widgets.build['ground']['traits'][11].clear() - self.build['ground']['traits'][11] = '' - else: - slot_trait_item(self, {'item': new_ground_trait}, 'ground', 'traits', 11) - self.autosave() - - -def spec_combo_callback(self, primary: bool, new_spec: str): - """ - Saves new spec to build and adjusts choices in other spec combo box. - """ - if primary: - self.build['captain']['primary_spec'] = new_spec - secondary_combo = self.widgets.character['secondary'] - secondary_specs = set() - remove_index = None - for i in range(secondary_combo.count()): - secondary_specs.add(secondary_combo.itemText(i)) - if secondary_combo.itemText(i) == new_spec and new_spec != '': - remove_index = i - if remove_index is not None: - secondary_combo.removeItem(remove_index) - secondary_combo.addItems((PRIMARY_SPECS | SECONDARY_SPECS) - secondary_specs) - else: - self.build['captain']['secondary_spec'] = new_spec - primary_combo = self.widgets.character['primary'] - primary_specs = set() - remove_index = None - for i in range(primary_combo.count()): - primary_specs.add(primary_combo.itemText(i)) - if primary_combo.itemText(i) == new_spec and new_spec != '': - remove_index = i - if remove_index is not None: - primary_combo.removeItem(remove_index) - primary_combo.addItems(PRIMARY_SPECS - primary_specs) - self.autosave() - - -def set_build_item(self, dictionary, key, value, autosave: bool = True): - """ - Assigns value to dictionary item. Triggers autosave. - - Parameters: - - :param dictionary: dictionary to use key on - - :param key: key for the dictionary - - :param value: value to be assigned to the item - - :param autosave: set to False to disable autosave - """ - dictionary[key] = value - if autosave: - self.autosave() - - -def elite_callback(self, state): - """ - Saves new state and updates build. - - Parameters: - - :param state: new state of the checkbox - """ - if state == Qt.CheckState.Checked: - if not self.building: - self.build['captain']['elite'] = True - self.build['space']['traits'][9] = '' - self.build['ground']['traits'][9] = '' - self.build['ground']['kit_modules'][5] = '' - self.build['ground']['ground_devices'][4] = '' - self.widgets.build['space']['traits'][9].show() - self.widgets.build['ground']['traits'][9].show() - self.widgets.build['ground']['kit_modules'][5].show() - self.widgets.build['ground']['ground_devices'][4].show() - else: - if not self.building: - self.build['captain']['elite'] = False - self.build['space']['traits'][9] = None - self.build['ground']['traits'][9] = None - self.build['ground']['kit_modules'][5] = None - self.build['ground']['ground_devices'][4] = None - self.widgets.build['space']['traits'][9].hide() - self.widgets.build['space']['traits'][9].clear() - self.widgets.build['ground']['traits'][9].hide() - self.widgets.build['ground']['traits'][9].clear() - self.widgets.build['ground']['kit_modules'][5].hide() - self.widgets.build['ground']['kit_modules'][5].clear() - self.widgets.build['ground']['ground_devices'][4].hide() - self.widgets.build['ground']['ground_devices'][4].clear() - self.autosave() - - -def get_boff_abilities( - self, environment: str, rank: int, boff_id: int) -> set: - """ - Returns list of boff abilities appropriate for the station described by the parameters. - - Parameters: - - :param environment: space/ground - - :param rank: rank of the ability slot - - :param boff_id: id of the boff - """ - if environment == 'space': - profession, specialization = self.build['space']['boff_specs'][boff_id] - if specialization == 'Temporal Operative': - specialization = 'Temporal' - else: - profession = self.build['ground']['boff_profs'][boff_id] - specialization = self.build['ground']['boff_specs'][boff_id] - abilities = self.cache.boff_abilities[environment][profession][rank] - if specialization != '': - abilities = abilities + self.cache.boff_abilities[environment][specialization][rank] - return abilities - - -def picker( - self, environment: str, build_key: str, build_subkey: int, button, equipment: bool = False, - boff_id=None): - """ - opens dialog to select item, stores it to build and updates item button - - Parameters: - - :param items: iterable of items available to pick from - - :param environment: space or ground - - :param build_key: key to self.build[environment]; for storing picked item - - :param build_subkey: index of the item within its build_key (category) - - :param button: reference to the button clicked - - :param equipment: set to True to show rarity, mark, and modifier selector (optional) - - :param boff_id: id of the boff; only set when picking boff abilities! (optional) - """ - modifiers = {} - image_suffix = '' - if equipment: - items = self.cache.equipment[build_key].keys() - modifiers = self.cache.modifiers[build_key] - elif build_key == 'boffs': - items = get_boff_abilities(self, environment, build_subkey, boff_id) - elif build_key == 'traits': - items = self.cache.traits[environment]['traits'].keys() - image_suffix = f'__{environment}__{build_key}' - elif build_key == 'starship_traits': - items = self.cache.starship_traits.keys() - image_suffix = '__space__starship_traits' - elif build_key == 'rep_traits': - items = self.cache.traits[environment]['rep_traits'].keys() - image_suffix = f'__{environment}__{build_key}' - elif build_key == 'active_rep_traits': - items = self.cache.traits[environment]['active_rep_traits'].keys() - image_suffix = f'__{environment}__{build_key}' - else: - items = [] - if self.settings.value('picker_relative', type=int) == 1: - pos = button.parent().mapToGlobal(button.pos()) - else: - pos = None - new_item = self.picker_window.pick_item(items, pos, equipment, modifiers, image_suffix) - if new_item is not None: - widget_storage = self.widgets.build[environment] - if equipment: - if 'consoles' in build_key: - type_ = EQUIPMENT_TYPES[self.cache.equipment[build_key][new_item['item']]['type']] - for i, mod in enumerate(new_item['modifiers']): - if mod not in self.cache.modifiers[type_]: - new_item['modifiers'][i] = '' - slot_equipment_item(self, new_item, environment, build_key, build_subkey) - else: - if boff_id is None: - slot_trait_item( - self, {'item': new_item['item']}, environment, build_key, build_subkey) - elif build_key == 'boffs': - ability_name, _, ability_rank = new_item['item'].rpartition(' ') - self.build[environment]['boffs'][boff_id][build_subkey] = { - 'item': ability_name, - 'rank': ability_rank - } - widget_storage['boffs'][boff_id][build_subkey].set_item(image(self, ability_name)) - widget_storage['boffs'][boff_id][build_subkey].tooltip = ( - self.cache.boff_abilities['all'][ability_name][ability_rank]) - self.autosave() - - -def boff_profession_callback_space(self, boff_id: int, new_spec: str): - """ - updates build with newly assigned profession; clears abilities of the old profession - """ - # to prevent overwriting the build while loading - if self.building: - return - if ' / ' in new_spec: - profession, specialization = new_spec.split(' / ') - if specialization == 'Temporal Operative': - specialization = 'Temporal' - for ability_num, ability in enumerate(self.build['space']['boffs'][boff_id]): - if ability is not None and ability != '': - # Lt. Commander rank contains all abilities - if ability['item'] not in self.cache.boff_abilities['space'][specialization][2]: - self.build['space']['boffs'][boff_id][ability_num] = '' - self.widgets.build['space']['boffs'][boff_id][ability_num].clear() - else: - profession = new_spec - specialization = '' - for ability_num, ability in enumerate(self.build['space']['boffs'][boff_id]): - if ability is not None and ability != '': - self.build['space']['boffs'][boff_id][ability_num] = '' - self.widgets.build['space']['boffs'][boff_id][ability_num].clear() - self.build['space']['boff_specs'][boff_id] = [profession, specialization] - self.autosave() - - -def boff_label_callback_ground(self, boff_id: int, type_: str, new_text: str): - """ - updates build with newly assigned profession or specialization; clears invalid abilities - - Parameters: - - :param boff_id: number of the boff station - - :param type_: "boff_profs" / "boff_specs" - - :param new_text: new profession / specialization - """ - if self.building: - return - self.build['ground'][type_][boff_id] = new_text - other_type = 'boff_profs' if type_ == 'boff_specs' else 'boff_specs' - other_text = self.build['ground'][other_type][boff_id] - for ability_num, ability in enumerate(self.build['ground']['boffs'][boff_id]): - if ability is not None and ability != '': - # Lt. Commander and Commander rank combined contain all abilities - if (ability['item'] not in self.cache.boff_abilities['ground'][new_text][2] - and ability['item'] not in self.cache.boff_abilities['ground'][new_text][3] - and ability['item'] not in self.cache.boff_abilities['ground'][other_text][2] - and ability['item'] not in self.cache.boff_abilities['ground'][other_text][3]): - self.build['ground']['boffs'][boff_id][ability_num] = '' - self.widgets.build['ground']['boffs'][boff_id][ability_num].clear() - self.autosave() - - -def select_ship(self): - """ - Opens ship picker and updates UI to reflect new ship. - """ - new_ship = self.ship_selector_window.pick_ship() - if new_ship is None: - return - self.building = True - self.widgets.ship['button'].setText(new_ship) - ship_data = self.cache.ships[new_ship] - exec_in_thread( - self, self.images.get_ship_image, ship_data['image'][5:], - result=lambda img: self.widgets.ship['image'].set_image(*img)) - tier = ship_data['tier'] - self.widgets.ship['tier'].clear() - if tier == 6: - self.widgets.ship['tier'].addItems(('T6', 'T6-X', 'T6-X2')) - elif tier == 5: - self.widgets.ship['tier'].addItems(('T5', 'T5-U', 'T5-X', 'T5-X2')) - else: - self.widgets.ship['tier'].addItem(f'T{tier}') - self.build['space']['ship'] = new_ship - self.build['space']['tier'] = f'T{tier}' - if ship_data['equipcannons'] == 'yes': - self.widgets.ship['dc'].show() - else: - self.widgets.ship['dc'].hide() - align_space_frame(self, ship_data, clear=True) - self.building = False - self.autosave() - - -def tier_callback(self, new_tier: str): - """ - Updates build according to new tier - """ - if self.building: - return - self.build['space']['tier'] = new_tier - ship_name = self.build['space']['ship'] - if ship_name == '': - ship_data = SHIP_TEMPLATE - else: - ship_data = self.cache.ships[ship_name] - uni, eng, sci, tac, devices, starship_traits = get_variable_slot_counts(self, ship_data) - update_equipment_cat(self, 'uni_consoles', uni, can_hide=True) - update_equipment_cat(self, 'eng_consoles', eng) - update_equipment_cat(self, 'sci_consoles', sci) - update_equipment_cat(self, 'tac_consoles', tac) - update_equipment_cat(self, 'devices', devices) - update_starship_traits(self, starship_traits) - self.autosave() - - -def clear_build_callback(self): - """ - Clears current build section - """ - current_tab = self.widgets.build_tabber.currentIndex() - self.building = True - if current_tab == 0: - clear_space_build(self) - elif current_tab == 1: - clear_ground_build(self) - elif current_tab == 2: - clear_space_skills(self) - elif current_tab == 3: - clear_ground_skills(self) - self.building = False - - -def clear_space_build(self): - """ - clears space build - """ - self.building = True - clear_ship(self) - align_space_frame(self, SHIP_TEMPLATE, clear=True) - clear_traits(self, 'space') - clear_doffs(self, 'space') - self.building = False - self.autosave() - - -def clear_space_skills(self): - """ - resets space skill tree - """ - self.widgets.build['skill_desc']['space'].clear() - self.build['skill_desc']['space'] = '' - self.build['space_skills'] = { - 'eng': [False] * 30, - 'sci': [False] * 30, - 'tac': [False] * 30 - } - self.cache.skills['space_points_total'] = 0 - self.cache.skills['space_points_eng'] = 0 - self.widgets.skill_counts_space['eng'].setText('0') - self.cache.skills['space_points_sci'] = 0 - self.widgets.skill_counts_space['sci'].setText('0') - self.cache.skills['space_points_tac'] = 0 - self.widgets.skill_counts_space['tac'].setText('0') - self.cache.skills['space_points_rank'] = [0] * 5 - for career in ('eng', 'sci', 'tac'): - for skill_button in self.widgets.build['space_skills'][career]: - skill_button.clear_overlay() - skill_button.highlight = False - self.build['skill_unlocks'][career] = [None] * 5 - for bar_segment in self.widgets.skill_bonus_bars[career]: - bar_segment.setChecked(False) - for unlock_button in self.widgets.build['skill_unlocks'][career]: - unlock_button.clear() - - -def clear_ground_skills(self): - """ - resets ground skill tree - """ - self.widgets.build['skill_desc']['ground'].clear() - self.build['skill_desc']['ground'] = '' - self.build['ground_skills'] = [ - [False] * 6, - [False] * 6, - [False] * 4, - [False] * 4 - ] - self.build['skill_unlocks']['ground'] = [None] * 5 - self.cache.skills['ground_points_total'] = 0 - self.widgets.skill_count_ground.setText('0') - for skill_subtree in self.widgets.build['ground_skills']: - for skill_button in skill_subtree: - skill_button.clear_overlay() - skill_button.highlight = False - for unlock_button in self.widgets.build['skill_unlocks']['ground']: - unlock_button.clear() - for bar_segment in self.widgets.skill_bonus_bars['ground']: - bar_segment.setChecked(False) - - -def clear_all(self): - """ - Clears space and ground build, skills and captain info - """ - self.building = True - clear_space_build(self) - clear_ground_build(self) - clear_captain(self) - clear_space_skills(self) - clear_ground_skills(self) - self.building = False - self.autosave() - - -def set_ui_scale_setting(self, new_value: int): - """ - Calculates new_value / 50 and stores it to settings. - - Parameters: - - :param new_value: 50 times the ui scale percentage - """ - setting_value = f'{new_value / 50:.2f}' - self.settings.setValue('ui_scale', setting_value) - return setting_value - - -def load_build_callback(self): - """ - Loads build from file - """ - load_path = browse_path( - self, self.config['config_subfolders']['library'], - 'SETS Files (*.json *.png);;JSON file (*.json);;PNG image (*.png);;Any File (*.*)') - if load_path != '': - load_build_file(self, load_path) - - -def load_skills_callback(self): - """ - Loads skills from file - """ - load_path = browse_path( - self, self.config['config_subfolders']['library'], - 'SETS Files (*.json *.png);;JSON file (*.json);;PNG image (*.png);;Any File (*.*)') - if load_path != '': - load_skill_tree_file(self, load_path) - - -def save_build_callback(self): - """ - Saves build to file - """ - if self.widgets.ship['button'].text() == '': - proposed_filename = '(Ship Template)' - else: - proposed_filename = f"({self.widgets.ship['button'].text()})" - if self.widgets.ship['name'].text() != '': - proposed_filename = f"{self.widgets.ship['name'].text()} {proposed_filename}" - default_path = os.path.join(self.config['config_subfolders']['library'], proposed_filename) - if self.settings.value('default_save_format') == 'PNG': - file_types = 'PNG image (*.png);;JSON file (*.json);;Any File (*.*)' - else: - file_types = 'JSON file (*.json);;PNG image (*.png);;Any File (*.*)' - save_path = browse_path(self, default_path, file_types, save=True) - if save_path != '': - save_build_file(self, save_path) - - -def save_skills_callback(self): - """ - Save skills to file - """ - default_path = os.path.join(self.config['config_subfolders']['library'], 'Skill Tree') - if self.settings.value('default_save_format') == 'PNG': - file_types = 'PNG image (*.png);;JSON file (*.json);;Any File (*.*)' - else: - file_types = 'JSON file (*.json);;PNG image (*.png);;Any File (*.*)' - save_path = browse_path(self, default_path, file_types, save=True) - if save_path != '': - save_skill_tree_file(self, save_path) - - -def ship_info_callback(self): - """ - Opens wiki page of ship if ship is slotted - """ - if self.build['space']['ship'] != '': - open_wiki_page(self.cache.ships[self.build['space']['ship']]['Page']) - - -def open_wiki_context(self): - """ - Opens wiki page of item in `self.context_menu.clicked_slot`. - """ - slot = self.context_menu.clicked_slot - if self.context_menu.clicked_boff_station != -1: - boff_id = self.context_menu.clicked_boff_station - item = self.build[slot.environment][slot.type][boff_id][slot.index] - if item is not None and item != '': - open_wiki_page(f"{item['item']}_(ability)") - return - item = self.build[slot.environment][slot.type][slot.index] - if item is None or item == '': - return - if slot.type == 'starship_traits': - open_wiki_page(f"{item['item']}_(starship_trait)") - elif 'traits' in slot.type: - open_wiki_page(f"{item['item']}_({slot.environment}_trait)") - else: - open_wiki_page(f"{self.cache.equipment[slot.type][item['item']]['Page']}#{item['item']}") - - -def copy_equipment_item(self): - """ - Copies equipment item clicked on. - """ - slot = self.context_menu.clicked_slot - item = self.build[slot.environment][slot.type][slot.index] - if item is None or item == '': - self.context_menu.copied_item = None - self.context_menu.copied_item_type = None - else: - self.context_menu.copied_item = item - item_type = EQUIPMENT_TYPES[self.cache.equipment[slot.type][item['item']]['type']] - self.context_menu.copied_item_type = item_type - - -def paste_equipment_item(self): - """ - Pastes copied item into clicked slot if slot types are compatible - """ - slot = self.context_menu.clicked_slot - copied_type = self.context_menu.copied_item_type - if slot.type == copied_type: - slot_equipment_item( - self, self.context_menu.copied_item, slot.environment, slot.type, slot.index) - elif copied_type == 'ship_weapon' and ( - slot.type == 'fore_weapons' or slot.type == 'aft_weapons'): - slot_equipment_item( - self, self.context_menu.copied_item, slot.environment, slot.type, slot.index) - elif (copied_type == 'uni_consoles' and 'consoles' in slot.type - or slot.type == 'uni_consoles' and 'consoles' in copied_type): - slot_equipment_item( - self, self.context_menu.copied_item, slot.environment, slot.type, slot.index) - self.autosave() - - -def clear_slot(self): - """ - Clears slot that was rightclicked on. - """ - slot = self.context_menu.clicked_slot - if self.context_menu.clicked_boff_station == -1: - self.widgets.build[slot.environment][slot.type][slot.index].clear() - self.build[slot.environment][slot.type][slot.index] = '' - else: - boff_id = self.context_menu.clicked_boff_station - self.widgets.build[slot.environment][slot.type][boff_id][slot.index].clear() - self.build[slot.environment][slot.type][boff_id][slot.index] = '' - self.autosave() - - -def edit_equipment_item(self): - """ - Edit mark, modifiers and rarity of rightclicked item. - """ - slot = self.context_menu.clicked_slot - item = self.build[slot.environment][slot.type][slot.index] - if slot.type == 'fore_weapons' or slot.type == 'aft_weapons': - item_type = slot.type - else: - item_type = EQUIPMENT_TYPES[self.cache.equipment[slot.type][item['item']]['type']] - modifiers = self.cache.modifiers[item_type] - new_item = self.edit_window.edit_item(item, modifiers) - if new_item is not None: - slot_equipment_item(self, new_item, slot.environment, slot.type, slot.index) - self.autosave() - - -def doff_spec_callback(self, new_spec: str, environment: str, doff_id: int): - """ - Callback for duty officer specialization combobox. - - Parameters: - - :param new_spec: selected specialization - - :param environment: "space" / "ground" - - :param doff_id: index of the doff - """ - if self.building: - return - self.build[environment]['doffs_spec'][doff_id] = new_spec - self.build[environment]['doffs_variant'][doff_id] = '' - self.widgets.build[environment]['doffs_variant'][doff_id].clear() - if new_spec != '': - variants = getattr(self.cache, f'{environment}_doffs')[new_spec].keys() - self.widgets.build[environment]['doffs_variant'][doff_id].addItems({''} | variants) - self.autosave() - - -def doff_variant_callback(self, new_variant: str, environment: str, doff_id: int): - """ - Callback for duty officer variant combobox. - - Parameters: - - :param new_variant: selected variant - - :param environment: "space" / "ground" - - :param doff_id: index of the doff - """ - if self.building: - return - self.build[environment]['doffs_variant'][doff_id] = new_variant - self.autosave() - - -def toggle_space_skill(self, current_state: bool, career: str, skill_id: int): - """ - Activates space skill if it's deactivated, deactivates skill if it's activated. - - Parameters: - - :param current_state: state of the button before toggling - - :param career: "eng" / "tac" / "sci" - - :param skill_id: id of the skill node (index in self.build and self.widgets.build) - """ - if current_state: - self.widgets.build['space_skills'][career][skill_id].clear_overlay() - self.widgets.build['space_skills'][career][skill_id].highlight = False - self.build['space_skills'][career][skill_id] = False - self.cache.skills['space_points_total'] -= 1 - self.cache.skills[f'space_points_{career}'] -= 1 - self.cache.skills['space_points_rank'][int(skill_id / 6)] -= 1 - segment_index = self.cache.skills[f'space_points_{career}'] - if segment_index < 24: - self.widgets.skill_bonus_bars[career][segment_index].setChecked(False) - if segment_index % 5 == 4: - button_index = (segment_index - 4) // 5 - set_skill_unlock_space(self, career, button_index, None) - elif segment_index == 23: - set_skill_unlock_space(self, career, 4, None) - elif 24 <= segment_index <= 26: - set_skill_unlock_space(self, career, 4, 0, segment_index) - else: - self.widgets.build['space_skills'][career][skill_id].set_overlay(self.cache.overlays.check) - self.widgets.build['space_skills'][career][skill_id].highlight = True - self.build['space_skills'][career][skill_id] = True - self.cache.skills['space_points_total'] += 1 - self.cache.skills[f'space_points_{career}'] += 1 - self.cache.skills['space_points_rank'][int(skill_id / 6)] += 1 - segment_index = self.cache.skills[f'space_points_{career}'] - 1 - if segment_index < 24: - self.widgets.skill_bonus_bars[career][segment_index].setChecked(True) - if segment_index % 5 == 4: - button_index = (segment_index - 4) // 5 - set_skill_unlock_space(self, career, button_index, 0) - elif segment_index == 23: - set_skill_unlock_space(self, career, 4, -1, 24) - elif 24 <= segment_index <= 25: - set_skill_unlock_space(self, career, 4, 0, segment_index + 1) - elif segment_index == 26: - set_skill_unlock_space(self, career, 4, 3, 27) - self.widgets.skill_counts_space[career].setText( - str(self.cache.skills[f'space_points_{career}'])) - self.autosave() - - -def skill_unlock_callback(self, bar: str, unlock_id: int): - """ - Callback for skill unlock buttons - - Parameters: - - :param bar: "eng" / "sci" / "tac" / "ground" - - :param unlock_id: index of the unlock button - """ - current_state = self.build['skill_unlocks'][bar][unlock_id] - if current_state is None: - return - if bar == 'ground': - if current_state == 0: - set_skill_unlock_ground(self, unlock_id, 1) - elif current_state == 1: - set_skill_unlock_ground(self, unlock_id, 0) - self.autosave() - else: - if unlock_id < 4: - if current_state == 0: - set_skill_unlock_space(self, bar, unlock_id, 1) - elif current_state == 1: - set_skill_unlock_space(self, bar, unlock_id, 0) - self.autosave() - else: - points_spent = self.cache.skills[f'space_points_{bar}'] - if 25 <= points_spent <= 26: - set_skill_unlock_space(self, bar, 4, (current_state + 1) % 3, points_spent) - self.autosave() - - -def toggle_ground_skill(self, current_state: bool, skill_group: int, skill_id: int): - """ - Activates ground skill if it's deactivated, deactivates skill if it's activated. - - Parameters: - - :param current_state: state of the button before toggling - - :param skill_group: number [0, 3] identifying the skill group - - :param skill_id: index of the skill within the group - """ - if current_state: - self.widgets.build['ground_skills'][skill_group][skill_id].clear_overlay() - self.widgets.build['ground_skills'][skill_group][skill_id].highlight = False - self.build['ground_skills'][skill_group][skill_id] = False - self.cache.skills['ground_points_total'] -= 1 - segment_index = self.cache.skills['ground_points_total'] - self.widgets.skill_bonus_bars['ground'][segment_index].setChecked(False) - if segment_index % 2 == 1: - button_index = (segment_index - 1) // 2 - set_skill_unlock_ground(self, button_index, None) - else: - self.widgets.build['ground_skills'][skill_group][skill_id].set_overlay( - self.cache.overlays.check) - self.widgets.build['ground_skills'][skill_group][skill_id].highlight = True - self.build['ground_skills'][skill_group][skill_id] = True - self.cache.skills['ground_points_total'] += 1 - segment_index = self.cache.skills['ground_points_total'] - 1 - self.widgets.skill_bonus_bars['ground'][segment_index].setChecked(True) - if segment_index % 2 == 1: - button_index = (segment_index - 1) // 2 - set_skill_unlock_ground(self, button_index, 0) - self.widgets.skill_count_ground.setText(str(self.cache.skills['ground_points_total'])) - self.autosave() - - -def skill_callback_space(self, career: str, skill_id: int, grouping: str): - """ - Callback for space skill node - - Parameters: - - :param career: "eng" / "tac" / "sci" - - :param skill_id: id of the skill node (index in self.build and self.widgets.build) - - :param grouping: type of skill grouping: "column" / "pair+1" / "separate" - """ - skill_active = self.build['space_skills'][career][skill_id] - skill_lvl = skill_id % 3 - skill_rank = int(skill_id / 6) - if skill_active: # check for valid deselect - if (skill_lvl == 2 - or grouping != 'column' and skill_lvl == 1 - or not self.build['space_skills'][career][skill_id + 1]): - skill_count = sum(self.cache.skills['space_points_rank'][:skill_rank + 1]) - for rank_offset, points_required in enumerate(SKILL_POINTS_FOR_RANK[skill_rank + 1:]): - if (skill_count - 1 < points_required - and self.cache.skills['space_points_total'] - skill_count > 0): - return - skill_count += self.cache.skills['space_points_rank'][skill_rank + rank_offset + 1] - toggle_space_skill(self, skill_active, career, skill_id) - else: # check for valid select - if 46 > self.cache.skills['space_points_total'] >= SKILL_POINTS_FOR_RANK[skill_rank]: - if skill_lvl == 0: - toggle_space_skill(self, skill_active, career, skill_id) - elif grouping == 'column' and self.build['space_skills'][career][skill_id - 1]: - toggle_space_skill(self, skill_active, career, skill_id) - elif grouping != 'column' and self.build['space_skills'][career][skill_id - skill_lvl]: - toggle_space_skill(self, skill_active, career, skill_id) - - -def skill_callback_ground(self, skill_group: int, skill_id: int): - """ - Callback for ground skill node - - Parameters: - - :param skill_group: number [0, 3] identifying the skill group - - :param skill_id: index of the skill within the group - """ - skill_active = self.build['ground_skills'][skill_group][skill_id] - if skill_active: # check for valid deselect - if skill_id == 0 and ( - self.build['ground_skills'][skill_group][1] - or self.build['ground_skills'][skill_group][2] - or skill_group <= 1 and self.build['ground_skills'][skill_group][4]): - return - elif skill_id % 2 == 0 and self.build['ground_skills'][skill_group][skill_id + 1]: - return - toggle_ground_skill(self, skill_active, skill_group, skill_id) - else: # check for valid select - if self.cache.skills['ground_points_total'] < 10: - if skill_id % 2 == 1 and self.build['ground_skills'][skill_group][skill_id - 1]: - toggle_ground_skill(self, skill_active, skill_group, skill_id) - elif skill_id == 0: - toggle_ground_skill(self, skill_active, skill_group, skill_id) - elif (skill_id == 2 or skill_id == 4) and self.build['ground_skills'][skill_group][0]: - toggle_ground_skill(self, skill_active, skill_group, skill_id) diff --git a/src/cargomanager.py b/src/cargomanager.py index ea075e5..c1b6360 100644 --- a/src/cargomanager.py +++ b/src/cargomanager.py @@ -1,24 +1,148 @@ from pathlib import Path from time import time -from .constants import SEVEN_DAYS_IN_SECONDS -from .iofunc import load_json__new +from .config import SETSSettings +from .constants import ( + CAREERS, BOFF_RANKS, DOFF_QUERY_URL, EQUIPMENT_TYPES, ITEM_QUERY_URL, MODIFIER_QUERY, + PRIMARY_SPECS, SEVEN_DAYS_IN_SECONDS, SHIP_QUERY_URL, STARSHIP_TRAIT_QUERY_URL, TRAIT_QUERY_URL, + TRAYSKILL_QUERY) +from .downloader import Downloader +from .iofunc import load_json, store_json +from .textedit import ( + create_equipment_tooltip, create_trait_tooltip, dewikify, parse_wikitext, + sanitize_equipment_name) +from .theme import AppTheme class CargoManager(): """Manages Cargo data and cache""" - def __init__(self, folders: dict[str, str]): + def __init__( + self, folders: dict[str, Path], app_dir: Path, downloader: Downloader, + settings: SETSSettings, theme: AppTheme): """ Parameters: - :param folders: folder names and paths of config folder """ - self._folders: dict[str, Path] = {name: Path(path) for name, path in folders.items()} - self.boff_abilities: dict[str, dict[str, dict]] = { + self._folders: dict[str, Path] = folders + self._app_dir: Path = app_dir + self._downloader: Downloader = downloader + self._settings: SETSSettings = settings + self._theme: AppTheme = theme + self.ships: dict[str, dict[str]] = dict() + self.equipment: dict[str, dict[str, dict[str]]] = { + equipment_type: dict() for equipment_type in EQUIPMENT_TYPES.values()} + self.modifiers: dict[str, dict[str, dict[str, str | bool]]] = { + type_: dict() for type_ in EQUIPMENT_TYPES.values()} + self.starship_traits: dict[str, dict[str]] = dict() + self.space_traits: dict[str, dict[str, dict[str]]] = { + 'traits': dict(), + 'rep_traits': dict(), + 'active_rep_traits': dict() + } + self.ground_traits: dict[str, dict[str, dict[str]]] = { + 'traits': dict(), + 'rep_traits': dict(), + 'active_rep_traits': dict() + } + self.ground_doffs: dict[str, dict[str, dict[str]]] = dict() + self.space_doffs: dict[str, dict[str, dict[str]]] = dict() + self.boff_abilities: dict[str, dict[str, list[list[str]] | dict[str, str]]] = { 'space': self.boff_dict(), 'ground': self.boff_dict(), 'all': dict() } + self.item_aliases: dict = dict() + self.skills = { + 'space': dict(), + 'space_unlocks': dict(), + 'ground': dict(), + 'ground_unlocks': dict() + } + self.image_set: set[str] = set() + self.alt_images: dict[str, str] = dict() + self.failed_images: dict[str, int] = dict() + + def load_static_data(self): + """ + Loads skill data and item aliases. + """ + local_folder = self._app_dir / 'local' + self.item_aliases = load_json(local_folder / 'aliases.json') + space_skill_data = load_json(local_folder / 'space_skills.json') + self.skills['space'] = space_skill_data['space'] + self.skills['space_unlocks'] = space_skill_data['space_unlocks'] + ground_skill_data = load_json(local_folder / 'ground_skills.json') + self.skills['ground'] = ground_skill_data['ground'] + self.skills['ground_unlocks'] = ground_skill_data['ground_unlocks'] + + def provision_cargo_data(self): + """ + (Down-) loads cargo data or gets cached cargo data. + """ + images_updated = False + force_image_update = False + all_images = self.get_cached_data('images_list.json') + if all_images is None: + image_set = set() + force_image_update = True + else: + image_set = set(all_images) + alt_images = self.get_cached_data('alt_images.json') + if alt_images is None: + alt_images = dict() + force_image_update = True + self.ships = self.get_cached_data('ships.json') + if self.ships is None: + self.cache_ship_data() + equipment_data = self.get_cached_data('equipment.json') + if equipment_data is None or force_image_update: + self.cache_equipment_data() + images_updated = True + else: + self.equipment = equipment_data + space_trait_data = self.get_cached_data('space_traits.json') + ground_trait_data = self.get_cached_data('ground_traits.json') + if space_trait_data is None or ground_trait_data is None or force_image_update: + self.cache_trait_data() + images_updated = True + else: + self.space_traits = space_trait_data + self.ground_traits = ground_trait_data + starship_trait_data = self.get_cached_data('starship_traits.json') + if starship_trait_data is None or force_image_update: + self.cache_starship_trait_data() + images_updated = True + else: + self.starship_traits = starship_trait_data + boff_data = self.get_cached_data('boff_abilities.json') + if boff_data is None or force_image_update: + self.cache_boff_data() + images_updated = True + else: + self.boff_abilities = boff_data + modifier_data = self.get_cached_data('modifiers.json') + if modifier_data is None: + self.cache_modifier_data() + else: + self.modifiers = modifier_data + space_doff_data = self.get_cached_data('space_doffs.json') + ground_doff_data = self.get_cached_data('ground_doffs.json') + if space_doff_data is None or ground_doff_data is None: + self.cache_duty_officer_data() + else: + self.space_doffs = space_doff_data + self.ground_doffs = ground_doff_data + if images_updated: + alt_images.update(self.alt_images) + store_json(alt_images, self._folders['cache'] / 'alt_images.json') + image_set |= self.image_set + store_json(list(image_set), self._folders['cache'] / 'images_list.json') + self.alt_images = alt_images + self.image_set = image_set + self.failed_images = self.get_cached_data('images_failed.json') + if self.failed_images is None: + self.failed_images = dict() def get_cached_data(self, file_name: str) -> dict | list | None: """ @@ -28,19 +152,296 @@ def get_cached_data(self, file_name: str) -> dict | list | None: - :param file_name: name of the cache file to load """ file_path = self._folders['cache'] / file_name - last_modified = file_path.stat().st_mtime - if time() - last_modified < SEVEN_DAYS_IN_SECONDS: - return load_json__new(file_path) + if file_path.is_file(): + last_modified = file_path.stat().st_mtime + if time() - last_modified < SEVEN_DAYS_IN_SECONDS: + return load_json(file_path) return None + def store_failed_images(self): + """ + Stores failed images to cache folder + """ + store_json(self.failed_images, self._folders['cache'] / 'images_failed.json') + + def cache_ship_data(self): + """ + Retrieves ship data and caches it. + """ + ship_cargo_data: list[dict[str]] = self.get_cargo_data('ship_list.json', SHIP_QUERY_URL) + self.ships = {ship['Page']: ship for ship in ship_cargo_data} + store_json(self.ships, self._folders['cache'] / 'ships.json') + + def cache_equipment_data(self): + """ + Retrieves equipment data and caches it. + """ + equipment_cargo_data: list[dict[str, str | None]] = self.get_cargo_data( + 'equipment.json', ITEM_QUERY_URL) + equipment_types = set(EQUIPMENT_TYPES.keys()) + tooltip_styles = self._theme.tooltips + elite_hangar = { + 'Hangar - Elite Federation Mission Scout Ships', + 'Hangar - Elite Valor Fighters' + } + for item in equipment_cargo_data: + if item['type'] in equipment_types: + if item['type'] == 'Hangar Bay' and item['name'] not in elite_hangar and ( + item['name'].startswith('Hangar - Advanced') + or item['name'].startswith('Hangar - Elite')): + continue + name = sanitize_equipment_name(item['name']) + self.equipment[EQUIPMENT_TYPES[item['type']]][name] = { + 'Page': item['Page'], + 'name': name, + 'rarity': item['rarity'], + 'type': item['type'], + 'tooltip': create_equipment_tooltip(item, tooltip_styles) + } + self.image_set.add(name) + self.equipment['fore_weapons'].update(self.equipment['ship_weapon']) + self.equipment['aft_weapons'].update(self.equipment['ship_weapon']) + del self.equipment['ship_weapon'] + self.equipment['tac_consoles'].update(self.equipment['uni_consoles']) + self.equipment['sci_consoles'].update(self.equipment['uni_consoles']) + self.equipment['eng_consoles'].update(self.equipment['uni_consoles']) + self.equipment['uni_consoles'].update(self.equipment['tac_consoles']) + self.equipment['uni_consoles'].update(self.equipment['sci_consoles']) + self.equipment['uni_consoles'].update(self.equipment['eng_consoles']) + store_json(self.equipment, self._folders['cache'] / 'equipment.json') + + def cache_trait_data(self): + """ + Retrieves personal and reputation trait data and caches it. + """ + trait_cargo_data: list[dict[str]] = self.get_cargo_data('traits.json', TRAIT_QUERY_URL) + tooltip_styles = self._theme.tooltips + for trait in trait_cargo_data: + name = trait['name'] + if trait['type'] != 'doff' and trait['type'] != 'boff' and name is not None: + if trait['type'] == 'reputation': + trait_type = 'rep_traits' + elif trait['type'] == 'activereputation': + trait_type = 'active_rep_traits' + else: + trait_type = 'traits' + try: + trait_data = { + 'Page': trait['Page'], + 'name': name, + 'tooltip': create_trait_tooltip( + name, trait['description'], trait_type, trait['environment'], + tooltip_styles) + } + if trait['environment'] == 'space': + self.space_traits[trait_type][name] = trait_data + else: + self.ground_traits[trait_type][name] = trait_data + if trait['icon_name'] is None: + self.image_set.add(name) + else: + self.image_set.add(trait['icon_name']) + self.alt_images[f'{name}__{trait["environment"]}__{trait_type}'] = ( + trait['icon_name']) + # catch wrong values in trait['environment'] (cargo issue) + except (KeyError, AttributeError): + pass + store_json(self.space_traits, self._folders['cache'] / 'space_traits.json') + store_json(self.ground_traits, self._folders['cache'] / 'ground_traits.json') + + def cache_starship_trait_data(self): + """ + Retrieves starship trait data and caches it. + """ + shiptrait_cargo = self.get_cargo_data('starship_traits.json', STARSHIP_TRAIT_QUERY_URL) + styles = self._theme.tooltips + for ship_trait in shiptrait_cargo: + name = ship_trait['name'] + if ship_trait['icon_name'] is None: + self.image_set.add(name) + else: + self.image_set.add(ship_trait['icon_name']) + self.alt_images[f"{name}__space__starship_traits"] = ship_trait['icon_name'] + self.starship_traits[name] = { + 'Page': ship_trait['Page'], + 'name': name, + 'obtained': ship_trait['obtained'], + 'tooltip': ( + f"

{name}

" + f"

Starship Trait

" + f"{ship_trait['short']}

{parse_wikitext(ship_trait['detailed'], styles)}") + } + store_json(self.starship_traits, self._folders['cache'] / 'starship_traits.json') + + def cache_boff_data(self): + """ + Retrieves bridge officer data and caches it. + """ + boff_cargo: list[dict[str, str]] = self.get_cargo_data( + 'boff_abilities.json', TRAYSKILL_QUERY) + boff_types = CAREERS | PRIMARY_SPECS + styles = self._theme.tooltips + rank_numbers = ((1, 'I'), (2, 'II'), (3, 'III')) + for boff_ability in boff_cargo: + boff_region = boff_ability['region'].lower() + boff_type = boff_ability['type'] + if boff_type not in boff_types or boff_region != 'space' and boff_region != 'ground': + continue + boff_name = boff_ability['name'] + ability_item = { + 'Page': boff_ability['_pageName'], + 'name': boff_name, + 'I': '', + 'II': '', + 'III': '' + } + desc = boff_ability['description'] + desc_long = boff_ability['description long'] + for decimal, roman in rank_numbers: + rank_id = BOFF_RANKS.get(boff_ability[f'rank{decimal}rank'], 0) - 1 + if rank_id >= 0: + self.boff_abilities[boff_region][boff_type][rank_id].append( + boff_name + ' ' + roman) + ability_item[roman] = ( + f"

{boff_name} {roman}

" + f"

{desc}

{desc_long}

" + f"{parse_wikitext(dewikify(boff_ability[f'rank{decimal}info']), styles)}") + self.boff_abilities['all'][boff_name] = ability_item + self.image_set |= self.boff_abilities['all'].keys() + store_json(self.boff_abilities, self._folders['cache'] / 'boff_abilities.json') + + def cache_modifier_data(self): + """ + Retrieves modifier data and caches it. + """ + mod_cargo_data: list[dict[str, str | list[str] | int | None]] = self.get_cargo_data( + 'modifiers.json', MODIFIER_QUERY) + for modifier in mod_cargo_data: + try: + if modifier['available'][0] == '': + modifier['available'] = list() + except (IndexError, TypeError): + modifier['available'] = list() + for mod_type in modifier['type']: + mod_name = modifier['modifier'].replace('>', '>') + try: + epic = bool(modifier['isepic']) + self.modifiers[EQUIPMENT_TYPES[mod_type]][mod_name] = { + 'stats': modifier['stats'], + 'available': modifier['available'], + 'epic': epic, + 'isunique': False if epic else bool(modifier['isunique']), + } + except KeyError: + pass + self.modifiers['fore_weapons'].update(self.modifiers['ship_weapon']) + self.modifiers['aft_weapons'].update(self.modifiers['ship_weapon']) + del self.modifiers['ship_weapon'] + self.modifiers['uni_consoles'].update(self.modifiers['sci_consoles']) + self.modifiers['uni_consoles'].update(self.modifiers['eng_consoles']) + self.modifiers['uni_consoles'].update(self.modifiers['tac_consoles']) + store_json(self.modifiers, self._folders['cache'] / 'modifiers.json') + + def cache_duty_officer_data(self): + """ + Retrieves duty officer data and caches it. + """ + doff_cargo_data = self.get_cargo_data('doffs.json', DOFF_QUERY_URL) + for doff in doff_cargo_data: + doff['description'] = dewikify(doff['description'], remove_formatting=True) + for rarity in ('white', 'green', 'blue', 'purple', 'violet', 'gold'): + if isinstance(doff[rarity], str): + doff[rarity] = dewikify(doff[rarity], remove_formatting=True) + if doff['shipdutytype'] == 'Space': + self.cache_doff_single(self.space_doffs, doff) + elif doff['shipdutytype'] == 'Ground': + self.cache_doff_single(self.ground_doffs, doff) + elif doff['shipdutytype'] is not None: + self.cache_doff_single(self.space_doffs, doff) + self.cache_doff_single(self.ground_doffs, doff) + store_json(self.space_doffs, self._folders['cache'] / 'space_doffs.json') + store_json(self.ground_doffs, self._folders['cache'] / 'ground_doffs.json') + + def cache_doff_single(self, cache: dict, doff: dict): + """ + Puts a single doff into cache. + + Parameters: + - :param cache: cache dictionary to store doff into + - :param doff: the doff itself + """ + try: + cache[doff['spec']][doff['description']] = doff + except KeyError: + cache[doff['spec']] = dict() + cache[doff['spec']][doff['description']] = doff + + def get_cargo_data( + self, filename: str, url: str, ignore_cache_age: bool = False) -> dict | list: + """ + Retrieves cargo data for specific table. Downloads cargo data from wiki if cargo cache is + empty. Updates cargo cache. + + Parameters: + - :param filename: filename of cache file + - :param url: url to cargo table + - :param ignore_cache_age: True if cache of any age should be accepted + """ + cargo_file = self._folders['cargo'] / filename + + # try loading from cache + if cargo_file.is_file(): + last_modified = cargo_file.stat().st_mtime + if time() - last_modified < SEVEN_DAYS_IN_SECONDS or ignore_cache_age: + cargo_data = load_json(cargo_file) + if cargo_data is not None: + return cargo_data + + # download cargo data if loading from cache failed or data should be updated + cargo_data = self._downloader.download_cargo_table(url, filename) + if cargo_data is None: + if ignore_cache_age: + backup_path = self._folders['backups'] / filename + auto_backup_path = self._folders['auto_backups'] / filename + if self._settings.pref_backup == 0: + backup_paths = (auto_backup_path, backup_path) + else: + backup_paths = (backup_path, auto_backup_path) + for path in backup_paths: + if path.is_file(): + cargo_data = load_json(path) + if cargo_data is not None: + store_json(cargo_data, cargo_file) + return cargo_data + # TODO what happens when both backups fail? + else: + return self.get_cargo_data(filename, url, ignore_cache_age=True) + else: + if cargo_file.is_file(): + cargo_file.copy_into(self._folders['auto_backups']) + store_json(cargo_data, cargo_file) + return cargo_data + + def backup_cargo_data(self): + """ + Saves current cargo data to backup folder. + """ + cargo_files = ( + 'boff_abilities.json', 'doffs.json', 'equipment.json', 'modifiers.json', + 'ship_list.json', 'starship_traits.json', 'traits.json') + cargo_folder = self._folders['cargo'] + backups_folder = self._folders['backups'] + for file_name in cargo_files: + (cargo_folder / file_name).copy_into(backups_folder) + def boff_dict(self): return { - 'Tactical': [dict(), dict(), dict(), dict()], - 'Engineering': [dict(), dict(), dict(), dict()], - 'Science': [dict(), dict(), dict(), dict()], - 'Intelligence': [dict(), dict(), dict(), dict()], - 'Command': [dict(), dict(), dict(), dict()], - 'Pilot': [dict(), dict(), dict(), dict()], - 'Temporal': [dict(), dict(), dict(), dict()], - 'Miracle Worker': [dict(), dict(), dict(), dict()], + 'Tactical': [list(), list(), list(), list()], + 'Engineering': [list(), list(), list(), list()], + 'Science': [list(), list(), list(), list()], + 'Intelligence': [list(), list(), list(), list()], + 'Command': [list(), list(), list(), list()], + 'Pilot': [list(), list(), list(), list()], + 'Temporal': [list(), list(), list(), list()], + 'Miracle Worker': [list(), list(), list(), list()], } diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..485433d --- /dev/null +++ b/src/config.py @@ -0,0 +1,116 @@ +import os +from pathlib import Path + +from PySide6.QtCore import QByteArray, QSettings + + +class SETSConfig(): + + __slots__ = ('autosave_filename', 'autosave_path', 'box_height', 'box_width', 'config_dir', + 'config_subfolders', 'home_dir', 'link_discord', 'link_downloads', 'link_github', + 'link_website', 'settings_file', 'ui_scale') + + def __init__(self): + self.autosave_filename: str = 'autosave.json' + self.autosave_path: Path = Path() + self.box_height: int = 64 + self.box_width: int = 49 + self.config_dir: Path = Path() + self.config_subfolders: dict[str, Path | None] = { + 'library': None, + 'cache': None, + 'cargo': None, + 'images': None, + 'ship_images': None, + 'backups': None, + 'auto_backups': None + } + self.home_dir: Path = Path() + self.link_discord: str = 'https://discord.gg/kxwHxbsqzF' + self.link_downloads: str = 'https://github.com/STOCD/SETS/releases' + self.link_github: str = 'https://github.com/STOCD' + self.link_website: str = 'https://stobuilds.com/apps/sets' + self.settings_file: str = 'SETS_settings.ini' + self.ui_scale: float = 1.0 + + def __repr__(self): + return f'' + + +class SETSSettings(): + + __slots__ = ('_settings', 'default_mark', 'default_save_format', 'default_rarity', + 'library_path', 'picker_relative', 'pref_backup', 'ui_scale', 'state__geometry') + + def __init__(self, settings_file_path: Path): + self.default_mark: str = '' + self.default_save_format: str = 'JSON' + self.default_rarity: str = 'Common' + self.library_path: str = '' + self.picker_relative: int = 0 + self.pref_backup: int = 0 # 0: auto backup preferred, 1: manual backup preferred + self.ui_scale: float = 1 + + self.state__geometry: QByteArray = QByteArray() + + if os.name == 'nt': + self._settings = QSettings(str(settings_file_path), QSettings.Format.IniFormat) + else: + self._settings = QSettings(str(settings_file_path), QSettings.Format.NativeFormat) + + self.load_settings() + + def load_settings(self): + """ + Loads settings from settings file given in constructor into attributes. + """ + for setting in self.__slots__: + if setting.startswith('_'): + continue + setting_id = setting.replace('__', '/') + if self._settings.contains(setting_id): + item_type = type(getattr(self, setting)) + if item_type is list: + settings_item: list = getattr(self, setting) + if len(settings_item) > 0: + list_element_type = type(settings_item[0]) + else: + list_element_type = str + item_list = self._settings.value(setting_id, type=list) + if list_element_type is bool: + items = [True if el == 'true' else False for el in item_list] + setattr(self, setting, items) + else: + setattr(self, setting, [list_element_type(el) for el in item_list]) + else: + setattr(self, setting, self._settings.value(setting_id, type=item_type)) + + def store_settings(self): + """ + Stores settings from attributes to settings file given in constructor. + """ + for setting in self.__slots__: + if not setting.startswith('_'): + setting_id = setting.replace('__', '/') + self._settings.setValue(setting_id, getattr(self, setting)) + + def set(self, setting_name: str, value): + """ + Sets setting `setting_name` to `value`. Only use when direct assignment cannot be used + (e.g. inside a lambda function). + """ + setattr(self, setting_name, value) + + def set_ui_scale(self, new_value: int) -> str: + """ + Calculates `new_value` / 50 and stores it to `ui_scale`. Returns the calculated value. + + Parameters: + - :param new_value: 50 times the ui scale percentage + """ + setting_value = round(new_value / 50, 2) + self.ui_scale = setting_value + return f'{setting_value:.2f}' + + def __repr__(self): + return f'' diff --git a/src/constants.py b/src/constants.py index 0e58ff1..5770676 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,6 +9,7 @@ SMAXMAX = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum) SMAXMIN = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Minimum) SMINMAX = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Maximum) +SMIXMAX = QSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Maximum) ATOP = Qt.AlignmentFlag.AlignTop ABOTTOM = Qt.AlignmentFlag.AlignBottom @@ -208,6 +209,10 @@ SKILL_POINTS_FOR_RANK = (0, 5, 15, 25, 35) +SETS_FILE_FILTER = ( + 'SETS Files (*.json *.png);;JSON file (*.json);;PNG image (*.png);;Any File (*.*)' +) + # commented maps must be transferred manually BUILD_CONVERSION = { 'space': ( diff --git a/src/contextmenu.py b/src/contextmenu.py new file mode 100644 index 0000000..ed57b33 --- /dev/null +++ b/src/contextmenu.py @@ -0,0 +1,131 @@ +from PySide6.QtCore import Signal +from PySide6.QtGui import QMouseEvent +from PySide6.QtWidgets import QMenu + +from .cargomanager import CargoManager +from .constants import EQUIPMENT_TYPES +from .buildmanager import BuildManager +from .iofunc import open_wiki_page +from .theme import AppTheme +from .widgets import ItemSlot + + +class ContextMenu(QMenu): + """ + Custom context menu with data storage + """ + + edit_slot: Signal = Signal(dict, dict, ItemSlot) + + def __init__(self, theme: AppTheme, build: BuildManager, cargo: CargoManager): + super().__init__() + self._build: BuildManager = build + self._cargo: CargoManager = cargo + self.clicked_slot: ItemSlot | None = None + self.clicked_modifiers: dict = {} + self.copied_item: dict = None + self.copied_item_type: str = None + + self.setStyleSheet(theme.get_style_class('ContextMenu', 'context_menu')) + self.setFont(theme.get_font('context_menu')) + self.addAction(theme.icons['copy'], 'Copy Item', self.copy_equipment_item) + self.addAction(theme.icons['paste'], 'Paste Item', self.paste_equipment_item) + self.addAction(theme.icons['clear'], 'Clear Slot', self.clear_slot) + self.addAction(theme.icons['link'], 'Open Wiki', self.open_wiki) + self.addAction(theme.icons['edit'], 'Edit Slot', self.edit_equipment_item) + + def invoke(self, event: QMouseEvent, key: str, subkey: int, environment: str, boff: int = -1): + """ + Opens context menu for equipment + + Parameters: + - :param event: event containing the clicked point + - :param key: slot type in self.build[environment] + - :param subkey: slot index + - :param environment: "space" / "ground" + - :param boff: id of the boff station + """ + actions = self.actions() + if key in {'boffs', 'rep_traits', 'starship_traits', 'traits', 'active_rep_traits'}: + actions[0].setEnabled(False) + actions[1].setEnabled(False) + actions[4].setEnabled(False) + is_equipment = False + else: + actions[0].setEnabled(True) + actions[1].setEnabled(True) + actions[4].setEnabled(True) + is_equipment = True + self.clicked_slot = ItemSlot(environment, key, subkey, boff, is_equipment) + self.popup(event.globalPos()) + + def copy_equipment_item(self): + """ + Copies equipment item clicked on. + """ + slot = self.clicked_slot + item = self._build[slot.environment][slot.type][slot.index] + if item is None or item == '': + self.copied_item = None + self.copied_item_type = None + else: + # TODO check if dict must be deep-copied + self.copied_item = item + item_type = EQUIPMENT_TYPES[self._cargo.equipment[slot.type][item['item']]['type']] + self.copied_item_type = item_type + + def paste_equipment_item(self): + """ + Pastes copied item into clicked slot if slot types are compatible + """ + slot = self.clicked_slot + if (self.copied_item_type == slot.type + or self.copied_item_type == 'ship_weapon' and ( + slot.type == 'fore_weapons' or slot.type == 'aft_weapons') + or self.copied_item_type == 'uni_consoles' and 'consoles' in slot.type + or slot.type == 'uni_consoles' and 'consoles' in self.copied_item_type): + self._build.slot_equipment_item( + self.copied_item, slot.environment, slot.type, slot.index) + self._build.autosave() + + def edit_equipment_item(self): + """ + Edit mark, modifiers and rarity of rightclicked item. + """ + slot = self.clicked_slot + item = self._build[slot.environment][slot.type][slot.index] + if slot.type == 'fore_weapons' or slot.type == 'aft_weapons': + item_type = slot.type + else: + item_type = EQUIPMENT_TYPES[self._cargo.equipment[slot.type][item['item']]['type']] + modifiers = self._cargo.modifiers[item_type] + self.edit_slot.emit(item, modifiers, slot) + + def clear_slot(self): + """ + Clears slot that was rightclicked on. + """ + slot = self.clicked_slot + self._build.unslot_item(slot.environment, slot.type, slot.index, slot.boff_id) + self._build.autosave() + + def open_wiki(self): + """ + Opens wiki page of item that was rightclicked on. + """ + slot = self.clicked_slot + if slot.boff_id != -1: + item = self._build[slot.environment][slot.type][slot.boff_id][slot.index] + if item is not None and item != '': + open_wiki_page(f"{item['item']}_(ability)") + return + item = self._build[slot.environment][slot.type][slot.index] + if item is None or item == '': + return + if slot.type == 'starship_traits': + open_wiki_page(f"{item['item']}_(starship_trait)") + elif 'traits' in slot.type: + open_wiki_page(f"{item['item']}_({slot.environment}_trait)") + else: + open_wiki_page( + f"{self._cargo.equipment[slot.type][item['item']]['Page']}#{item['item']}") diff --git a/src/datafunctions.py b/src/datafunctions.py deleted file mode 100644 index eb6e779..0000000 --- a/src/datafunctions.py +++ /dev/null @@ -1,1161 +0,0 @@ -from datetime import datetime -from json import dumps as json__dumps, loads as json__loads, JSONDecodeError -import os -from pathlib import Path -import sys -from zlib import compress as zlib_compress, decompress as zlib_decompress -from numpy import array, append, fromiter, packbits, uint8, unpackbits, zeros -from PySide6.QtGui import QImage -from requests import Session -from requests.cookies import create_cookie -from requests.exceptions import ( - ConnectionError as requests__ConnectionError, Timeout as requests__Timeout) -from requests_html import Element -from urllib.parse import unquote_plus - -from .buildupdater import get_boff_spec, load_build, load_skill_pages -from .constants import ( - BOFF_RANKS, BUILD_CONVERSION, BUILD_VERSION, CAREERS, DOFF_QUERY_URL, EQUIPMENT_TYPES, - ITEM_QUERY_URL, MODIFIER_QUERY, PRIMARY_SPECS, SHIP_QUERY_URL, - STARSHIP_TRAIT_QUERY_URL, TRAIT_QUERY_URL, TRAYSKILL_QUERY, WIKI_IMAGE_URL) -from .iofunc import ( - auto_backup_cargo_file, browse_path, cache_cargo_data, copy_file, download_image, - download_images_fast, fetch_html, get_asset_path, get_cached_cargo_data, get_cargo_data, - get_downloaded_icons, image, load_image, load_json, read_env_file, retrieve_image, - store_json, store_to_cache) -from .splash import enter_splash, exit_splash, splash_text -from .textedit import ( - create_equipment_tooltip, create_trait_tooltip, dewikify, parse_wikitext, - sanitize_equipment_name) -from .widgets import exec_in_thread, notempty, TagStyles, ThreadObject - - -def init_backend(self): - """ - Loads cargo and build data. - """ - def finish_backend_init(): - splash_text(self, 'Injecting Cargo Data') - insert_cargo_data(self) - slot_skill_images(self) - splash_text(self, 'Loading Build') - load_build(self) - exec_in_thread(self, load_images, self) - exit_splash(self) - - enter_splash(self) - load_build_file(self, self.config['autosave_filename'], update_ui=False) - self.downloader.default_session_from_env() - exec_in_thread( - self, populate_cache, self, finished=finish_backend_init, - update_splash=lambda new_text: splash_text(self, new_text)) - - -def insert_cargo_data(self): - """ - Updates UI elements depending on cargo data with the loaded data - """ - self.ship_selector_window.set_ships(self.cache.ships.keys()) - space_doff_specs = [''] + sorted(self.cache.space_doffs.keys()) - for combobox in self.widgets.build['space']['doffs_spec']: - combobox.addItems(space_doff_specs) - ground_doff_specs = [''] + sorted(self.cache.ground_doffs.keys()) - for combobox in self.widgets.build['ground']['doffs_spec']: - combobox.addItems(ground_doff_specs) - - -def slot_skill_images(self): - """ - Updates the ground and skill tree, slotting the correct images into the slots. - """ - for career_block in self.widgets.build['space_skills'].values(): - for skill_button in career_block: - skill_button.set_item(image(self, skill_button.skill_image_name)) - for skill_group in self.widgets.build['ground_skills']: - for skill_button in skill_group: - skill_button.set_item(image(self, skill_button.skill_image_name)) - - -def populate_cache(self, threaded_worker: ThreadObject): - """ - Loads cargo data and images into cache - - Parameters: - - :param threaded_worker: worker object supplying signals - """ - success = load_cargo_cache(self, threaded_worker) - if not success: - self.cache.reset_cache(keep_static_data=True) - load_cargo_data(self, threaded_worker) - self.cache.empty_image = QImage() - self.cache.images_failed = get_cached_cargo_data(self, 'images_failed.json') - - # temporary: until self.cache has been replaced - self.images.image_set = self.cache.images_set - self.images.failed_images = self.cache.images_failed - self.cargo.boff_abilities = self.cache.boff_abilities - - threaded_worker.update_splash.emit('Loading: Images') - self.images.download_images(self.cache.skills) - store_to_cache(self, self.images.failed_images, 'images_failed.json') - load_base_images(self, threaded_worker) - - -def load_cargo_cache(self, threaded_worker: ThreadObject) -> bool: - """ - Loads cargo data for all cargo tables from cached data and puts them into variables. Returns - True when successful, False if cache is too old - - Parameters: - - :param threaded_worker: worker object supplying signals - """ - threaded_worker.update_splash.emit('Loading: Cargo Data') - self.cache.ships = get_cached_cargo_data(self, 'ships.json') - if len(self.cache.ships) == 0: - return False - self.cache.equipment = get_cached_cargo_data(self, 'equipment.json') - if len(self.cache.equipment) == 0: - return False - self.cache.traits = get_cached_cargo_data(self, 'traits.json') - if len(self.cache.traits) == 0: - return False - self.cache.starship_traits = get_cached_cargo_data(self, 'starship_traits.json') - if len(self.cache.starship_traits) == 0: - return False - self.cache.boff_abilities = get_cached_cargo_data(self, 'boff_abilities.json') - if len(self.cache.boff_abilities) == 0 or len(self.cache.boff_abilities.get('all', {})) == 0: - return False - self.cache.modifiers = get_cached_cargo_data(self, 'modifiers.json') - if len(self.cache.modifiers) == 0: - return False - self.cache.space_doffs = get_cached_cargo_data(self, 'space_doffs.json') - if len(self.cache.space_doffs) == 0: - return False - self.cache.ground_doffs = get_cached_cargo_data(self, 'ground_doffs.json') - if len(self.cache.ground_doffs) == 0: - return False - self.cache.alt_images = get_cached_cargo_data(self, 'alt_images.json') - if len(self.cache.alt_images) == 0: - return False - self.cache.images_set = set(get_cached_cargo_data(self, 'images_list.json')) - if len(self.cache.images_set) == 0: - return False - return True - - -def load_cargo_data(self, threaded_worker: ThreadObject): - """ - Loads cargo data for all cargo tables and puts them into variables. - - Parameters: - - :param threaded_worker: worker object supplying signals - """ - threaded_worker.update_splash.emit('Loading: Starships') - ship_cargo_data = get_cargo_data(self, 'ship_list.json', SHIP_QUERY_URL) - self.cache.ships = {ship['Page']: ship for ship in ship_cargo_data} - store_to_cache(self, self.cache.ships, 'ships.json') - - tags = TagStyles( - self.theme['tooltip']['ul'], self.theme['tooltip']['li'], - self.theme['tooltip']['indent']) - - threaded_worker.update_splash.emit('Loading: Equipment') - equipment_cargo_data = get_cargo_data(self, 'equipment.json', ITEM_QUERY_URL) - equipment_types = set(EQUIPMENT_TYPES.keys()) - head_s = self.theme['tooltip']['equipment_head'] - subhead_s = self.theme['tooltip']['equipment_subhead'] - who_s = self.theme['tooltip']['equipment_who'] - elite_hangar = { - 'Hangar - Elite Federation Mission Scout Ships', - 'Hangar - Elite Valor Fighters' - } - for item in equipment_cargo_data: - if item['type'] in equipment_types: - if item['type'] == 'Hangar Bay' and item['name'] not in elite_hangar and ( - item['name'].startswith('Hangar - Advanced') - or item['name'].startswith('Hangar - Elite')): - continue - name = sanitize_equipment_name(item['name']) - self.cache.equipment[EQUIPMENT_TYPES[item['type']]][name] = { - 'Page': item['Page'], - 'name': name, - 'rarity': item['rarity'], - 'type': item['type'], - 'tooltip': create_equipment_tooltip(item, head_s, subhead_s, who_s, tags) - } - self.cache.images_set.add(name) - self.cache.equipment['fore_weapons'].update(self.cache.equipment['ship_weapon']) - self.cache.equipment['aft_weapons'].update(self.cache.equipment['ship_weapon']) - del self.cache.equipment['ship_weapon'] - self.cache.equipment['tac_consoles'].update(self.cache.equipment['uni_consoles']) - self.cache.equipment['sci_consoles'].update(self.cache.equipment['uni_consoles']) - self.cache.equipment['eng_consoles'].update(self.cache.equipment['uni_consoles']) - self.cache.equipment['uni_consoles'].update(self.cache.equipment['tac_consoles']) - self.cache.equipment['uni_consoles'].update(self.cache.equipment['sci_consoles']) - self.cache.equipment['uni_consoles'].update(self.cache.equipment['eng_consoles']) - store_to_cache(self, self.cache.equipment, 'equipment.json') - - threaded_worker.update_splash.emit('Loading: Traits') - trait_cargo_data = get_cargo_data(self, 'traits.json', TRAIT_QUERY_URL) - head_s = self.theme['tooltip']['trait_header'] - subhead_s = self.theme['tooltip']['trait_subheader'] - for trait in trait_cargo_data: - name = trait['name'] - if trait['type'] != 'doff' and trait['type'] != 'boff' and name is not None: - if trait['type'] == 'reputation': - trait_type = 'rep_traits' - elif trait['type'] == 'activereputation': - trait_type = 'active_rep_traits' - else: - trait_type = 'traits' - try: - self.cache.traits[trait['environment']][trait_type][name] = { - 'Page': trait['Page'], - 'name': name, - 'tooltip': create_trait_tooltip( - name, trait['description'], trait_type, trait['environment'], head_s, - subhead_s, tags) - } - if trait['icon_name'] is None: - self.cache.images_set.add(name) - else: - self.cache.images_set.add(trait['icon_name']) - self.cache.alt_images[f'{name}__{trait["environment"]}__{trait_type}'] = ( - trait['icon_name']) - # catch wrong values in trait['environment'] (cargo issue) - except (KeyError, AttributeError): - pass - store_to_cache(self, self.cache.traits, 'traits.json') - - threaded_worker.update_splash.emit('Loading: Starship Traits') - shiptrait_cargo = get_cargo_data(self, 'starship_traits.json', STARSHIP_TRAIT_QUERY_URL) - for ship_trait in shiptrait_cargo: - name = ship_trait['name'] - if ship_trait['icon_name'] is None: - self.cache.images_set.add(name) - else: - self.cache.images_set.add(ship_trait['icon_name']) - self.cache.alt_images[f"{name}__space__starship_traits"] = ( - ship_trait['icon_name']) - self.cache.starship_traits[name] = { - 'Page': ship_trait['Page'], - 'name': name, - 'obtained': ship_trait['obtained'], - 'tooltip': ( - f"

{name}

" - f"Starship Trait

" - f"{ship_trait['short']}

{parse_wikitext(ship_trait['detailed'], tags)}") - } - self.cache.images_set |= self.cache.starship_traits.keys() - store_to_cache(self, self.cache.starship_traits, 'starship_traits.json') - store_to_cache(self, self.cache.alt_images, 'alt_images.json') - - threaded_worker.update_splash.emit('Loading: Bridge Officers') - boff_head = self.theme['tooltip']['boff_header'] - boff_subhead = self.theme['tooltip']['boff_subheader'] - boff_cargo = get_cargo_data(self, 'boff_abilities.json', TRAYSKILL_QUERY) - boff_types = CAREERS | PRIMARY_SPECS - rank_numbers = ((1, 'I'), (2, 'II'), (3, 'III')) - for boff_ability in boff_cargo: - boff_region = boff_ability['region'].lower() - boff_type = boff_ability['type'] - if boff_type not in boff_types or boff_region != 'space' and boff_region != 'ground': - continue - boff_name = boff_ability['name'] - ability_item = { - 'Page': boff_ability['_pageName'], - 'name': boff_name, - 'I': '', - 'II': '', - 'III': '' - } - desc = boff_ability['description'] - desc_long = boff_ability['description long'] - for decimal, roman in rank_numbers: - rank_id = BOFF_RANKS.get(boff_ability[f'rank{decimal}rank'], 0) - 1 - if rank_id >= 0: - self.cache.boff_abilities[boff_region][boff_type][rank_id].append( - boff_name + ' ' + roman) - ability_item[roman] = ( - f"

{boff_name} {roman}

" - f"{desc}

{desc_long}

" - f"{parse_wikitext(dewikify(boff_ability[f'rank{decimal}info']), tags)}") - self.cache.boff_abilities['all'][boff_name] = ability_item - self.cache.images_set |= self.cache.boff_abilities['all'].keys() - store_to_cache(self, self.cache.boff_abilities, 'boff_abilities.json') - - threaded_worker.update_splash.emit('Loading: Modifiers') - mod_cargo_data = get_cargo_data(self, 'modifiers.json', MODIFIER_QUERY) - for modifier in mod_cargo_data: - try: - if modifier['available'][0] == '': - modifier['available'] = list() - except (IndexError, TypeError): - modifier['available'] = list() - for mod_type in modifier['type']: - mod_name = modifier['modifier'].replace('>', '>') - try: - epic = bool(modifier['isepic']) - self.cache.modifiers[EQUIPMENT_TYPES[mod_type]][mod_name] = { - 'stats': modifier['stats'], - 'available': modifier['available'], - 'epic': epic, - 'isunique': False if epic else bool(modifier['isunique']), - } - except KeyError: - pass - self.cache.modifiers['fore_weapons'].update(self.cache.modifiers['ship_weapon']) - self.cache.modifiers['aft_weapons'].update(self.cache.modifiers['ship_weapon']) - del self.cache.modifiers['ship_weapon'] - self.cache.modifiers['uni_consoles'].update(self.cache.modifiers['sci_consoles']) - self.cache.modifiers['uni_consoles'].update(self.cache.modifiers['eng_consoles']) - self.cache.modifiers['uni_consoles'].update(self.cache.modifiers['tac_consoles']) - store_to_cache(self, self.cache.modifiers, 'modifiers.json') - - threaded_worker.update_splash.emit('Loading: Duty Officers') - doff_cargo_data = get_cargo_data(self, 'doffs.json', DOFF_QUERY_URL) - for doff in doff_cargo_data: - doff['description'] = dewikify(doff['description'], remove_formatting=True) - for rarity in ('white', 'green', 'blue', 'purple', 'violet', 'gold'): - if isinstance(doff[rarity], str): - doff[rarity] = dewikify(doff[rarity], remove_formatting=True) - if doff['shipdutytype'] == 'Space': - cache_doff_single(self, self.cache.space_doffs, doff) - elif doff['shipdutytype'] == 'Ground': - cache_doff_single(self, self.cache.ground_doffs, doff) - elif doff['shipdutytype'] is not None: - cache_doff_single(self, self.cache.space_doffs, doff) - cache_doff_single(self, self.cache.ground_doffs, doff) - store_to_cache(self, self.cache.space_doffs, 'space_doffs.json') - store_to_cache(self, self.cache.ground_doffs, 'ground_doffs.json') - store_to_cache(self, list(self.cache.images_set), 'images_list.json') - - -def load_base_images(self, threaded_worker: ThreadObject): - """ - Loads all images that are required for the app to start (skills, overlays) - - Parameters: - - :param threaded_worker: worker object supplying signals - """ - threaded_worker.update_splash.emit('Loading: Images (Overlays)') - self.cache.images = {image_name: QImage() for image_name in self.cache.images_set} - self.cache.overlays.common = QImage(get_asset_path('Common_icon.png', self.app_dir)) - self.cache.overlays.uncommon = QImage(get_asset_path('Uncommon_icon.png', self.app_dir)) - self.cache.overlays.rare = QImage(get_asset_path('Rare_icon.png', self.app_dir)) - self.cache.overlays.veryrare = QImage(get_asset_path('Very_rare_icon.png', self.app_dir)) - self.cache.overlays.ultrarare = QImage(get_asset_path('Ultra_rare_icon.png', self.app_dir)) - self.cache.overlays.epic = QImage(get_asset_path('Epic_icon.png', self.app_dir)) - self.cache.overlays.check = QImage(get_asset_path('check_overlay.png', self.app_dir)) - - threaded_worker.update_splash.emit('Loading: Images (Skills)') - img_folder = self.config['config_subfolders']['images'] - for rank_group in self.cache.skills['space']: - for skill_group in rank_group: - for skill_node in skill_group['nodes']: - self.cache.images[skill_node['image']] = retrieve_image( - self, skill_node['image'], img_folder, threaded_worker.update_splash, - f'{WIKI_IMAGE_URL}{skill_node['image']}.png') - for skill_group in self.cache.skills['ground']: - for skill_node in skill_group['nodes']: - self.cache.images[skill_node['image']] = retrieve_image( - self, skill_node['image'], img_folder, threaded_worker.update_splash, - f'{WIKI_IMAGE_URL}{skill_node['image']}.png') - self.cache.images['arrow-up'] = QImage(get_asset_path('arrow-up.png', self.app_dir)) - self.cache.images['arrow-down'] = QImage(get_asset_path('arrow-down.png', self.app_dir)) - self.cache.images['Focused Frenzy'] = retrieve_image( - self, 'Focused Frenzy', img_folder) - self.cache.images['Probability Manipulation'] = retrieve_image( - self, 'Probability Manipulation', img_folder) - self.cache.images['EPS Corruption'] = retrieve_image( - self, 'EPS Corruption', img_folder) - - -def load_images(self, threaded_worker=None): - """ - - Parameters: - - :param threaded_worker: (unused; required for compatability with employed threading method) - """ - img_folder = self.config['config_subfolders']['images'] - for img_name, img in self.cache.images.items(): - if img.isNull(): - load_image(img_name, img, img_folder) - - -def download_images(self, threaded_worker: ThreadObject): - """ - Downloads all images not already in the images folder and puts them into cache. Returns set of - images not to be retried in this cycle. - """ - no_retry_images = set() - now = datetime.now() - for img, timestamp in self.cache.images_failed.items(): - if (datetime.fromtimestamp(timestamp) - now).days < 7: - no_retry_images.add(img) - else: - self.cache.images_failed.pop(img) - images = self.cache.images_set - no_retry_images - get_downloaded_icons( - Path(self.config['config_subfolders']['images'])) - img_folder = self.config['config_subfolders']['images'] - - images_to_download = images - self.cache.boff_abilities['all'].keys() - for image_name in images_to_download: - threaded_worker.update_splash.emit(f'Downloading Image: {image_name}') - download_image(self, image_name, img_folder) - - boff_images_to_download = images & self.cache.boff_abilities['all'].keys() - for image_name in boff_images_to_download: - threaded_worker.update_splash.emit(f'Downloading Image: {image_name}') - image_url = f'{WIKI_IMAGE_URL}{image_name.replace(' ', '_')}_icon_(Federation).png' - download_image(self, image_name, img_folder, image_url) - return no_retry_images - - -def cache_doff_single(self, cache: dict, doff: dict): - """ - Puts a single doff into cache. - - Parameters: - - :param cache: cache dictionary to store doff into - - :param doff: the doff itself - """ - try: - cache[doff['spec']][doff['description']] = doff - except KeyError: - cache[doff['spec']] = dict() - cache[doff['spec']][doff['description']] = doff - - -def cache_skills(skill_cache: dict[str, dict], app_directory: str): - """ - Loads skills into cache. - """ - space_skill_data = load_json(get_asset_path('space_skills.json', app_directory)) - skill_cache['space'] = space_skill_data['space'] - skill_cache['space_unlocks'] = space_skill_data['space_unlocks'] - ground_skill_data = load_json(get_asset_path('ground_skills.json', app_directory)) - skill_cache['ground'] = ground_skill_data['ground'] - skill_cache['ground_unlocks'] = ground_skill_data['ground_unlocks'] - - -def autosave(self): - """ - Saves build to autosave file. - """ - if not self.building: - store_json(self.build, self.config['autosave_filename']) - - -def map_build_items(self, old_build: dict, new_build: dict, mapping): - """ - Inserts items from old build into new build according to mapping; in-place - - Parameters: - - :param old_build: source - - :param new_build: target - - :param mapping: iterable of 2-tuples containing source and target key - """ - for source_key, target_key in mapping: - try: - if isinstance(new_build[target_key], list): - for index, element in enumerate(old_build[source_key]): - try: - if isinstance(element, dict) and 'modifiers' in element: - element['modifiers'] += [None] * (5 - len(element['modifiers'])) - new_build[target_key][index] = element - except IndexError: - break - else: - new_build[target_key] = old_build[source_key] - except KeyError: - continue - - -def convert_old_build(self, build: dict) -> dict: - """ - converts build from old spec to current spec - """ - new_build = empty_build(self) - - # space - map_build_items(self, build, new_build['space'], BUILD_CONVERSION['space']) - - new_build['space']['traits'] = build['personalSpaceTrait'] + build['personalSpaceTrait2'] - if len(new_build['space']['traits']) < 12: - new_build['space']['traits'] += [None] * (12 - len(new_build['space']['traits'])) - elite_captain_trait = new_build['space']['traits'][5] - new_build['space']['traits'][5] = new_build['space']['traits'][9] - new_build['space']['traits'][9] = elite_captain_trait - - ship_data = self.cache.ships[new_build['space']['ship']] - boff_data = sorted(map(lambda s: get_boff_spec(self, s), ship_data['boffs']), reverse=True) - boff_data_old = [] - for boff_id, boff_profession in enumerate(build['boffseats']['space']): - if f'spaceBoff_{boff_id}' in build['boffs'] and boff_profession is not None: - abilities = build['boffs'][f'spaceBoff_{boff_id}'] - boff_data_old.append((len(abilities), boff_profession, abilities)) - boff_data_old.sort(reverse=True) - for boff_id, (new_station, old_station) in enumerate(zip(boff_data, boff_data_old)): - if new_station[1] == old_station[1] or new_station[1] == 'Universal': - continue - for i, test_station in enumerate(boff_data): - if old_station[0] == test_station[0] and old_station[1] == test_station[1]: - boff_data_old[boff_id] = boff_data_old[i] - boff_data_old[i] = old_station - break - else: - for i, test_station in enumerate(boff_data): - if old_station[0] == test_station[0] and test_station[1] == 'Universal': - boff_data_old[boff_id] = boff_data_old[i] - boff_data_old[i] = old_station - break - for boff_id, station in enumerate(boff_data_old): - new_build['space']['boff_specs'][boff_id] = [station[1], boff_data[boff_id][2]] - for i, ability in enumerate(station[2]): - if ability is None or ability == '': - new_build['space']['boffs'][boff_id][i] = '' - else: - new_build['space']['boffs'][boff_id][i] = {'item': ability} - - # ground - map_build_items(self, build, new_build['ground'], BUILD_CONVERSION['ground']) - - try: - for boff_id in range(4): - new_build['ground']['boff_profs'][boff_id] = build['boffseats']['ground'][boff_id] - new_build['ground']['boff_specs'][boff_id] = build['boffseats']['ground_spec'][boff_id] - if new_build['ground']['boff_specs'][boff_id] is None: - new_build['ground']['boff_specs'][boff_id] = 'Command' - for i, ability in enumerate(build['boffs'][f'groundBoff_{boff_id}']): - if ability is None or ability == '': - new_build['ground']['boffs'][boff_id][i] - else: - new_build['ground']['boffs'][boff_id][i] = {'item': ability} - except KeyError: - pass - - new_build['ground']['traits'] = build['personalGroundTrait'] + build['personalGroundTrait2'] - if len(new_build['ground']['traits']) < 12: - new_build['ground']['traits'] += [None] * (12 - len(new_build['ground']['traits'])) - elite_captain_trait = new_build['ground']['traits'][5] - new_build['ground']['traits'][5] = new_build['ground']['traits'][9] - new_build['ground']['traits'][9] = elite_captain_trait - - # captain - map_build_items(self, build, new_build['captain'], BUILD_CONVERSION['captain']) - try: - new_build['captain']['name'] = build['playerName'] + build['playerHandle'] - new_build['captain']['faction'] = build['captain']['faction'] - except KeyError: - pass - - # doffs - for environment in ('space', 'ground'): - for doff_index, doff in enumerate(build['doffs'][environment]): - if doff is not None and doff != '': - new_build[environment]['doffs_spec'][doff_index] = doff['spec'] - try: - for variant in getattr(self.cache, f'{environment}_doffs')[doff['spec']]: - if doff['effect'] in variant: - new_build[environment]['doffs_variant'][doff_index] = variant - break - except KeyError: - pass - - return new_build - - -def compensate_old_build(self, build: str): - """ - replaces known wrong terms in build string - """ - build = build.replace('Ultra rare', 'Ultra Rare') - build = build.replace('Very rare', 'Very Rare') - return build - - -def remove_invalid_build_items(self, build: dict): - """ - Checks build for invalid items and removes these to maintain compatibility. - - Parameters: - - :param build: build to remove items from (in place) - """ - for environment in ('space', 'ground'): - for category, category_items in build[environment].items(): - if isinstance(category_items, str): - continue - elif category == 'boffs': - for station in category_items: - for index, ability in enumerate(station): - if (isinstance(ability, dict) - and ability['item'] not in self.cache.images_set): - station[index] = '' - elif (category.startswith('doff') - or category == 'boff_specs' - or category == 'boff_profs'): - continue - elif isinstance(category_items, list): - for index, item in enumerate(category_items): - if isinstance(item, dict) and item['item'] not in self.cache.images_set: - category_items[index] = '' - - -def update_build_version(self, build: dict[str]): - """ - Updates contents of `build` to match the newest version. - - Parameters: - - :param build: contains build data of outdated version - """ - def _fix_station(environment): - for rank_id in range(4): - if isinstance(boff_station[rank_id], dict) and 'rank' not in boff_station[rank_id]: - ability_name = boff_station[rank_id]['item'] - prof_abilities = self.cache.boff_abilities[environment][prof] - spec_abilities = self.cache.boff_abilities[environment].get(spec, None) - for rank in ('III', 'II', 'I'): - if f'{ability_name} {rank}' in prof_abilities[rank_id]: - boff_station[rank_id]['rank'] = rank - break - elif (spec_abilities is not None - and f'{ability_name} {rank}' in spec_abilities[rank_id]): - boff_station[rank_id]['rank'] = rank - break - else: - boff_station[rank_id] = '' - - for boff_station, (prof, spec) in zip(build['space']['boffs'], build['space']['boff_specs']): - _fix_station('space') - for station_id, boff_station in enumerate(build['ground']['boffs']): - prof = build['ground']['boff_profs'][station_id] - spec = build['ground']['boff_specs'][station_id] - _fix_station('ground') - - build['_version'] = BUILD_VERSION - - -def encode_in_image(self, image: QImage, data: str): - """ - Embeds data into image - - Parameters: - - :param image: image to edit - - :param data: data string to embed into image - """ - data_bytes = zlib_compress(bytes(data, encoding='utf-8')) - total_characters = len(data_bytes) - bits = zeros(total_characters * 8 + 32 + 8, dtype=uint8) - prefix = array([167, total_characters >> 8, total_characters & 0b11111111, 167], dtype=uint8) - bits[0:32] = unpackbits(prefix) - bits[32:-8] = unpackbits(fromiter(data_bytes, dtype=uint8, count=total_characters)) - bits[-8:] = unpackbits(array([167], dtype=uint8)) - total_characters += 5 # prefix and suffix length - w = image.width() - total_bits = total_characters * 8 - full_rows = total_bits // (w * 3) - additional_pixels = (total_bits - full_rows * w * 3) // 3 - additional_subpixels = total_bits % 3 - i = -1 - row = -1 - for row in range(full_rows): - row_data = image.scanLine(row) - for i, subpixel in pixel_range(w, i + 1): - row_data[subpixel] = row_data[subpixel] & 0b11111110 | bits[i] - row_data = image.scanLine(row + 1) - for i, subpixel in pixel_range(additional_pixels, i + 1): - row_data[subpixel] = row_data[subpixel] & 0b11111110 | bits[i] - if additional_pixels == 0: - subpixel = -2 - if additional_subpixels == 1: - row_data[subpixel + 2] = row_data[subpixel + 2] & 0b11111110 | bits[i + 1] - elif additional_subpixels == 2: - row_data[subpixel + 2] = row_data[subpixel + 2] & 0b11111110 | bits[i + 1] - row_data[subpixel + 3] = row_data[subpixel + 3] & 0b11111110 | bits[i + 2] - - -def decode_from_image(self, image: QImage) -> str: - """ - Extracts embedded data from image; returns empty string if no data was found - - Parameters: - - :param image: image with embedded data - """ - # prefix: §15000§ where 15000 is the number (as uint16) of bytes the encoded data occupies - prefix_bits = zeros(32, dtype=uint8) - first_row = image.constScanLine(0) - for i, subpixel in pixel_range(10): - prefix_bits[i] = first_row[subpixel] & 0b1 - prefix_bits[30] = first_row[40] & 0b1 - prefix_bits[31] = first_row[41] & 0b1 - prefix_bytes = packbits(prefix_bits) - if prefix_bytes[0] != 167 or prefix_bytes[3] != 167: # ord('§') == 167 - return '' - total_characters = int(prefix_bytes[1]) << 8 | int(prefix_bytes[2]) # constructs 16-bit int - total_characters += 5 # prefix and suffix length - w = image.width() - total_bits = total_characters * 8 - bits = zeros(total_bits, dtype=uint8) - full_rows = total_bits // (w * 3) - additional_pixels = (total_bits - full_rows * w * 3) // 3 - additional_subpixels = total_bits % 3 - i = -1 - row = -1 - for row in range(full_rows): - row_data = image.constScanLine(row) - for i, subpixel in pixel_range(w, i + 1): - bits[i] = row_data[subpixel] & 0b1 - row_data = image.constScanLine(row + 1) - for i, subpixel in pixel_range(additional_pixels, i + 1): - bits[i] = row_data[subpixel] & 0b1 - if additional_pixels == 0: - subpixel = -2 - if additional_subpixels == 1: - bits[i + 1] = row_data[subpixel + 2] & 0b1 - elif additional_subpixels == 2: - bits[i + 1] = row_data[subpixel + 2] & 0b1 - bits[i + 2] = row_data[subpixel + 3] & 0b1 - decoded_bytes = bytes(packbits(bits)) - if decoded_bytes[-1] != 167: - raise ValueError('End delimiter not found! Decoded data not intact.') - return str(zlib_decompress(decoded_bytes[4:-1]), 'utf-8') - - -def legacy_decode_from_image(self, image_path: str) -> str: - """ - Decodes build from image using old embedding specification. - - Parameters: - - :param image_path: path to image - """ - message = '' - image = QImage(image_path) - width = image.width() - pixel_num = width * 3 - bit_diff = pixel_num % 8 - decoded_binary = zeros(pixel_num, dtype=uint8) - extra_bits = zeros(0, dtype=uint8) - for line in range(image.height()): - data = image.constScanLine(line) - for col in range(width): - pixel_index = col * 4 - bin_index = col * 3 - decoded_binary[bin_index] = data[pixel_index + 2] & 0b1 - decoded_binary[bin_index + 1] = data[pixel_index + 1] & 0b1 - decoded_binary[bin_index + 2] = data[pixel_index] & 0b1 - if bit_diff == 0: - decoded_bytes = packbits(append(extra_bits, decoded_binary)) - extra_bits = zeros(0, dtype=uint8) - bit_diff = pixel_num % 8 - else: - decoded_bytes = packbits(append(extra_bits, decoded_binary[:-1 * bit_diff])) - extra_bits = decoded_binary[-1 * bit_diff:].copy() - bit_diff = (pixel_num + len(extra_bits)) % 8 - new_message = ''.join(map(chr, decoded_bytes)) - message += new_message - if '$t3g0' in new_message: - break - return message.split('$t3g0', maxsplit=1)[0] - - -def load_legacy_build_image(self): - """ - Loads legacy build from image file - """ - load_path = browse_path( - self, self.config['config_subfolders']['library'], - 'PNG image (*.png);;Any File (*.*)') - if load_path != '': - _, _, extension = load_path.rpartition('.') - if extension.lower() != 'png': - return - try: - raw_build = legacy_decode_from_image(self, load_path) - build_data = json__loads(compensate_old_build(self, raw_build)) - except JSONDecodeError: - sys.stderr.write('[Error] Image contains no build or is corrupted.') - return - if 'versionJSON' in build_data: - new_build = empty_build(self) - new_build.update(convert_old_build(self, build_data)) - self.build = new_build - try: - load_build(self) - except KeyError: - remove_invalid_build_items(self, self.build) - load_build(self) - - -def load_build_file(self, filepath: str, update_ui: bool = True): - """ - Loads build from json or png file and puts it into self.build - - Parameters: - - :param filepath: path to build file - """ - _, _, extension = filepath.rpartition('.') - if extension.lower() == 'json': - build_data = load_json(filepath) - elif extension.lower() == 'png': - decoded_str = decode_from_image(self, QImage(filepath)) - if decoded_str == '': - return - build_data = json__loads(decoded_str) - else: - return - new_build = empty_build(self) - if build_data.get('_version', -1) == BUILD_VERSION: - merge_build(self, new_build, build_data) - elif 'versionJSON' in build_data: - build_data = json__loads(compensate_old_build(self, json__dumps(build_data))) - new_build.update(convert_old_build(self, build_data)) - else: - merge_build(self, new_build, build_data) - update_build_version(self, new_build) - self.build = new_build - if update_ui: - try: - load_build(self) - except KeyError: - remove_invalid_build_items(self, self.build) - load_build(self) - - -def save_build_file(self, filepath: str): - """ - Saves build to json or png file - - Parameters: - - :param filepath: path to build file - """ - _, _, extension = filepath.rpartition('.') - if extension.lower() == 'json': - store_json(self.build, filepath) - elif extension.lower() == 'png': - image = self.window.grab().toImage() - encode_in_image(self, image, json__dumps(self.build)) - image.save(filepath) - - -def load_skill_tree_file(self, filepath: str): - """ - Loads skill tree from json or png file and puts it into self.build - - Parameters: - - :param filepath: path to skill tree file - """ - _, _, extension = filepath.rpartition('.') - if extension.lower() == 'json': - build_data = load_json(filepath) - elif extension.lower() == 'png': - decoded_str = decode_from_image(self, QImage(filepath)) - if decoded_str == '': - return - build_data = json__loads(decoded_str) - else: - return - new_build = empty_build(self, 'skills') - merge_build(self, new_build, build_data) - self.build['space_skills'] = new_build['space_skills'] - self.build['ground_skills'] = new_build['ground_skills'] - self.build['skill_unlocks'] = new_build['skill_unlocks'] - self.build['skill_desc'] = new_build['skill_desc'] - load_skill_pages(self) - - -def save_skill_tree_file(self, filepath: str): - """ - Saves skill tree to json or png file - - Parameters: - - :param filepath: path to skill tree file - """ - _, _, extension = filepath.rpartition('.') - skill_tree = { - 'space_skills': self.build['space_skills'], - 'ground_skills': self.build['ground_skills'], - 'skill_unlocks': self.build['skill_unlocks'], - 'skill_desc': self.build['skill_desc'], - } - if extension.lower() == 'json': - store_json(skill_tree, filepath) - elif extension.lower() == 'png': - image = self.window.grab().toImage() - encode_in_image(self, image, json__dumps(skill_tree)) - image.save(filepath) - - -def empty_build(self, build_type: str = 'full') -> dict: - """ - Creates empty build and returns it. - - Parameters: - - :param build_type: `build` -> space and ground build; `skills` -> space and ground skills; - `full` -> space and ground build and skills - """ - # None means not available on the build; empty string means empty slot - new_build = { - '_version': BUILD_VERSION, - 'space': { - 'active_rep_traits': [None] * 5, - 'aft_weapons': [None] * 5, - 'boffs': [[None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4], - 'boff_specs': [[None, None]] * 6, - 'core': [''], - 'deflector': [''], - 'devices': [None] * 6, - 'doffs_spec': [''] * 6, - 'doffs_variant': [''] * 6, - 'eng_consoles': [None] * 5, - 'engines': [''], - 'experimental': [None], - 'fore_weapons': [None] * 5, - 'hangars': [None] * 2, - 'rep_traits': [None] * 5, - 'sci_consoles': [None] * 5, - 'sec_def': [None], - 'shield': [''], - 'ship': '', - 'ship_name': '', - 'ship_desc': '', - 'starship_traits': [None] * 7, - 'tac_consoles': [None] * 5, - 'tier': '', - 'traits': ['', '', '', '', '', '', '', '', '', None, None, ''], - 'uni_consoles': [None] * 3, - }, - 'ground': { - 'active_rep_traits': [None] * 5, - 'armor': [''], - 'boffs': [[''] * 4, [''] * 4, [''] * 4, [''] * 4], - 'boff_profs': ['Tactical'] * 4, - 'boff_specs': ['Command'] * 4, - 'ground_desc': '', - 'ground_devices': ['', '', '', '', None], - 'doffs_spec': [''] * 6, - 'doffs_variant': [''] * 6, - 'ev_suit': [''], - 'kit': [''], - 'kit_modules': ['', '', '', '', '', None], - 'rep_traits': [''] * 5, - 'personal_shield': [''], - 'traits': ['', '', '', '', '', '', '', '', '', None, None, ''], - 'weapons': [''] * 2, - }, - 'captain': { - 'career': '', - 'elite': False, - 'faction': '', - 'name': '', - 'primary_spec': '', - 'secondary_spec': '', - 'species': '', - }, - } - - new_skills = { - '_version': BUILD_VERSION, - 'space_skills': { - 'eng': [False] * 30, - 'sci': [False] * 30, - 'tac': [False] * 30, - }, - 'skill_unlocks': { - 'eng': [None] * 5, - 'sci': [None] * 5, - 'tac': [None] * 5, - 'ground': [None] * 5 - }, - 'ground_skills': [ - [False] * 6, - [False] * 6, - [False] * 4, - [False] * 4 - ], - 'skill_desc': { - 'space': '', - 'ground': '' - } - } - - if build_type == 'build': - return new_build - elif build_type == 'full': - new_build.update(new_skills) - return new_build - elif build_type == 'skills': - return new_skills - - -def merge_build(self, original_build: dict, new_build: dict): - """ - updates `original_build` with contents of `new_build` - """ - for build_segment in original_build: - subdict = new_build.get(build_segment, None) - if subdict is None: - continue - if isinstance(subdict, dict): - original_build[build_segment].update(subdict) - else: - original_build[build_segment] = subdict - - -def pixel_range(num: int = 0, range_start: int = 0, /): - """ - Returns appropriate indices to access the RGB (not A) channels of the pixel row of `num` pixels, - as well as an 1-step increasing range index -> (range_index, pixel_index) - """ - counter = range_start - for index in range(0, num * 4, 4): - yield counter, index - counter += 1 - yield counter, index + 1 - counter += 1 - yield counter, index + 2 - counter += 1 - - -def backup_cargo_data(self): - """ - Saves current cargo data to backup folder. - """ - cargo_files = ( - 'boff_abilities.json', 'doffs.json', 'equipment.json', 'modifiers.json', - 'ship_list.json', 'starship_traits.json', 'traits.json') - cargo_folder = self.config['config_subfolders']['cargo'] - backups_folder = self.config['config_subfolders']['backups'] - for file_name in cargo_files: - cargo_path = os.path.join(cargo_folder, file_name) - backups_path = os.path.join(backups_folder, file_name) - copy_file(cargo_path, backups_path) - - -def get_icon_set(cargo_dir: Path) -> set[str]: - """ - Creates set of all required icons from cargo data and required static images. - - Parameters: - - :param cargo_dir: path to cargo data directory - """ - images_set = set() - equipment_cargo_data = load_json(str(cargo_dir / 'equipment.json')) - equipment_types = set(EQUIPMENT_TYPES.keys()) - elite_hangar = { - 'Hangar - Elite Federation Mission Scout Ships', - 'Hangar - Elite Valor Fighters' - } - for item in equipment_cargo_data: - if item['type'] in equipment_types: - if item['type'] == 'Hangar Bay' and item['name'] not in elite_hangar and ( - item['name'].startswith('Hangar - Advanced') - or item['name'].startswith('Hangar - Elite')): - continue - images_set.add(sanitize_equipment_name(item['name'])) - trait_cargo_data = load_json(str(cargo_dir / 'traits.json')) - for trait in trait_cargo_data: - if trait['type'] != 'doff' and trait['type'] != 'boff' and trait['name'] is not None: - if trait['icon_name'] is None: - images_set.add(trait['name']) - else: - images_set.add(trait['icon_name']) - shiptrait_cargo_data = load_json(str(cargo_dir / 'starship_traits.json')) - for ship_trait in shiptrait_cargo_data: - if ship_trait['icon_name'] is None: - images_set.add(ship_trait['name']) - else: - images_set.add(ship_trait['icon_name']) - return images_set - - -def get_skill_icons(skill_cache: dict[str, dict]) -> set[str]: - """ - """ - icons = set() - for rank_group in skill_cache['space']: - for skill_group in rank_group: - for skill_node in skill_group['nodes']: - icons.add(skill_node['image']) - for skill_group in skill_cache['ground']: - for skill_node in skill_group['nodes']: - icons.add(skill_node['image']) - return icons - - -def get_boff_icons(boff_cache: dict[str, dict]) -> set[str]: - """ - """ - return set(boff_cache['all'].keys()) - - -def get_ship_icons(ship_list: list[dict[str]]) -> set[str]: - """ - """ - icon_set = set() - for ship in ship_list: - try: - icon_set.add(ship['image'][5:]) - except TypeError: - pass - return icon_set - - -def build_cache(app_dir: Path) -> int: - """ - Builds cache in config folder indicated by `config_path`. Returns status: success: `0`, - failure: `1` - - Parameters: - - :param config_path: path to build cache into - """ - config_path = app_dir / '.config' - env_variables = read_env_file(config_path / '.env', ['SETS_CF_CLEARANCE', 'SETS_USER_AGENT']) - requests_session = Session() - if 'SETS_CF_CLEARANCE' in env_variables: - print(f'[Info] "SETS_CF_CLEARANCE" variable: "{env_variables["SETS_CF_CLEARANCE"][:10]}"') - print(f'[Info] "SETS_CF_CLEARANCE" variable: "{env_variables["SETS_CF_CLEARANCE"][-10:]}"') - requests_session.cookies.set_cookie( - create_cookie(name='cf_clearance', value=env_variables['SETS_CF_CLEARANCE'])) - if 'SETS_USER_AGENT' in env_variables: - print(f'[Info] "SETS_USER_AGENT" variable: "{env_variables["SETS_USER_AGENT"][:10]}"') - print(f'[Info] "SETS_USER_AGENT" variable: "{env_variables["SETS_USER_AGENT"][-10:]}"') - requests_session.headers['User-Agent'] = env_variables['SETS_USER_AGENT'] - cargo_dir = config_path / 'cargo' - success = list() - success.append(cache_cargo_data(cargo_dir / 'ship_list.json', SHIP_QUERY_URL, requests_session)) - success.append(cache_cargo_data(cargo_dir / 'equipment.json', ITEM_QUERY_URL, requests_session)) - success.append(cache_cargo_data(cargo_dir / 'traits.json', TRAIT_QUERY_URL, requests_session)) - success.append(cache_cargo_data( - cargo_dir / 'starship_traits.json', STARSHIP_TRAIT_QUERY_URL, requests_session)) - success.append(cache_cargo_data(cargo_dir / 'modifiers.json', MODIFIER_QUERY, requests_session)) - success.append(cache_cargo_data(cargo_dir / 'doffs.json', DOFF_QUERY_URL, requests_session)) - - image_dir = config_path / 'images' - downloaded_images = get_downloaded_icons(image_dir) - ultimate_icons = {'Focused Frenzy', 'Probability Manipulation', 'EPS Corruption'} - images_set = (get_icon_set(cargo_dir) | ultimate_icons) - downloaded_images - if len(images_set) > 0: - download_images_fast(list(images_set), env_variables, image_dir) - skill_cache = dict() - cache_skills(skill_cache, app_dir) - skill_images = get_skill_icons(skill_cache) - downloaded_images - if len(skill_images) > 0: - download_images_fast(list(skill_images), env_variables, image_dir, image_suffix='.png') - boff_cache = load_json(cargo_dir / 'boff_abilities.json') - boff_images = get_boff_icons(boff_cache) - downloaded_images - if len(boff_images) > 0: - download_images_fast( - list(boff_images), env_variables, image_dir, image_suffix='_icon_(Federation).png') - - downloaded_ship_images = set( - map(lambda x: unquote_plus(x), os.listdir(str(config_path / 'ship_images')))) - ship_list = load_json(str(cargo_dir / 'ship_list.json')) - ship_images = get_ship_icons(ship_list) - downloaded_ship_images - if len(ship_images) > 0: - download_images_fast( - list(ship_images), env_variables, config_path / 'ship_images', image_suffix='') - - if False in success: - return 1 - return 0 diff --git a/src/downloader.py b/src/downloader.py index ba9fa67..284cf77 100644 --- a/src/downloader.py +++ b/src/downloader.py @@ -5,8 +5,11 @@ from requests.exceptions import Timeout from time import time from threading import Thread +from typing import Callable from urllib.parse import quote_plus +from PySide6.QtCore import QObject, Signal + from .constants import GITHUB_CACHE_URL, WIKI_IMAGE_URL from .textedit import compensate_json @@ -14,6 +17,8 @@ class ReturnValueThread(Thread): def __init__(self, target, args: tuple = tuple()): super().__init__(target=target, args=args) + self._target: Callable + self._args: tuple self._return = None def run(self): @@ -25,15 +30,19 @@ def join(self): return self._return -class Downloader(): +class Downloader(QObject): """Downloads images and cargo tables""" + progress_init: Signal = Signal(int) + progress_step: Signal = Signal() + def __init__(self, images_dir: Path, ship_images_dir: Path): """ Parameters: - :param images_dir: path to directory storing icons - :param ship_images_dir: path to directory storing ship images """ + super().__init__() self._images_dir: str = str(images_dir) self._ship_images_dir: str = str(ship_images_dir) self._session: Session = Session() @@ -130,6 +139,7 @@ def download_image( image_file.write(image_response.content) else: failed_images[name] = int(time()) + self.progress_step.emit() def download_ship_image( self, name: str, failed_images: dict[str, int], session: Session | None = None): @@ -159,6 +169,7 @@ def download_ship_image( image_file.write(image_response.content) else: failed_images[name] = int(time()) + self.progress_step.emit() def download_image_chunk( self, image_list: list[str], image_suffix: str = '_icon.png', @@ -203,6 +214,7 @@ def download_image_list( while image_chunk_size < 4 and total_threads > 1: total_threads -= 1 image_chunk_size = len(image_list) // total_threads + self.progress_init.emit(len(image_list)) threads: list[ReturnValueThread] = list() for thread_num in range(total_threads): image_chunk_start = image_chunk_size * thread_num diff --git a/src/export.py b/src/export.py deleted file mode 100644 index e12efd6..0000000 --- a/src/export.py +++ /dev/null @@ -1,390 +0,0 @@ -from .constants import BOFF_RANKS_MD, CAREER_ABBR -from .textedit import wiki_url -from .widgets import notempty - - -def create_md_table(self, table: list[list[str]], alignment: list = []) -> str: - """ - Creates markdown-formatted table from two-dimensional list - - Parameters: - - :param table: two-dimenional list representing the table - - :param alignment: contains column alignment codes for the table - """ - text = '|'.join(table[0]) + '\n' - if len(alignment) == 0: - text += '|'.join([':--'] * len(table[0])) + '\n' - else: - text += '|'.join(alignment) + '\n' - for row in table[1:]: - text += '|'.join(row) + '\n' - return text - - -def md_equipment_table( - self, environment: str, key: str, header: str, extra_cols: int = 1, - single_line: bool = False) -> str: - """ - Returns table segment of equipment table for markdown export. - - Parameters: - - :param environment: "space" / "ground" - - :param key: key to `self.build[environment]` - - :param header: header text for section - - :param extra_cols: how many empty cols should be added - - :param single_line: whether the sections consists of a single line - """ - section = [[f'**{header}**']] - if single_line: - item = self.build[environment][key][0] - if item is not None and item != '': - section[0].append( - f"[{item['item']} {item['mark']} {''.join(notempty(item['modifiers']))}]" - f"({wiki_url(self.cache.equipment[key][item['item']]['Page'])})") - else: - section[0].append('') - section[0] += [''] * extra_cols - else: - category_items = self.build[environment][key] - for i, item in enumerate(category_items): - if item is None: - if i == 0: - section[0] += [''] * (extra_cols + 1) - continue - if i > 0: - section.append([' ']) - if item == '': - section[-1] += [''] * (extra_cols + 1) - else: - section[-1].append( - f"[{item['item']} {item['mark']} {''.join(notempty(item['modifiers']))}]" - f"({wiki_url(self.cache.equipment[key][item['item']]['Page'])})") - section[-1] += [''] * extra_cols - section.append(['--------------', '--------------'] + [''] * extra_cols) - return section - - -def md_boff_table(self, station: list, header: str, extra_cols: int = 1) -> list: - """ - Returns table segment of bridge officer table for markdown export. - - Parameters: - - :param station: boff station to convert - - :param header: station name - - :param extra_cols: how many empty cols should be added - """ - section = [[f'**{header}**']] - for i, ability in enumerate(station): - if i > 0: - section.append([' ']) - if ability == '': - section[i] += [''] * (extra_cols + 1) - elif ability is None: - section.pop() - else: - section[i].append(f"[{ability['item']}]({wiki_url(ability['item'], 'Ability: ')})") - section[i] += [''] * extra_cols - section.append(['--------------', '--------------'] + [''] * extra_cols) - return section - - -def md_skill_table_space(self, skills: list, offset: int) -> list: - """ - Returns table segment (one rank) of space skills for markdown export. - - Parameters: - - :param skills: contains all skill groups of one rank - - :param offset: offset of the first skill node for indexing into `self.build` - """ - section = [[], []] - offsets = {'eng': offset, 'tac': offset, 'sci': offset} - for skill in skills: - if skill['grouping'] == 'column': - section[0].append(f"[{skill['skill']}]({skill['link']})") - unlocked_skills = '' - if self.build['space_skills'][skill['career']][offsets[skill['career']]]: - unlocked_skills += '[X] > ' - else: - unlocked_skills += '[   ] > ' - if self.build['space_skills'][skill['career']][offsets[skill['career']] + 1]: - unlocked_skills += '[X] > ' - else: - unlocked_skills += '[   ] > ' - if self.build['space_skills'][skill['career']][offsets[skill['career']] + 2]: - unlocked_skills += '[X]' - else: - unlocked_skills += '[   ]' - section[1].append(unlocked_skills) - elif skill['grouping'] == 'pair+1': - section[0].append(skill['skill'][0]) - unlocked_skills = '' - if self.build['space_skills'][skill['career']][offsets[skill['career']] + 1]: - unlocked_skills += f"[[X]]({skill['link'][1]}) < " - else: - unlocked_skills += '[   ] < ' - if self.build['space_skills'][skill['career']][offsets[skill['career']]]: - unlocked_skills += f"[[X]]({skill['link'][0]}) > " - else: - unlocked_skills += '[   ] > ' - if self.build['space_skills'][skill['career']][offsets[skill['career']] + 2]: - unlocked_skills += f"[[X]]({skill['link'][2]})" - else: - unlocked_skills += '[   ]' - section[1].append(unlocked_skills) - elif skill['grouping'] == 'separate': - section[0].append(f"[{skill['skill'][0]}]({skill['link']})") - unlocked_skills = '' - if self.build['space_skills'][skill['career']][offsets[skill['career']] + 1]: - unlocked_skills += '[X] < ' - else: - unlocked_skills += '[   ] < ' - if self.build['space_skills'][skill['career']][offsets[skill['career']]]: - unlocked_skills += '[X] > ' - else: - unlocked_skills += '[   ] > ' - if self.build['space_skills'][skill['career']][offsets[skill['career']] + 2]: - unlocked_skills += '[X]' - else: - unlocked_skills += '[   ]' - section[1].append(unlocked_skills) - if len(section[0]) == 2 or len(section[0]) == 5: - section[0].append('') - section[1].append('') - offsets[skill['career']] += 3 - return section - - -def get_build_markdown(self, environment: str, type_: str) -> str: - """ - Converts part of build in self.build to markdown. - - Parameters: - - :param environment: "space" / "ground"; determines which build environment is generated - - :param type_: "build" / "skills"; determines whether build or skill tree is generated - """ - if environment == 'space' and type_ == 'build': - md = ( - f"# SPACE BUILD\n\n**Basic Information** | **Data** \n:--- | :--- \n" - f"*Ship Name* | {self.build['space']['ship_name']} \n" - f"*Ship Class* | {self.build['space']['ship']} \n" - f"*Ship Tier* | {self.build['space']['tier']} \n" - f"*Player Career* | {self.build['captain']['career']} \n" - f"*Elite Captain* | {'✓' if self.build['captain']['elite'] else '✗'}\n" - f"*Player Species* | {self.build['captain']['species']} \n" - f"*Primary Specialization* | {self.build['captain']['primary_spec']} \n" - f"*Secondary Specialization* | {self.build['captain']['secondary_spec']} \n\n\n" - ) - if self.build['space']['ship_desc']: - md += f"## Build Description\n\n{self.build['space']['ship_desc']}\n\n\n" - - md += '## Ship Equipment\n\n' - equip_table = [['**Basic Information**', '**Component**', '**Notes**']] - equip_table += md_equipment_table(self, 'space', 'fore_weapons', 'Fore Weapons') - equip_table += md_equipment_table(self, 'space', 'aft_weapons', 'Aft Weapons') - equip_table += md_equipment_table(self, 'space', 'deflector', 'Deflector', single_line=True) - if self.build['space']['sec_def'][0]: - equip_table += md_equipment_table( - self, 'space', 'sec_def', 'Secondary Deflector', single_line=True) - equip_table += md_equipment_table( - self, 'space', 'engines', 'Impulse Engines', single_line=True) - equip_table += md_equipment_table(self, 'space', 'core', 'Warp', single_line=True) - equip_table += md_equipment_table(self, 'space', 'shield', 'Shield', single_line=True) - equip_table += md_equipment_table(self, 'space', 'devices', 'Devices') - if self.build['space']['experimental'][0]: - equip_table += md_equipment_table( - self, 'space', 'experimental', 'Experimental Weapon', single_line=True) - if self.build['space']['hangars'][0] or self.build['space']['hangars'][1]: - equip_table += md_equipment_table(self, 'space', 'hangars', 'Hangars') - equip_table += md_equipment_table(self, 'space', 'uni_consoles', 'Universal Consoles') - equip_table += md_equipment_table(self, 'space', 'eng_consoles', 'Engineering Consoles') - equip_table += md_equipment_table(self, 'space', 'sci_consoles', 'Science Consoles') - equip_table += md_equipment_table(self, 'space', 'tac_consoles', 'Tactical Consoles') - md += create_md_table(self, equip_table) - - md += '\n\n\n## Bridge Officer Stations\n\n' - boff_table = [['**Profession**', '**Power**', '**Notes**']] - for specs, station in zip(self.build['space']['boff_specs'], self.build['space']['boffs']): - if any(specs): - station_name = BOFF_RANKS_MD[station.count(None)] + ' ' + specs[0] - if specs[1] != '': - station_name += ' / ' + specs[1] - boff_table += md_boff_table(self, station, station_name) - md += create_md_table(self, boff_table) - - md += '\n\n\n## Traits\n\n' - trait_table = [['**Starship Traits**', '**Notes**']] - for trait in notempty(self.build['space']['starship_traits']): - trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) - md += create_md_table(self, trait_table) - md += '\n\n​\n\n' - trait_table = [['**Personal Space Traits**', '**Notes**']] - for trait in notempty(self.build['space']['traits']): - trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) - md += create_md_table(self, trait_table) - md += '\n\n​\n\n' - trait_table = [['**Space Reputation Traits**', '**Notes**']] - for trait in notempty(self.build['space']['rep_traits']): - trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) - md += create_md_table(self, trait_table) - md += '\n\n​\n\n' - trait_table = [['**Active Space Reputation Traits**', '**Notes**']] - for trait in notempty(self.build['space']['active_rep_traits']): - trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) - md += create_md_table(self, trait_table) - - md += '\n\n\n## Active Space Duty Officers\n\n' - doff_table = [['**Specialization**', '**Power**', '**Notes**']] - for spec, variant in zip( - self.build['space']['doffs_spec'], self.build['space']['doffs_variant']): - if spec != '': - doff_table.append([f"[{spec}]({wiki_url(spec, 'Specialization: ')})", variant, '']) - md += create_md_table(self, doff_table) - return md - elif environment == 'ground' and type_ == 'build': - md = ( - f"# GROUND BUILD\n\n**Basic Information** | **Data** \n:--- | :--- \n" - f"*Player Name* | {self.build['captain']['name']} \n" - f"*Player Species* | {self.build['captain']['species']} \n" - f"*Player Career* | {self.build['captain']['career']} \n" - f"*Elite Captain* | {'✓' if self.build['captain']['elite'] else '✗'}\n" - f"*Primary Specialization* | {self.build['captain']['primary_spec']} \n" - f"*Secondary Specialization* | {self.build['captain']['secondary_spec']} \n\n\n" - ) - if self.build['ground']['ground_desc'] != '': - md += f"## Build Description\n\n{self.build['ground']['ground_desc']}\n\n\n" - - md += '## Personal Equipment\n\n' - equip_table = [[' ', '**Component**', '**Notes**']] - equip_table += md_equipment_table(self, 'ground', 'kit', 'Kit Frame', single_line=True) - equip_table += md_equipment_table(self, 'ground', 'kit_modules', 'Kit Modules') - equip_table += md_equipment_table(self, 'ground', 'armor', 'Body Armor', single_line=True) - equip_table += md_equipment_table(self, 'ground', 'ev_suit', 'EV Suit', single_line=True) - equip_table += md_equipment_table( - self, 'ground', 'personal_shield', 'Personal Shield', single_line=True) - equip_table += md_equipment_table(self, 'ground', 'weapons', 'Weapons') - equip_table += md_equipment_table(self, 'ground', 'ground_devices', 'Devices') - md += create_md_table(self, equip_table) - - md += '\n\n\n## Traits\n\n' - trait_table = [['**Personal Ground Traits**', '**Notes**']] - for trait in notempty(self.build['ground']['traits']): - trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) - md += create_md_table(self, trait_table) - md += '\n\n​\n\n' - trait_table = [['**Ground Reputation Traits**', '**Notes**']] - for trait in notempty(self.build['ground']['rep_traits']): - trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) - md += create_md_table(self, trait_table) - md += '\n\n​\n\n' - trait_table = [['**Active Ground Reputation Traits**', '**Notes**']] - for trait in notempty(self.build['ground']['active_rep_traits']): - trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) - md += create_md_table(self, trait_table) - - md += '\n\n\n## Active Ground Duty Officers\n\n' - doff_table = [['**Specialization**', '**Power**', '**Notes**']] - for spec, variant in zip( - self.build['ground']['doffs_spec'], self.build['ground']['doffs_variant']): - if spec != '': - doff_table.append([f"[{spec}]({wiki_url(spec, 'Specialization: ')})", variant, '']) - md += create_md_table(self, doff_table) - - md += '\n\n\n## Away Team\n\n' - boff_table = [['**Profession**', '**Power**', '**Notes**']] - for profession, specialization, station in zip( - self.build['ground']['boff_profs'], self.build['ground']['boff_specs'], - self.build['ground']['boffs']): - station_name = f"{profession} / {specialization}" - boff_table += md_boff_table(self, station, station_name) - md += create_md_table(self, boff_table) - return md - elif environment == 'space' and type_ == 'skills': - md = '# Space Skills\n\n' - skill_table = [[ - '**Engineering**', '', '', - '**Science**', '', '', - '**Tactical**', ' ' - ]] - offset = 0 - for rank_skills in self.cache.skills['space']: - skill_table += md_skill_table_space(self, rank_skills, offset) - skill_table.append([' '] + [''] * 6 + [' ']) - offset += 6 - md += create_md_table(self, skill_table, alignment=[':-:'] * 8) - md += '\n\n​\n\n' - - unlock_table = [ - [f"**[Unlocks]({wiki_url('Skill#Space_2')})**"] + [''] * 7 + [' '] - ] - for career, career_name in CAREER_ABBR.items(): - row = [f"**{career_name}**"] - for i, unlock_state in enumerate(self.build['skill_unlocks'][career]): - if unlock_state is None: - row.append('') - else: - unlock_slot = self.cache.skills['space_unlocks'][career][i] - if unlock_slot['points_required'] == 24: - skill_count = self.cache.skills[f"space_points_{career}"] - link = wiki_url(unlock_slot['name'], 'Ability: ') - if unlock_state is None: - row += ['', '', '', ' '] - elif unlock_state == -1: - row += [f"[{unlock_slot['name']}]({link})", '', '', ' '] - elif unlock_state == 3: - row += [ - f"[{unlock_slot['name']}]({link})", - unlock_slot['options'][0]['name'], - unlock_slot['options'][1]['name'], - unlock_slot['options'][2]['name'], - ] - elif skill_count == 25: - row += [ - f"[{unlock_slot['name']}]({link})", - unlock_slot['options'][unlock_state]['name'] - ] - elif skill_count == 26: - row.append(f"[{unlock_slot['name']}]({link})") - enhancements = [ - unlock_slot['options'][0]['name'], - unlock_slot['options'][1]['name'], - unlock_slot['options'][2]['name'], - ' ' - ] - enhancements.pop(unlock_state) - row += enhancements - else: - row.append(unlock_slot['nodes'][unlock_state]['name']) - if row[-1] == '': - row[-1] = ' ' - unlock_table.append(row) - md += create_md_table(self, unlock_table) - return md - elif environment == 'ground' and type_ == 'skills': - md = '# Ground Skills\n\n' - skill_table = [['**Skill**', '**I**', '**II**']] - id_offset = 0 - for skill in self.cache.skills['ground']: - row = [f"[{skill['nodes'][0]['name']}]({skill['link']})"] - if self.build['ground_skills'][skill['tree']][id_offset]: - row.append('[X]') - else: - row.append('[   ]') - if self.build['ground_skills'][skill['tree']][id_offset + 1]: - row.append('[X]') - else: - row.append('[   ]') - skill_table.append(row) - if skill['tree'] < 2 and id_offset == 4 or skill['tree'] >= 2 and id_offset == 2: - id_offset = 0 - else: - id_offset += 2 - md += create_md_table(self, skill_table, alignment=[':--', ':-:', ':-:']) - md += '\n\n​\n\n' - - unlock_table = [['', f"**[Unlocks]({wiki_url('Skill#Ground_2')})**", '']] - for unlock, unlock_state in zip( - self.cache.skills['ground_unlocks'], self.build['skill_unlocks']['ground']): - if unlock_state is not None: - unlock_table.append(['', unlock['nodes'][unlock_state]['name'], '']) - md += create_md_table(self, unlock_table, alignment=['', ':-:', '']) - return md diff --git a/src/exportwindow.py b/src/exportwindow.py new file mode 100644 index 0000000..a535aca --- /dev/null +++ b/src/exportwindow.py @@ -0,0 +1,492 @@ +from PySide6.QtGui import QTextOption +from PySide6.QtWidgets import QApplication, QDialog, QPlainTextEdit, QWidget + +from .buildmanager import BuildManager +from .cargomanager import CargoManager +from .constants import AHCENTER, ALEFT, ATOP, BOFF_RANKS_MD, CAREER_ABBR, SMINMAX, SMINMIN +from .textedit import wiki_url +from .theme import AppTheme +from .widgetbuilder import create_button_series2, create_frame2, create_label2 +from .widgets import notempty, VBoxLayout + + +class ExportWindow(QDialog): + """ + Holds Export Window + """ + def __init__( + self, theme: AppTheme, parent_window: QWidget, build: BuildManager, + cargo: CargoManager): + super().__init__(parent=parent_window) + self._window: QWidget = parent_window + self._build: BuildManager = build + self._cargo: CargoManager = cargo + thick = theme['app']['frame_thickness'] * theme.scale + dialog_layout = VBoxLayout(margins=thick) + main_frame = create_frame2(theme, size_policy=SMINMIN) + dialog_layout.addWidget(main_frame) + main_layout = VBoxLayout(margins=thick, spacing=thick) + content_frame = create_frame2(theme, size_policy=SMINMIN) + content_layout = VBoxLayout(spacing=thick) + content_layout.setAlignment(ATOP) + + header_label = create_label2(theme, 'Markdown Export:', 'label_heading') + content_layout.addWidget(header_label, alignment=ALEFT) + self._md_textedit = QPlainTextEdit() + button_def = { + 'default': {'margin-top': 0}, + 'Space Build': { + 'callback': lambda: self.update_export('space', 'build') + }, + 'Ground Build': { + 'callback': lambda: self.update_export('ground', 'build') + }, + 'Space Skills': { + 'callback': lambda: self.update_export('space', 'skills') + }, + 'Ground Skills': { + 'callback': lambda: self.update_export('ground', 'skills') + }, + } + top_buttons = create_button_series2(theme, button_def) + top_buttons.setAlignment(AHCENTER) + content_layout.addLayout(top_buttons) + self._md_textedit.setSizePolicy(SMINMIN) + self._md_textedit.setStyleSheet(theme.get_style_class('QPlainTextEdit', 'textedit')) + self._md_textedit.setFont(theme.get_font('textedit')) + self._md_textedit.setWordWrapMode(QTextOption.WrapMode.NoWrap) + content_layout.addWidget(self._md_textedit, stretch=1) + content_frame.setLayout(content_layout) + main_layout.addWidget(content_frame, stretch=1) + + separator = create_frame2(theme, style='light_frame', size_policy=SMINMAX) + separator.setFixedHeight(1) + main_layout.addWidget(separator) + footer_button_def = { + 'Copy': {'callback': self.copy_current_markdown}, + 'Close': {'callback': lambda: self.done(0)} + } + footer_buttons = create_button_series2(theme, footer_button_def) + footer_buttons.setAlignment(AHCENTER) + main_layout.addLayout(footer_buttons) + main_frame.setLayout(main_layout) + + self.setLayout(dialog_layout) + self.setWindowTitle('SETS - Markdown Export') + self.setStyleSheet(theme.get_style('dialog_window')) + + def invoke(self): + """ + Shows Export Window. + """ + window_rect = self._window.geometry() + self.setGeometry( + window_rect.x() + window_rect.width() * 0.25, + window_rect.y() + window_rect.height() * 0.25, + window_rect.width() * 0.5, + window_rect.height() * 0.5) + self.update_export('space', 'build') + self.open() + + def update_export(self, environment: str, type_: str): + """ + Updates text output area with newly generated markdown output. + + Parameters: + - :param environment: `space` or `ground` + - :param type_: `build` or `skills` + """ + self._md_textedit.setPlainText(self.get_build_markdown(environment, type_)) + + def copy_current_markdown(self): + """ + Copies currently displayed mardown to application clipboard. + """ + QApplication.clipboard().setText(self._md_textedit.toPlainText()) + + def create_md_table(self, table: list[list[str]], alignment: list = []) -> str: + """ + Creates markdown-formatted table from two-dimensional list + + Parameters: + - :param table: two-dimenional list representing the table + - :param alignment: contains column alignment codes for the table + """ + text = '|'.join(table[0]) + '\n' + if len(alignment) == 0: + text += '|'.join([':--'] * len(table[0])) + '\n' + else: + text += '|'.join(alignment) + '\n' + for row in table[1:]: + text += '|'.join(row) + '\n' + return text + + def md_equipment_table( + self, environment: str, key: str, header: str, extra_cols: int = 1, + single_line: bool = False) -> str: + """ + Returns table segment of equipment table for markdown export. + + Parameters: + - :param environment: "space" / "ground" + - :param key: key to `self.build[environment]` + - :param header: header text for section + - :param extra_cols: how many empty cols should be added + - :param single_line: whether the sections consists of a single line + """ + section = [[f'**{header}**']] + if single_line: + item = self._build[environment][key][0] + if item is not None and item != '': + section[0].append( + f"[{item['item']} {item['mark']} {''.join(notempty(item['modifiers']))}]" + f"({wiki_url(self._cargo.equipment[key][item['item']]['Page'])})") + else: + section[0].append('') + section[0] += [''] * extra_cols + else: + category_items = self._build[environment][key] + for i, item in enumerate(category_items): + if item is None: + if i == 0: + section[0] += [''] * (extra_cols + 1) + continue + if i > 0: + section.append([' ']) + if item == '': + section[-1] += [''] * (extra_cols + 1) + else: + section[-1].append( + f"[{item['item']} {item['mark']} {''.join(notempty(item['modifiers']))}]" + f"({wiki_url(self._cargo.equipment[key][item['item']]['Page'])})") + section[-1] += [''] * extra_cols + section.append(['--------------', '--------------'] + [''] * extra_cols) + return section + + def md_boff_table(self, station: list, header: str, extra_cols: int = 1) -> list: + """ + Returns table segment of bridge officer table for markdown export. + + Parameters: + - :param station: boff station to convert + - :param header: station name + - :param extra_cols: how many empty cols should be added + """ + section = [[f'**{header}**']] + for i, ability in enumerate(station): + if i > 0: + section.append([' ']) + if ability == '': + section[i] += [''] * (extra_cols + 1) + elif ability is None: + section.pop() + else: + section[i].append(f"[{ability['item']}]({wiki_url(ability['item'], 'Ability: ')})") + section[i] += [''] * extra_cols + section.append(['--------------', '--------------'] + [''] * extra_cols) + return section + + def md_skill_table_space(self, skills: list, offset: int) -> list: + """ + Returns table segment (one rank) of space skills for markdown export. + + Parameters: + - :param skills: contains all skill groups of one rank + - :param offset: offset of the first skill node for indexing into `self.build` + """ + section = [[], []] + offsets = {'eng': offset, 'tac': offset, 'sci': offset} + for skill in skills: + if skill['grouping'] == 'column': + section[0].append(f"[{skill['skill']}]({skill['link']})") + unlocked_skills = '' + if self._build['space_skills'][skill['career']][offsets[skill['career']]]: + unlocked_skills += '[X] > ' + else: + unlocked_skills += '[   ] > ' + if self._build['space_skills'][skill['career']][offsets[skill['career']] + 1]: + unlocked_skills += '[X] > ' + else: + unlocked_skills += '[   ] > ' + if self._build['space_skills'][skill['career']][offsets[skill['career']] + 2]: + unlocked_skills += '[X]' + else: + unlocked_skills += '[   ]' + section[1].append(unlocked_skills) + elif skill['grouping'] == 'pair+1': + section[0].append(skill['skill'][0]) + unlocked_skills = '' + if self._build['space_skills'][skill['career']][offsets[skill['career']] + 1]: + unlocked_skills += f"[[X]]({skill['link'][1]}) < " + else: + unlocked_skills += '[   ] < ' + if self._build['space_skills'][skill['career']][offsets[skill['career']]]: + unlocked_skills += f"[[X]]({skill['link'][0]}) > " + else: + unlocked_skills += '[   ] > ' + if self._build['space_skills'][skill['career']][offsets[skill['career']] + 2]: + unlocked_skills += f"[[X]]({skill['link'][2]})" + else: + unlocked_skills += '[   ]' + section[1].append(unlocked_skills) + elif skill['grouping'] == 'separate': + section[0].append(f"[{skill['skill'][0]}]({skill['link']})") + unlocked_skills = '' + if self._build['space_skills'][skill['career']][offsets[skill['career']] + 1]: + unlocked_skills += '[X] < ' + else: + unlocked_skills += '[   ] < ' + if self._build['space_skills'][skill['career']][offsets[skill['career']]]: + unlocked_skills += '[X] > ' + else: + unlocked_skills += '[   ] > ' + if self._build['space_skills'][skill['career']][offsets[skill['career']] + 2]: + unlocked_skills += '[X]' + else: + unlocked_skills += '[   ]' + section[1].append(unlocked_skills) + if len(section[0]) == 2 or len(section[0]) == 5: + section[0].append('') + section[1].append('') + offsets[skill['career']] += 3 + return section + + def get_build_markdown(self, environment: str, type_: str) -> str: + """ + Converts part of build in self.build to markdown. + + Parameters: + - :param environment: "space" / "ground"; determines which build environment is generated + - :param type_: "build" / "skills"; determines whether build or skill tree is generated + """ + if environment == 'space' and type_ == 'build': + md = ( + f"# SPACE BUILD\n\n**Basic Information** | **Data** \n:--- | :--- \n" + f"*Ship Name* | {self._build['space']['ship_name']} \n" + f"*Ship Class* | {self._build['space']['ship']} \n" + f"*Ship Tier* | {self._build['space']['tier']} \n" + f"*Player Career* | {self._build['captain']['career']} \n" + f"*Elite Captain* | {'✓' if self._build['captain']['elite'] else '✗'}\n" + f"*Player Species* | {self._build['captain']['species']} \n" + f"*Primary Specialization* | {self._build['captain']['primary_spec']} \n" + f"*Secondary Specialization* | {self._build['captain']['secondary_spec']} \n\n\n" + ) + if self._build['space']['ship_desc']: + md += f"## Build Description\n\n{self._build['space']['ship_desc']}\n\n\n" + + md += '## Ship Equipment\n\n' + equip_table = [['**Basic Information**', '**Component**', '**Notes**']] + equip_table += self.md_equipment_table('space', 'fore_weapons', 'Fore Weapons') + equip_table += self.md_equipment_table('space', 'aft_weapons', 'Aft Weapons') + equip_table += self.md_equipment_table( + 'space', 'deflector', 'Deflector', single_line=True) + if self._build['space']['sec_def'][0]: + equip_table += self.md_equipment_table( + 'space', 'sec_def', 'Secondary Deflector', single_line=True) + equip_table += self.md_equipment_table( + 'space', 'engines', 'Impulse Engines', single_line=True) + equip_table += self.md_equipment_table('space', 'core', 'Warp', single_line=True) + equip_table += self.md_equipment_table('space', 'shield', 'Shield', single_line=True) + equip_table += self.md_equipment_table('space', 'devices', 'Devices') + if self._build['space']['experimental'][0]: + equip_table += self.md_equipment_table( + 'space', 'experimental', 'Experimental Weapon', single_line=True) + if self._build['space']['hangars'][0] or self._build['space']['hangars'][1]: + equip_table += self.md_equipment_table('space', 'hangars', 'Hangars') + equip_table += self.md_equipment_table('space', 'uni_consoles', 'Universal Consoles') + equip_table += self.md_equipment_table('space', 'eng_consoles', 'Engineering Consoles') + equip_table += self.md_equipment_table('space', 'sci_consoles', 'Science Consoles') + equip_table += self.md_equipment_table('space', 'tac_consoles', 'Tactical Consoles') + md += self.create_md_table(equip_table) + + md += '\n\n\n## Bridge Officer Stations\n\n' + boff_table = [['**Profession**', '**Power**', '**Notes**']] + for specs, station in zip( + self._build['space']['boff_specs'], self._build['space']['boffs']): + if any(specs): + station_name = BOFF_RANKS_MD[station.count(None)] + ' ' + specs[0] + if specs[1] != '': + station_name += ' / ' + specs[1] + boff_table += self.md_boff_table(station, station_name) + md += self.create_md_table(boff_table) + + md += '\n\n\n## Traits\n\n' + trait_table = [['**Starship Traits**', '**Notes**']] + for trait in notempty(self._build['space']['starship_traits']): + trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) + md += self.create_md_table(trait_table) + md += '\n\n​\n\n' + trait_table = [['**Personal Space Traits**', '**Notes**']] + for trait in notempty(self._build['space']['traits']): + trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) + md += self.create_md_table(trait_table) + md += '\n\n​\n\n' + trait_table = [['**Space Reputation Traits**', '**Notes**']] + for trait in notempty(self._build['space']['rep_traits']): + trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) + md += self.create_md_table(trait_table) + md += '\n\n​\n\n' + trait_table = [['**Active Space Reputation Traits**', '**Notes**']] + for trait in notempty(self._build['space']['active_rep_traits']): + trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) + md += self.create_md_table(trait_table) + + md += '\n\n\n## Active Space Duty Officers\n\n' + doff_table = [['**Specialization**', '**Power**', '**Notes**']] + for spec, variant in zip( + self._build['space']['doffs_spec'], self._build['space']['doffs_variant']): + if spec != '': + doff_table.append( + [f"[{spec}]({wiki_url(spec, 'Specialization: ')})", variant, '']) + md += self.create_md_table(doff_table) + return md + elif environment == 'ground' and type_ == 'build': + md = ( + f"# GROUND BUILD\n\n**Basic Information** | **Data** \n:--- | :--- \n" + f"*Player Name* | {self._build['captain']['name']} \n" + f"*Player Species* | {self._build['captain']['species']} \n" + f"*Player Career* | {self._build['captain']['career']} \n" + f"*Elite Captain* | {'✓' if self._build['captain']['elite'] else '✗'}\n" + f"*Primary Specialization* | {self._build['captain']['primary_spec']} \n" + f"*Secondary Specialization* | {self._build['captain']['secondary_spec']} \n\n\n" + ) + if self._build['ground']['ground_desc'] != '': + md += f"## Build Description\n\n{self._build['ground']['ground_desc']}\n\n\n" + + md += '## Personal Equipment\n\n' + equip_table = [[' ', '**Component**', '**Notes**']] + equip_table += self.md_equipment_table('ground', 'kit', 'Kit Frame', single_line=True) + equip_table += self.md_equipment_table('ground', 'kit_modules', 'Kit Modules') + equip_table += self.md_equipment_table( + 'ground', 'armor', 'Body Armor', single_line=True) + equip_table += self.md_equipment_table('ground', 'ev_suit', 'EV Suit', single_line=True) + equip_table += self.md_equipment_table( + 'ground', 'personal_shield', 'Personal Shield', single_line=True) + equip_table += self.md_equipment_table('ground', 'weapons', 'Weapons') + equip_table += self.md_equipment_table('ground', 'ground_devices', 'Devices') + md += self.create_md_table(equip_table) + + md += '\n\n\n## Traits\n\n' + trait_table = [['**Personal Ground Traits**', '**Notes**']] + for trait in notempty(self._build['ground']['traits']): + trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) + md += self.create_md_table(trait_table) + md += '\n\n​\n\n' + trait_table = [['**Ground Reputation Traits**', '**Notes**']] + for trait in notempty(self._build['ground']['rep_traits']): + trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) + md += self.create_md_table(trait_table) + md += '\n\n​\n\n' + trait_table = [['**Active Ground Reputation Traits**', '**Notes**']] + for trait in notempty(self._build['ground']['active_rep_traits']): + trait_table.append([f"[{trait['item']}]({wiki_url(trait['item'], 'Trait: ')})", '']) + md += self.create_md_table(trait_table) + + md += '\n\n\n## Active Ground Duty Officers\n\n' + doff_table = [['**Specialization**', '**Power**', '**Notes**']] + for spec, variant in zip( + self._build['ground']['doffs_spec'], self._build['ground']['doffs_variant']): + if spec != '': + doff_table.append( + [f"[{spec}]({wiki_url(spec, 'Specialization: ')})", variant, '']) + md += self.create_md_table(doff_table) + + md += '\n\n\n## Away Team\n\n' + boff_table = [['**Profession**', '**Power**', '**Notes**']] + for profession, specialization, station in zip( + self._build['ground']['boff_profs'], self._build['ground']['boff_specs'], + self._build['ground']['boffs']): + station_name = f"{profession} / {specialization}" + boff_table += self.md_boff_table(station, station_name) + md += self.create_md_table(boff_table) + return md + elif environment == 'space' and type_ == 'skills': + md = '# Space Skills\n\n' + skill_table = [[ + '**Engineering**', '', '', + '**Science**', '', '', + '**Tactical**', ' ' + ]] + offset = 0 + for rank_skills in self._cargo.skills['space']: + skill_table += self.md_skill_table_space(rank_skills, offset) + skill_table.append([' '] + [''] * 6 + [' ']) + offset += 6 + md += self.create_md_table(skill_table, alignment=[':-:'] * 8) + md += '\n\n​\n\n' + + unlock_table = [ + [f"**[Unlocks]({wiki_url('Skill#Space_2')})**"] + [''] * 7 + [' '] + ] + for career, career_name in CAREER_ABBR.items(): + row = [f"**{career_name}**"] + for i, unlock_state in enumerate(self._build['skill_unlocks'][career]): + if unlock_state is None: + row.append('') + else: + unlock_slot = self._cargo.skills['space_unlocks'][career][i] + if unlock_slot['points_required'] == 24: + skill_count = self._build._skill_state[f"space_points_{career}"] + link = wiki_url(unlock_slot['name'], 'Ability: ') + if unlock_state is None: + row += ['', '', '', ' '] + elif unlock_state == -1: + row += [f"[{unlock_slot['name']}]({link})", '', '', ' '] + elif unlock_state == 3: + row += [ + f"[{unlock_slot['name']}]({link})", + unlock_slot['options'][0]['name'], + unlock_slot['options'][1]['name'], + unlock_slot['options'][2]['name'], + ] + elif skill_count == 25: + row += [ + f"[{unlock_slot['name']}]({link})", + unlock_slot['options'][unlock_state]['name'] + ] + elif skill_count == 26: + row.append(f"[{unlock_slot['name']}]({link})") + enhancements = [ + unlock_slot['options'][0]['name'], + unlock_slot['options'][1]['name'], + unlock_slot['options'][2]['name'], + ' ' + ] + enhancements.pop(unlock_state) + row += enhancements + else: + row.append(unlock_slot['nodes'][unlock_state]['name']) + if row[-1] == '': + row[-1] = ' ' + unlock_table.append(row) + md += self.create_md_table(unlock_table) + return md + elif environment == 'ground' and type_ == 'skills': + md = '# Ground Skills\n\n' + skill_table = [['**Skill**', '**I**', '**II**']] + id_offset = 0 + for skill in self._cargo.skills['ground']: + row = [f"[{skill['nodes'][0]['name']}]({skill['link']})"] + if self._build['ground_skills'][skill['tree']][id_offset]: + row.append('[X]') + else: + row.append('[   ]') + if self._build['ground_skills'][skill['tree']][id_offset + 1]: + row.append('[X]') + else: + row.append('[   ]') + skill_table.append(row) + if skill['tree'] < 2 and id_offset == 4 or skill['tree'] >= 2 and id_offset == 2: + id_offset = 0 + else: + id_offset += 2 + md += self.create_md_table(skill_table, alignment=[':--', ':-:', ':-:']) + md += '\n\n​\n\n' + + unlock_table = [['', f"**[Unlocks]({wiki_url('Skill#Ground_2')})**", '']] + for unlock, unlock_state in zip( + self._cargo.skills['ground_unlocks'], self._build['skill_unlocks']['ground']): + if unlock_state is not None: + unlock_table.append(['', unlock['nodes'][unlock_state]['name'], '']) + md += self.create_md_table(unlock_table, alignment=['', ':-:', '']) + return md diff --git a/src/imagemanager.py b/src/imagemanager.py index 793cf1f..4bc86d7 100644 --- a/src/imagemanager.py +++ b/src/imagemanager.py @@ -1,36 +1,87 @@ from os import listdir as os__listdir from pathlib import Path -from PySide6.QtGui import QImage from time import time from urllib.parse import quote_plus, unquote_plus +from PySide6.QtCore import QObject, Signal +from PySide6.QtGui import QIcon, QImage, QPixmap + from .cargomanager import CargoManager from .constants import SEVEN_DAYS_IN_SECONDS from .downloader import Downloader -from .iofunc import get_cached_cargo_data +from .iofunc import get_image_file_name + + +class Overlays(): + """Stores overlay icons.""" + + __slots__ = ('common', 'uncommon', 'rare', 'veryrare', 'ultrarare', 'epic', 'check') + def __init__(self): + self.common: QImage + self.uncommon: QImage + self.rare: QImage + self.veryrare: QImage + self.ultrarare: QImage + self.epic: QImage + self.check: QImage -class ImageManager(): + +class ImageManager(QObject): """Manages icons and ship images""" + splash_text: Signal = Signal(str) + def __init__( - self, images_dir: Path, ship_images_dir: Path, cargo_cache: CargoManager, + self, images_dir: Path, ship_images_dir: Path, app_dir: Path, cargo_cache: CargoManager, downloader: Downloader): """ Parameters: - :param images_dir: path to directory storing icons - :param ship_images_dir: path to directory storing ship images + - :param app_dir: path to directory containing the app installation - :param cargo_cache: used to access cache - :param downloader: used to download icons and ship images """ + super().__init__() self._images_dir: Path = images_dir self._ship_images_dir: Path = ship_images_dir + self._app_dir: Path = app_dir self._cargo_cache: CargoManager = cargo_cache self._downloader: Downloader = downloader - self.empty = QImage() + self.empty: QImage = QImage() + self.overlays: Overlays = Overlays() + self.icons: dict[str, QIcon | QPixmap] = dict() + self._images: dict[str, QImage] = dict() self.image_set: set[str] = set() self.failed_images: dict[str, int] = dict() + def get(self, image_name: str) -> QImage: + """ + Returns image from cache if cached, loads and returns image if not cached. + + Parameters: + - :param image_name: name of the image + """ + image = self._images[image_name] + if image.isNull(): + image.load(str(self._images_dir / get_image_file_name(image_name))) + return image + + def get_alt(self, image_name: str, image_suffix: str = '') -> QImage: + """ + Returns image from cache if cached, loads and returns image if not cached. Tries to get + alternate image first. + + Parameters: + - :param image_name: name of the image + - :param image_suffix: suffix to check in self.cache.alt_images + """ + if image_name + image_suffix in self._cargo_cache.alt_images: + return self.get(self._cargo_cache.alt_images[image_name + image_suffix]) + else: + return self.get(image_name) + def get_downloaded_icons(self) -> set[str]: """ Returns set containing all images currently in the images folder. @@ -56,19 +107,21 @@ def download_images(self, skill_cache: dict[str, dict]): del self.failed_images[image_name] available_images = self.get_downloaded_icons() | no_retry_images - # TODO have all icons (including skills) in image_set from the start ultimate_skill_icons = {'Focused Frenzy', 'Probability Manipulation', 'EPS Corruption'} image_set = self.image_set | ultimate_skill_icons images = image_set - available_images - self._cargo_cache.boff_abilities['all'].keys() + self.splash_text.emit('Downloading Equipment and Trait Images...') failed = self._downloader.download_image_list(list(images)) self.failed_images.update(failed) boff_images = self._cargo_cache.boff_abilities['all'].keys() - available_images + self.splash_text.emit('Downloading Bridge Officer Images...') failed = self._downloader.download_image_list( list(boff_images), image_suffix='_icon_(Federation).png') self.failed_images.update(failed) skill_images = self.get_skill_icons(skill_cache) - available_images + self.splash_text.emit('Downloading Skill Images...') failed = self._downloader.download_image_list(list(skill_images), image_suffix='.png') self.failed_images.update(failed) @@ -89,7 +142,7 @@ def get_skill_icons(self, skill_cache: dict[str, dict]) -> set[str]: icons.add(skill_node['image']) return icons - def get_ship_image(self, image_name: str, threaded_worker): + def get_ship_image(self, image_name: str) -> QImage: """ Tries to load ship image from local filesystem. If it is not avilable, downloads and stores it. Passes the image back using the provided signal. TODO improve result handling @@ -104,4 +157,45 @@ def get_ship_image(self, image_name: str, threaded_worker): # TODO integrate with failed images self._downloader.download_ship_image(image_name, {}) image = QImage(image_path) - threaded_worker.result.emit((image,)) + return image + + def load_base_images(self): + """ + Loads all images that are required for the app to start (skills, overlays) + """ + local_folder = self._app_dir / 'local' + self._images = {image_name: QImage() for image_name in self.image_set} + self.overlays.common = QImage(local_folder / 'Common_icon.png') + self.overlays.uncommon = QImage(local_folder / 'Uncommon_icon.png') + self.overlays.rare = QImage(local_folder / 'Rare_icon.png') + self.overlays.veryrare = QImage(local_folder / 'Very_rare_icon.png') + self.overlays.ultrarare = QImage(local_folder / 'Ultra_rare_icon.png') + self.overlays.epic = QImage(local_folder / 'Epic_icon.png') + self.overlays.check = QImage(local_folder / 'check_overlay.png') + + for rank_group in self._cargo_cache.skills['space']: + for skill_group in rank_group: + for skill_node in skill_group['nodes']: + self._images[skill_node['image']] = QImage( + self._images_dir / get_image_file_name(skill_node['image'])) + for skill_group in self._cargo_cache.skills['ground']: + for skill_node in skill_group['nodes']: + self._images[skill_node['image']] = QImage( + self._images_dir / get_image_file_name(skill_node['image'])) + self._images['arrow-up'] = QImage(local_folder / 'arrow-up.png') + self._images['arrow-down'] = QImage(local_folder / 'arrow-down.png') + self._images['Focused Frenzy'] = QImage( + self._images_dir / get_image_file_name('Focused Frenzy')) + self._images['Probability Manipulation'] = QImage( + self._images_dir / get_image_file_name('Probability Manipulation')) + self._images['EPS Corruption'] = QImage( + self._images_dir / get_image_file_name('EPS Corruption')) + + def load_images(self): + """ + Loads images from drive. + """ + image_dir = str(self._images_dir) + for image_name, image in self._images.items(): + if image.isNull(): + image.load(f'{image_dir}/{get_image_file_name(image_name)}') diff --git a/src/iofunc.py b/src/iofunc.py index e6742c1..1426de0 100644 --- a/src/iofunc.py +++ b/src/iofunc.py @@ -1,332 +1,66 @@ -from datetime import datetime -import json -from json import load as json__load, JSONDecodeError -import os +from json import dump as json__dump, load as json__load, JSONDecodeError from pathlib import Path -from shutil import copyfile as shutil__copyfile, rmtree as shutil__rmtree -import sys -from threading import Thread -from urllib.parse import quote_plus, unquote_plus +from shutil import rmtree as shutil__rmtree +from urllib.parse import quote_plus from webbrowser import open as webbrowser_open -from PySide6.QtGui import QIcon, QImage -from PySide6.QtWidgets import QFileDialog -import requests -from requests.cookies import create_cookie as requests__create_cookie -from requests_html import HTMLSession +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtWidgets import QFileDialog, QWidget -from .constants import WIKI_IMAGE_URL, WIKI_URL -from .textedit import compensate_json +from .constants import WIKI_URL -class ReturnValueThread(Thread): - def __init__(self, target, args: tuple = tuple()): - super().__init__(target=target, args=args) - self._return = None - - def run(self): - if self._target is not None: - self._return = self._target(*self._args) - - def join(self): - super().join() - return self._return - - -def browse_path(self, default_path: str = None, types: str = 'Any File (*.*)', save=False) -> str: +def browse_path( + preset_path: Path, types: str = 'Any File (*.*)', save: bool = False, folder: bool = False, + parent_window: QWidget | None = None) -> Path | None: """ Opens file dialog prompting the user to select a file. Parameters: - - :param default_path: path that the file dialog opens at - - :param types: string containing all file extensions and their respective names that are - allowed. - Format: " (*.);; (*.);; [...]" - Example: "Logfile (*.log);;Any File (*.*)" + - :param preset_path: path that the file dialog opens at; includes default file name + - :param types: string containing all file extensions and their respective names that are \ + allowed. Format: ` (*.);; (*.);; \ + [...]` Example: `Logfile (*.log);;Any File (*.*)` + - :param save: False => open file with dialog; True => save file with dialog + - :param folder: True => tries to open folder instead of file + - :param parent_window: window to use as parent; uses window icon and name of parent window + + :return: returns selected path; None if user aborts or tries to open not-existing file """ - if default_path is None or default_path == '': - default_path = self.app_dir - default_path = os.path.abspath(default_path) - if not os.path.exists(os.path.dirname(default_path)): - default_path = self.app_dir + if folder: + f = QFileDialog.getExistingDirectory(parent_window, 'Open Folder', str(preset_path)) + if f == '': + return None + return Path(f) if save: - file, filter = QFileDialog.getSaveFileName(self.window, 'Save...', default_path, types) - selected_extension = filter.rpartition('.')[2][:-1] - if file.rpartition('.')[2].lower() != selected_extension: - file += f".{selected_extension}" + f = QFileDialog.getSaveFileName(parent_window, 'Save File', str(preset_path), types)[0] + if f == '': + return None + return Path(f) else: - file, _ = QFileDialog.getOpenFileName(self.window, 'Open...', default_path, types) - return file - - -def get_cargo_data(self, filename: str, url: str, ignore_cache_age=False) -> dict | list: - """ - Retrieves cargo data for specific table. Downloads cargo data from wiki if cargo cache is empty. - Updates cargo cache. - - Parameters: - - :param filename: filename of cache file - - :param url: url to cargo table - - :param ignore_cache_age: True if cache of any age should be accepted - """ - filepath = os.path.join(self.config['config_subfolders']['cargo'], filename) - cargo_data = None - - # try loading from cache - if os.path.exists(filepath) and os.path.isfile(filepath): - last_modified = os.path.getmtime(filepath) - if (datetime.now() - datetime.fromtimestamp(last_modified)).days < 7 or ignore_cache_age: - try: - return load_json(filepath) - except json.JSONDecodeError: - pass - - # download cargo data if loading from cache failed or data should be updated - try: - cargo_data = self.downloader.download_cargo_table(url, filename) - if cargo_data is not None: - auto_backup_cargo_file(self, filename) - store_json(cargo_data, filepath) - return cargo_data - except (requests.exceptions.RequestException, json.JSONDecodeError): - if ignore_cache_age: - backup_path = os.path.join(self.config['config_subfolders']['backups'], filename) - auto_backup_path = os.path.join( - self.config['config_subfolders']['auto_backups'], filename) - if self.settings.value('pref_backup', type=int) == 0: - backup_paths = (auto_backup_path, backup_path) - else: - backup_paths = (backup_path, auto_backup_path) - for path in backup_paths: - if os.path.exists(path) and os.path.isfile(path): - try: - cargo_data = load_json(path) - store_json(cargo_data, filepath) - return cargo_data - except json.JSONDecodeError: - pass - sys.stderr.write(f'[Error] Cargo table could not be retrieved ({filename})\n') - sys.exit(1) + f = QFileDialog.getOpenFileName(parent_window, 'Open File', str(preset_path), types)[0] + if f == '': + return None + selected_path = Path(f) + if selected_path.exists(): + return selected_path else: - return get_cargo_data(self, filename, url, ignore_cache_age=True) - - -def get_cached_cargo_data(self, filename: str) -> dict | list: - """ - Retrieves cached cargo data from filename. Returns empty dict when cache is too old or - corrupted. - - Parameters: - - :param filename: name of the cache file - """ - filepath = os.path.join(self.config['config_subfolders']['cache'], filename) - if os.path.exists(filepath) and os.path.isfile(filepath): - last_modified = os.path.getmtime(filepath) - if (datetime.now() - datetime.fromtimestamp(last_modified)).days < 7: - try: - return load_json(filepath) - except json.JSONDecodeError: - pass - return {} - - -def store_to_cache(self, data, filename: str): - """ - Stores data to cache file with filename. - - Parameters: - - :param data: data that will be stored - - :param filename: filename of the cache file - """ - filepath = os.path.join(self.config['config_subfolders']['cache'], filename) - store_json(data, filepath) - - -def retrieve_image( - self, name: str, image_folder_path: str, signal=None, url_override: str = '') -> QImage: - """ - Downloads image or fetches image from cache. - - Parameters: - - :param name: name of the item - - :param image_folder_path: path to the image folder - - :param signal: signal that is emitted to chance splash when downloading image (optional) - - :param url_override: non default image url (optional) - """ - filename = get_image_file_name(name) - filepath = os.path.join(image_folder_path, filename) - image = QImage(filepath) - if image.isNull(): - if signal is not None: - signal.emit(f'Downloading Image: {name}') - image = download_image(self, name, image_folder_path, url_override) - return image - - -def download_image(self, name: str, image_folder_path: str, url_override: str = ''): - """ - Downloads image from wiki and stores it in images folder. Returns the image. - - Parameters: - - :param name: name of the item - - :param image_folder_path: path to the image folder - - :param url_override: non default image url (optional) - """ - filepath = os.path.join(image_folder_path, get_image_file_name(name)) - if url_override == '': - image_url = f'{WIKI_IMAGE_URL}{name.replace(' ', '_')}_icon.png' - else: - image_url = url_override - image_response = requests.get(image_url) - image = QImage() - if image_response.ok: - image.loadFromData(image_response.content, 'png') - image.save(filepath) - else: - self.cache.images_failed[name] = int(datetime.now().timestamp()) - return image + return None -def get_ship_image(self, image_name: str, threaded_worker): - """ - Tries to fetch ship image from local filesystem, downloads it otherwise. Returns the image. - - Parameters: - - :image_name: filename of the image - - :param threaded_worker: thread object supplying signals - """ - image_url = WIKI_IMAGE_URL + image_name.replace(' ', '_') - image_path = os.path.join( - self.config['config_subfolders']['ship_images'], quote_plus(image_name)) - _, _, fmt = image_name.rpartition('.') - image = QImage(image_path) - if image.isNull(): - image_response = requests.get(image_url) - if image_response.ok: - image.loadFromData(image_response.content, fmt) - image.save(image_path) - # else: returns null image - threaded_worker.result.emit((image,)) - - -def load_image(image_name: str, image: QImage, image_folder_path: str) -> QImage: - """ - Retrieves image from images folder and returns it. Assumes the image exists. - - Parameters: - - :param image_name: name of the image - - :param image: preconstructed (empty) Image - - :param image_folder_path: path to the image folder - """ - image_path = os.path.join(image_folder_path, get_image_file_name(image_name)) - image.load(image_path) - - -def image(self, image_name: str) -> QImage: - """ - Returns image from cache if cached, loads and returns image if not cached. - - Parameters: - - :param image_name: name of the image - """ - img = self.cache.images[image_name] - if img.isNull(): - img_folder = self.config['config_subfolders']['images'] - load_image(image_name, img, img_folder) - return img - - -def alt_image(self, image_name: str, image_suffix: str) -> QImage: - """ - Returns image from cache if cached, loads and returns image if not cached. If `image_suffix` is - not empty, tries to get alternate image first. - - Parameters: - - :param image_name: name of the image - - :param image_suffix: suffix to check in self.cache.alt_images - """ - if image_name + image_suffix in self.cache.alt_images: - return image(self, self.cache.alt_images[image_name + image_suffix]) - else: - return image(self, image_name) - - -def auto_backup_cargo_file(self, filename: str): - """ - Backs up given cargo data file to the auto backups folder - - Parameters: - - :param filename: name of the file to back up - """ - source_path = os.path.join(self.config['config_subfolders']['cargo'], filename) - if os.path.exists(source_path): - target_path = os.path.join(self.config['config_subfolders']['auto_backups'], filename) - shutil__copyfile(source_path, target_path) - - -# -------------------------------------------------------------------------------------------------- -# static functions -# -------------------------------------------------------------------------------------------------- - - -def get_downloaded_icons(images_dir: Path) -> set: - """ - Returns set containing all images currently in the images folder. - """ - return set(map(lambda x: unquote_plus(x)[:-4], os.listdir(str(images_dir)))) - - -def create_folder(path_to_folder): - """ - Creates the folder at path_to_folder in case it does not exist. - - Parameters: - - :param path_to_folder: absolute path to folder - """ - if not os.path.exists(path_to_folder) and not os.path.isdir(path_to_folder): - os.mkdir(path_to_folder) - - -def delete_folder_contents(path_to_folder): +def delete_folder_contents(path_to_folder: Path): """ Delets all files and folders within a folder. Parameters: - - :param path_to_folder: absolute path to folder + - :param path_to_folder: path to folder """ - if os.path.exists(path_to_folder) and os.path.isdir(path_to_folder): + if path_to_folder.is_dir(): shutil__rmtree(path_to_folder) - os.mkdir(path_to_folder) - - -def copy_file(source_path, target_path): - """ - Tries to copy file from `source_path` to `target_path` - - Parameters: - - :param source_path: file to copy - - :param target_path: location and name of the target file - """ - if os.path.exists(source_path) and os.path.isfile(source_path): - shutil__copyfile(source_path, target_path) + path_to_folder.mkdir(exist_ok=True) -def get_asset_path(asset_name: str, app_directory: str) -> str: - """ - returns the absolute path to a file in the asset folder - - Parameters: - - :param asset_name: filename of the asset - - :param app_directory: absolute path to app directory - """ - fp = os.path.join(app_directory, 'local', asset_name) - if os.path.exists(fp): - return fp - else: - return '' - - -def load_icon(filename: str, app_directory: str) -> QIcon: +def load_icon(filename: str, app_directory: Path, size: tuple = tuple()) -> QIcon | QPixmap: """ Loads icon from path and returns it. @@ -334,10 +68,13 @@ def load_icon(filename: str, app_directory: str) -> QIcon: - :param path: path to icon - :param app_directory: absolute path to the app directory """ - return QIcon(get_asset_path(filename, app_directory)) + icon = QIcon(str(app_directory / 'local' / filename)) + if len(size) == 2: + return icon.pixmap(*size) + return icon -def load_json__new(file_path: Path) -> dict | list | None: +def load_json(file_path: Path) -> dict | list | None: """ Loads json from path and returns dictionary or list. Returns `None` if no data could be found. @@ -351,115 +88,21 @@ def load_json__new(file_path: Path) -> dict | list | None: return None -def load_json(path: str) -> dict | list: - """ - Loads json from path and returns dictionary or list. - - Parameters: - - :param path: absolute path to json file - """ - if not (os.path.exists(path) and os.path.isfile(path) and os.path.isabs(path)): - raise FileNotFoundError(f'Invalid / not absolute path: {path}') - with open(path, 'r', encoding='utf-8') as file: - data = json.load(file) - return data - - -def store_json(data: dict | list, path: str): +def store_json(data: dict | list, path: Path) -> bool: """ Stores data to json file at path. Overwrites file at target location. Raises ValueError if path - is not absolute. + is not absolute. Returns `False` if file could not be saved, `True` otherwise. Paramters: - :param data: dictionary or list that should be stored - - :param path: target location; must be absolute path - """ - if not os.path.isabs(path): - raise ValueError(f'Path to file must be absolute: {path}') - try: - with open(path, 'w') as file: - json.dump(data, file) - except OSError as e: - sys.stdout.write(f'[Error] Data could not be saved: {e}') - - -def fetch_json(url: str) -> dict | list: - """ - Fetches json from url and returns parsed object. Raises `requests.exceptions.JSONDecodeError` if - result cannot be decoded. Raises `requests.exceptions.Timeout` or 2 download attempts failed. - - Parameters: - - :param url: URL to file + - :param path: file path to store the data to """ try: - r = requests.get(url, timeout=10) - except requests.exceptions.Timeout: - r = requests.get(url, timeout=10) - r.encoding = 'utf-8' - return json.loads(compensate_json(r.text)) - - -def fetch_html(url: str): - """ - Fetches html from url and returns plain text. Raises requests.exceptions.Timeout if - 2 download attempts failed. - - Parameters: - - :param url: URL to file - """ - session = HTMLSession() - r = session.get(url) - return r.html - - -def sanitize_file_name(txt, chr_set='extended') -> str: - """ - Converts txt to a valid filename. - - Parameters: - - :param txt: The path to convert. - - :param chr_set: - - 'printable': Any printable character except those disallowed on Windows/*nix. - - 'extended': 'printable' + extended ASCII character codes 128-255 - - 'universal': For almost *any* file system. - """ - FILLER = '-' - MAX_LEN = 255 # Maximum length of filename is 255 bytes in Windows and some *nix flavors. - - # Step 1: Remove excluded characters. - BLACK_LIST = set(chr(127) + r'<>:"/\|?*') - white_lists = { - 'universal': {'-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'}, - 'printable': {chr(x) for x in range(32, 127)} - BLACK_LIST, # 0-32, 127 are unprintable, - 'extended': {chr(x) for x in range(32, 256)} - BLACK_LIST, - } - white_list = white_lists[chr_set] - result = ''.join(x if x in white_list else FILLER for x in txt) - - # Step 2: Device names, '.', and '..' are invalid filenames in Windows. - DEVICE_NAMES = ( - 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', - 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', - 'LPT9', 'CONIN$', 'CONOUT$', '..', '.') - if '.' in txt: - name, _, ext = result.rpartition('.') - ext = f'.{ext}' - else: - name = result - ext = '' - if name in DEVICE_NAMES: - result = f'-{result}-{ext}' - - # Step 3: Truncate long files while preserving the file extension. - if len(result) > MAX_LEN: - result = result[:MAX_LEN - len(ext)] + ext - - # Step 4: Windows does not allow filenames to end with '.' or ' ' or begin with ' '. - result = result.strip() - while len(result) > 0 and result[-1] == '.': - result = result[:-1] - - return result + with path.open('w') as file: + json__dump(data, file) + return True + except OSError: + return False def get_image_file_name(name: str) -> str: @@ -481,119 +124,3 @@ def open_wiki_page(page_name: str): Converts page name to URL and opens page in webbrowser. """ open_url(WIKI_URL + page_name.replace(' ', '_')) - - -def read_env_file(path: Path, names: list[str]) -> dict[str, str]: - """ - Reads given `names` from env file at `path` and returns dictionary containing them. - - Parameters: - - :param path: path to env file - - :param names: variables to read from env file - """ - env_variables = dict() - if not path.exists(): - return env_variables - with path.open(encoding='utf-8') as env_file: - for line in env_file: - for identifier in names: - if line.startswith(f'{identifier}='): - if line[-1] == '\n': - env_variables[identifier] = line[len(identifier) + 1:-1] - else: - env_variables[identifier] = line[len(identifier) + 1:] - break - return env_variables - - -def cache_cargo_data(cache_file: Path, url: str, session: requests.Session) -> bool: - """ - Obtains cargo data from `url` and stores it. Returns `True` on success, `False` on failure. - - Parameters: - - :param cache_file: path to file that the cargo data should be stored to - - :param url: url to request data from - - :param session: request session to use for the request - """ - try: - response = session.get(url, timeout=10) - except requests.exceptions.Timeout: - sys.stdout.write(f'[Error] Requesting the following URL timed out:\n[Error] {url}\n') - return False - if response.ok: - response.encoding = 'utf-8' - try: - cargo_data = json.loads(compensate_json(response.text)) - store_json(cargo_data, str(cache_file)) - return True - except json.JSONDecodeError: - sys.stdout.write( - f'[Error] Decoding the response failed for the following URL:\n[Error] {url}\n') - return False - - -def download_image_session( - session: requests.Session, name: str, image_folder_path: Path, - failed_images: dict[str, int], image_suffix: str = '_icon.png'): - """ - """ - if image_suffix == '': - # exception for ship images - filepath = image_folder_path / quote_plus(name) - image_type = None - else: - filepath = image_folder_path / get_image_file_name(name) - image_type = 'png' - image_url = WIKI_IMAGE_URL + name.replace(' ', '_') + image_suffix - image_response = session.get(image_url) - image = QImage() - if image_response.ok: - image.loadFromData(image_response.content, image_type) - image.save(str(filepath)) - else: - failed_images[name] = int(datetime.now().timestamp()) - - -def download_images_list( - images_list: list[str], env_variables: dict[str, str], images_path: Path, - image_suffix: str = '_icon.png') -> dict[str, int]: - """ - """ - requests_session = requests.Session() - if 'SETS_CF_CLEARANCE' in env_variables: - requests_session.cookies.set_cookie( - requests__create_cookie(name='cf_clearance', value=env_variables['SETS_CF_CLEARANCE'])) - if 'SETS_USER_AGENT' in env_variables: - requests_session.headers['User-Agent'] = env_variables['SETS_USER_AGENT'] - failed_images = dict() - for image_name in images_list: - download_image_session( - requests_session, image_name, images_path, failed_images, image_suffix) - return failed_images - - -def download_images_fast( - images_list: list[str], env_variables: dict[str, str], images_dir: Path, - image_suffix: str = '_icon.png'): - """ - Downloads images using multiple threads. - """ - total_threads = 16 - image_chunk_size = len(images_list) // total_threads - while image_chunk_size < 4 and total_threads > 1: - total_threads -= 1 - image_chunk_size = len(images_list) // total_threads - threads: list[ReturnValueThread] = list() - for thread_num in range(total_threads): - if thread_num == total_threads - 1: - images = images_list[image_chunk_size * thread_num:] - else: - images = images_list[image_chunk_size * thread_num:image_chunk_size * (thread_num + 1)] - thread = ReturnValueThread( - target=download_images_list, args=(images, env_variables, images_dir, image_suffix)) - thread.start() - threads.append(thread) - failed_images = dict() - for thread in threads: - failed_images.update(thread.join()) - print(failed_images) diff --git a/src/subwindows.py b/src/picker.py similarity index 61% rename from src/subwindows.py rename to src/picker.py index 2969882..f087107 100644 --- a/src/subwindows.py +++ b/src/picker.py @@ -1,24 +1,39 @@ -from typing import Callable, Iterable, Iterator - -from PySide6.QtCore import QPoint, QSortFilterProxyModel, QStringListModel, Qt -from PySide6.QtGui import QMouseEvent, QTextOption -from PySide6.QtWidgets import QAbstractItemView, QDialog, QListView, QPlainTextEdit - -from .constants import AHCENTER, ALEFT, ATOP, MARKS, RARITIES, SMAXMAX, SMINMAX, SMINMIN -from .iofunc import alt_image +from typing import Iterable, Iterator + +from PySide6.QtCore import ( + QModelIndex, QPoint, QSortFilterProxyModel, QStringListModel, Qt, Signal, Slot) +from PySide6.QtGui import QMouseEvent +from PySide6.QtWidgets import ( + QAbstractItemView, QComboBox, QDialog, QFrame, QLabel, QListView, QWidget) + +from .config import SETSSettings +from .constants import AHCENTER, ALEFT, MARKS, RARITIES, SMAXMAX, SMINMAX, SMINMIN +from .imagemanager import ImageManager +from .theme import AppTheme from .widgetbuilder import ( - create_button, create_button_series, create_combo_box, create_entry, create_frame, - create_item_button, create_label) -from .widgets import GridLayout, HBoxLayout, VBoxLayout -from .style import get_style, get_style_class, theme_font + create_button2, create_combo_box2, create_entry2, create_frame2, create_item_button2, + create_label2) +from .widgets import GridLayout, HBoxLayout, ItemButton, ItemSlot, VBoxLayout class BasePicker(QDialog): """ Base class of SETS item picker / editor housing shared methods. """ + + dialog_result: Signal = Signal(dict, ItemSlot) + + def __init__(self, parent: QWidget): + super().__init__(parent=parent) + self._item: dict[str, str | list[str]] = self.empty_item + self._slot: ItemSlot | None = None + self._modifiers: dict[str, dict[str]] = {} + self._mod_combos: list[QComboBox | None] = [None] * 5 + self._mark_combo: QComboBox + self._rarity_combo: QComboBox + @property - def empty_item(self): + def empty_item(self) -> dict[str, str | list[str]]: return { 'item': '', 'rarity': 'Common', @@ -26,7 +41,7 @@ def empty_item(self): 'modifiers': [''] * 5 } - def insert_modifiers(self, modifiers: dict = {}): + def insert_modifiers(self, modifiers: dict[str] = {}): """ Inserts the modifiers into the comboboxes """ @@ -42,7 +57,7 @@ def insert_modifiers(self, modifiers: dict = {}): self._mod_combos[4].clear() self._mod_combos[4].addItems(self.epic_mods(modifiers)) - def unique_mods(self, modifiers: dict = {}) -> Iterator[str]: + def unique_mods(self, modifiers: dict[str] = {}) -> Iterator[str]: """ yields mods for first mod slot from modifier dict """ @@ -51,7 +66,7 @@ def unique_mods(self, modifiers: dict = {}) -> Iterator[str]: if not details['epic']: yield mod - def standard_mods(self, modifiers: dict = {}) -> Iterator[str]: + def standard_mods(self, modifiers: dict[str] = {}) -> Iterator[str]: """ yields mods for second to fourth mod slot from modifier list """ @@ -60,7 +75,7 @@ def standard_mods(self, modifiers: dict = {}) -> Iterator[str]: if not details['epic'] and not details['isunique']: yield mod - def not_epic_mods(self, modifiers: dict = {}) -> Iterator[str]: + def not_epic_mods(self, modifiers: dict[str] = {}) -> Iterator[str]: """ yields mods for first to fourth mod slot from modifier list """ @@ -69,7 +84,7 @@ def not_epic_mods(self, modifiers: dict = {}) -> Iterator[str]: if not details['epic']: yield mod - def epic_mods(self, modifiers: dict = {}) -> Iterator[str]: + def epic_mods(self, modifiers: dict[str] = {}) -> Iterator[str]: """ yields mods for fifth mod slot from modifier list """ @@ -112,47 +127,52 @@ class Picker(BasePicker): Picker Window """ def __init__( - self, sets, parent_window, style: str = 'picker', - default_rarity_getter: Callable = lambda: 'Common', - default_mark_getter: Callable = lambda: ''): + self, theme: AppTheme, parent_window: QWidget, settings: SETSSettings, + images: ImageManager, style: str = 'picker'): super().__init__(parent=parent_window) - self.start_pos = None + self._settings: SETSSettings = settings + self._images: ImageManager = images + self.start_pos: QPoint | None = None + self._image_suffix: str = '' + self._item_button: ItemButton + self._item_label: QLabel + self._prop_frame: QFrame + self._item_model: QStringListModel + self._sort_model: QSortFilterProxyModel + self._items_list: QListView + self.finished.connect(self.finish_pick) + self.setWindowFlags( - self.windowFlags() | Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) - self.setStyleSheet(get_style(sets, style)) + self.windowFlags() | Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setStyleSheet(theme.get_style(style)) self.setWindowModality(Qt.WindowModality.WindowModal) self.setMinimumSize(10, 10) self.setSizePolicy(SMAXMAX) - self._sets = sets - self._item = self.empty_item - self._result = None - self._modifiers = {} - self._image_suffix = '' - ui_scale = sets.config['ui_scale'] - spacing = sets.theme['defaults']['isp'] * ui_scale + ui_scale = theme.scale + spacing = theme['defaults']['isp'] * ui_scale layout = VBoxLayout(margins=(spacing, 0, spacing, spacing), spacing=0) top_layout = HBoxLayout(spacing=spacing) button_layout = VBoxLayout(margins=(0, spacing, 0, spacing)) - button_frame = create_frame(sets, style_override={'background': 'none'}) - self._item_button = create_item_button(sets) + button_frame = create_frame2(theme, style_override={'background': 'none'}) + self._item_button = create_item_button2(theme) button_layout.addWidget(self._item_button) button_frame.setLayout(button_layout) top_layout.addWidget(button_frame, alignment=ALEFT) - self._item_label = create_label( - sets, '', 'label_subhead', style_override={'margin-bottom': 0}) + self._item_label = create_label2( + theme, '', 'label_subhead', style_override={'margin-bottom': 0}) self._item_label.setWordWrap(True) self._item_label.setSizePolicy(SMINMAX) top_layout.addWidget(self._item_label, stretch=1) layout.addLayout(top_layout) - self._prop_frame = create_frame(sets, size_policy=SMINMAX) - csp = sets.theme['defaults']['csp'] * ui_scale + self._prop_frame = create_frame2(theme, size_policy=SMINMAX) + csp = theme['defaults']['csp'] * ui_scale prop_layout = VBoxLayout(spacing=csp) rarity_layout = HBoxLayout(spacing=csp) - self._mark_combo = create_combo_box(sets) + self._mark_combo = create_combo_box2(theme) self._mark_combo.addItems(('', *MARKS)) self._mark_combo.currentTextChanged.connect(self.mark_callback) rarity_layout.addWidget(self._mark_combo, 1) - self._rarity_combo = create_combo_box(sets) + self._rarity_combo = create_combo_box2(theme) self._rarity_combo.addItems(RARITIES.keys()) self._rarity_combo.currentTextChanged.connect(self.rarity_callback) rarity_layout.addWidget(self._rarity_combo, 1) @@ -160,66 +180,63 @@ def __init__( mod_layout = GridLayout(spacing=csp) self._mod_combos = [None] * 5 for i in range(4): - mod_combo = create_combo_box(sets, style_override={'font': '@font'}, editable=True) + mod_combo = create_combo_box2(theme, style_override={'font': '@font'}, editable=True) mod_combo.currentIndexChanged.connect(lambda mod, i=i: self.modifier_callback(mod, i)) self._mod_combos[i] = mod_combo mod_layout.addWidget(mod_combo, i // 2, i % 2) - mod_combo = create_combo_box(sets, style_override={'font': '@font'}, editable=True) + mod_combo = create_combo_box2(theme, style_override={'font': '@font'}, editable=True) mod_combo.currentIndexChanged.connect(lambda mod: self.modifier_callback(mod, 4)) self._mod_combos[4] = mod_combo mod_layout.addWidget(mod_combo, 2, 0, 1, 2) prop_layout.addLayout(mod_layout) - spacer_1 = create_frame(sets) + spacer_1 = create_frame2(theme) spacer_1.setFixedHeight(spacing - csp) prop_layout.addWidget(spacer_1) self._prop_frame.setLayout(prop_layout) layout.addWidget(self._prop_frame) - seperator = create_frame(sets, size_policy=SMINMAX, style_override={ - 'background-color': '@lbg', 'margin': '@isp'}) - seperator.setFixedHeight(sets.theme['defaults']['sep'] * ui_scale) + seperator = create_frame2(theme, size_policy=SMINMAX, style_override={ + 'background-color': '@lbg', 'margin': '@isp'}) + seperator.setFixedHeight(theme['defaults']['sep'] * ui_scale) layout.addWidget(seperator) - spacer_2 = create_frame(sets) + spacer_2 = create_frame2(theme) spacer_2.setFixedHeight(spacing) layout.addWidget(spacer_2) self._item_model = QStringListModel() self._sort_model = QSortFilterProxyModel() self._sort_model.setSourceModel(self._item_model) self._sort_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self._search_bar = create_entry(sets, placeholder='Search') + self._search_bar = create_entry2(theme, placeholder='Search') self._search_bar.textChanged.connect( - lambda new_text: self._sort_model.setFilterFixedString(new_text)) + lambda new_text: self._sort_model.setFilterFixedString(new_text)) self._search_bar.setSizePolicy(SMINMAX) layout.addWidget(self._search_bar) - spacer_3 = create_frame(sets) + spacer_3 = create_frame2(theme) spacer_3.setFixedHeight(spacing) layout.addWidget(spacer_3) self._items_list = QListView() - self._items_list.setStyleSheet(get_style_class(sets, 'QListView', 'picker_list')) + self._items_list.setStyleSheet(theme.get_style_class('QListView', 'picker_list')) self._items_list.setSizePolicy(SMINMIN) self._items_list.setModel(self._sort_model) self._items_list.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self._items_list.clicked.connect(self.slot_item) self._items_list.doubleClicked.connect(self.select_item) layout.addWidget(self._items_list) - spacer_4 = create_frame(sets) + spacer_4 = create_frame2(theme) spacer_4.setFixedHeight(spacing) layout.addWidget(spacer_4) control_layout = HBoxLayout(spacing=csp) - cancel_button = create_button(sets, 'Cancel') + cancel_button = create_button2(theme, 'Cancel') cancel_button.setSizePolicy(SMINMAX) cancel_button.clicked.connect(self.reject) control_layout.addWidget(cancel_button) - save_button = create_button(sets, 'Save') + save_button = create_button2(theme, 'Save') save_button.clicked.connect(self.accept) save_button.setSizePolicy(SMINMAX) control_layout.addWidget(save_button) layout.addLayout(control_layout) self.setLayout(layout) - self._get_default_rarity = default_rarity_getter - self._get_default_mark = default_mark_getter - - def slot_item(self, new_index): + def slot_item(self, new_index: QModelIndex): """ called when item is clicked """ @@ -228,11 +245,11 @@ def slot_item(self, new_index): self._item_label.setText(new_item) if new_item.endswith('I'): new_item, _, _ = new_item.rpartition(' ') - self._item_button.set_item(alt_image(self._sets, new_item, self._image_suffix)) + self._item_button.set_item(self._images.get_alt(new_item, self._image_suffix)) for i in range(5): self._mod_combos[i].setCurrentText('') - def select_item(self, new_index): + def select_item(self, new_index: QModelIndex): """ shortcut for selecting item and pressing ok """ @@ -241,10 +258,17 @@ def select_item(self, new_index): self.accept() def pick_item( - self, items: Iterable, button_pos: QPoint | None, equipment: bool = False, - modifiers: dict = {}, image_suffix: str = ''): + self, items: Iterable[str], button_pos: QPoint | None, slot: ItemSlot, + modifiers: dict[str, dict[str]] = {}, image_suffix: str = ''): """ - Executes picker, returns selected item. Returns None when picker is closed without saving. + Shows picker window. Returns immediately. + + Parameters: + - :param items: collection of items to select from + - :param button_pos: positions picker next to this position if not `None` + - :param slot: information about the slot + - :param modifiers: collection of modifiers + - :param image_suffix: suffix containing environment and type to check for alternative icon """ window = self.parentWidget() if button_pos is None: @@ -259,29 +283,43 @@ def pick_item( ) window_position = (button_pos.x() - window_size[0] * 1.05, button_pos.y()) self._result = None + self._slot = slot self.setFixedSize(*window_size) self.move(*window_position) self._item_model.setStringList(items) self._item_label.setMinimumWidth(window_size[0] * 0.75) self._sort_model.sort(0, Qt.SortOrder.AscendingOrder) self._items_list.scrollToTop() - if equipment: + if slot.is_equipment: self.insert_modifiers(modifiers) - self._mark_combo.setCurrentText(self._get_default_mark()) - self._rarity_combo.setCurrentText(self._get_default_rarity()) + self._mark_combo.setCurrentText(self._settings.default_mark) + self._rarity_combo.setCurrentText(self._settings.default_rarity) self._prop_frame.show() else: self._prop_frame.hide() self._image_suffix = image_suffix self._search_bar.setFocus() - action = self.exec() + self.open() + + @Slot(int) + def finish_pick(self, action: int): + """ + Completes the pick action, resets the dialog and emits the data using the `dialog_result` + signal. + + Parameters: + - :param action: indicates whether the result should be saved (`1`) or not (`0`) + """ + slot = self._slot if action == 1 and self._item['item'] != '': - self._result = { + picked_item = { 'item': self._item['item'], 'rarity': self._item['rarity'], 'mark': self._item['mark'], 'modifiers': [mod for mod in self._item['modifiers']] } + else: + picked_item = self.empty_item self._item_button.clear() self._search_bar.clear() self._item_label.setText('') @@ -291,12 +329,14 @@ def pick_item( for mod_combo in self._mod_combos: mod_combo.setCurrentText('') self._item = self.empty_item - return self._result + self._slot = None + self.dialog_result.emit(picked_item, slot) def mousePressEvent(self, event: QMouseEvent): pr = self._prop_frame.rect() pr.moveTopLeft(self._prop_frame.pos()) if pr.contains(event.pos()): + # allowing window move to start here can cause accidental clicks on comboboxes self.start_pos = None else: self.start_pos = event.globalPosition().toPoint() @@ -313,49 +353,50 @@ def mouseMoveEvent(self, event: QMouseEvent): class ShipSelector(QDialog): - """ - Selection Window for ships - """ - def __init__(self, sets, parent_window, style: str = 'picker'): + """Selection Window for ships""" + + dialog_result: Signal = Signal(str) + + def __init__(self, theme: AppTheme, parent_window: QWidget, style: str = 'picker'): super().__init__(parent=parent_window) self.setWindowFlags( - self.windowFlags() | Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) - self.setStyleSheet(get_style(sets, style)) + self.windowFlags() | Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setStyleSheet(theme.get_style(style)) self.setWindowModality(Qt.WindowModality.WindowModal) self.setMinimumSize(10, 10) self.setSizePolicy(SMAXMAX) + self.finished.connect(self.finish_pick) - ui_scale = sets.config['ui_scale'] - spacing = sets.theme['defaults']['isp'] * ui_scale + ui_scale = theme.scale + spacing = theme['defaults']['isp'] * ui_scale layout = VBoxLayout(margins=spacing, spacing=spacing) self._ship_data_model = QStringListModel() sort_model = QSortFilterProxyModel() sort_model.setSourceModel(self._ship_data_model) sort_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - heading = create_label(sets, 'Select Ship', 'label_heading') + heading = create_label2(theme, 'Select Ship', 'label_heading') layout.addWidget(heading, alignment=AHCENTER) - self._search_bar = create_entry(sets, placeholder='Search') + self._search_bar = create_entry2(theme, placeholder='Search') self._search_bar.textChanged.connect( - lambda new_text: sort_model.setFilterFixedString(new_text)) + lambda new_text: sort_model.setFilterFixedString(new_text)) self._search_bar.setSizePolicy(SMINMAX) layout.addWidget(self._search_bar) self._ship_list = QListView() - self._ship_list.setStyleSheet(get_style_class( - sets, 'QListView', 'picker_list', - override={'::item:selected': {'border-color': '@sets'}})) + self._ship_list.setStyleSheet(theme.get_style_class( + 'QListView', 'picker_list', override={'::item:selected': {'border-color': '@sets'}})) self._ship_list.setSizePolicy(SMINMIN) self._ship_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self._ship_list.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self._ship_list.setModel(sort_model) self._ship_list.doubleClicked.connect(self.accept) layout.addWidget(self._ship_list) - csp = sets.theme['defaults']['csp'] * ui_scale + csp = theme['defaults']['csp'] * ui_scale control_layout = HBoxLayout(spacing=csp) - cancel_button = create_button(sets, 'Cancel') + cancel_button = create_button2(theme, 'Cancel') cancel_button.setSizePolicy(SMINMAX) cancel_button.clicked.connect(self.reject) control_layout.addWidget(cancel_button) - save_button = create_button(sets, 'Save') + save_button = create_button2(theme, 'Save') save_button.clicked.connect(self.accept) save_button.setSizePolicy(SMINMAX) control_layout.addWidget(save_button) @@ -365,9 +406,10 @@ def __init__(self, sets, parent_window, style: str = 'picker'): def set_ships(self, ships: Iterable): self._ship_data_model.setStringList(ships) + @Slot() def pick_ship(self): """ - Executes Picker, returns selected ship, returns None when cancelled. + Shows picker window. """ window = self.parentWidget() size = (window.width() * 0.2, window.height() * 0.9) @@ -375,13 +417,22 @@ def pick_ship(self): self.setFixedSize(*size) self.move(*pos) self._ship_list.scrollToTop() - action = self.exec() + self.open() + + @Slot(int) + def finish_pick(self, action: int): + """ + Completes the ship pick action, resets the dialog and emits the data using the + `dialog_result` signal. + + Parameters: + - :param action: indicates whether the result should be saved (`1`) or not (`0`) + """ self._search_bar.clear() + ship_name = '' if action == 1: ship_name = self._ship_list.currentIndex().data(Qt.ItemDataRole.DisplayRole) - if ship_name != '': - return ship_name - return None + self.dialog_result.emit(ship_name) def mousePressEvent(self, event: QMouseEvent): self.start_pos = event.globalPosition().toPoint() @@ -398,73 +449,68 @@ class ItemEditor(BasePicker): """ Dialog to edit mark, rarity and mods of equipment items. """ - def __init__(self, sets, parent_window, style: str = 'picker'): - """ - Dialog to edit mark, rarity and mods of equipment items. - - Parameters: - - :param sets: SETS object - - :param parent_window: parent window of dialog - - :param style: style key for sets.theme - """ + def __init__(self, theme: AppTheme, parent_window: QWidget, style: str = 'picker'): super().__init__(parent=parent_window) + self.finished.connect(self.finish_edit) self.setWindowFlags( - self.windowFlags() | Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) - self.setStyleSheet(get_style(sets, style)) + self.windowFlags() | Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setStyleSheet(theme.get_style(style)) self.setWindowModality(Qt.WindowModality.WindowModal) self.setMinimumSize(10, 10) self.setSizePolicy(SMAXMAX) - self._item = self.empty_item - self._result = None - self._modifiers = {} - ui_scale = sets.config['ui_scale'] - csp = sets.theme['defaults']['csp'] * ui_scale + ui_scale = theme.scale + csp = theme['defaults']['csp'] * ui_scale layout = VBoxLayout(spacing=csp) rarity_layout = HBoxLayout(spacing=csp) - self._mark_combo = create_combo_box(sets) + self._mark_combo = create_combo_box2(theme) self._mark_combo.addItems(('', *MARKS)) self._mark_combo.currentTextChanged.connect(self.mark_callback) rarity_layout.addWidget(self._mark_combo, 1) - self._rarity_combo = create_combo_box(sets) + self._rarity_combo = create_combo_box2(theme) self._rarity_combo.addItems(RARITIES.keys()) self._rarity_combo.currentTextChanged.connect(self.rarity_callback) rarity_layout.addWidget(self._rarity_combo, 1) layout.addLayout(rarity_layout) mod_layout = GridLayout(spacing=csp) - self._mod_combos = [None] * 5 for i in range(4): - mod_combo = create_combo_box( - sets, style_override={'font': '@font'}, editable=True, size_policy=SMINMAX) + mod_combo = create_combo_box2( + theme, style_override={'font': '@font'}, editable=True, size_policy=SMINMAX) mod_combo.currentIndexChanged.connect(lambda mod, i=i: self.modifier_callback(mod, i)) self._mod_combos[i] = mod_combo mod_layout.addWidget(mod_combo, i // 2, i % 2) - mod_combo = create_combo_box(sets, style_override={'font': '@font'}, editable=True) + mod_combo = create_combo_box2(theme, style_override={'font': '@font'}, editable=True) mod_combo.currentIndexChanged.connect(lambda mod: self.modifier_callback(mod, 4)) self._mod_combos[4] = mod_combo mod_layout.addWidget(mod_combo, 2, 0, 1, 2) layout.addLayout(mod_layout) control_layout = HBoxLayout(spacing=csp) - cancel_button = create_button(sets, 'Cancel') + cancel_button = create_button2(theme, 'Cancel') cancel_button.setSizePolicy(SMINMAX) cancel_button.clicked.connect(self.reject) control_layout.addWidget(cancel_button) - save_button = create_button(sets, 'Save') + save_button = create_button2(theme, 'Save') save_button.clicked.connect(self.accept) save_button.setSizePolicy(SMINMAX) control_layout.addWidget(save_button) layout.addLayout(control_layout) - content_frame = create_frame(sets, size_policy=SMINMIN) + content_frame = create_frame2(theme, size_policy=SMINMIN) content_frame.setLayout(layout) - margin = sets.theme['defaults']['isp'] * ui_scale + margin = theme['defaults']['isp'] * ui_scale main_layout = VBoxLayout(margins=margin) main_layout.addWidget(content_frame) self.setLayout(main_layout) - def edit_item(self, item: dict, modifiers: dict): + @Slot(dict, dict, ItemSlot) + def edit_item(self, item: dict[str], modifiers: dict[str, dict[str]], slot: ItemSlot): """ - Executes editor, returns edited item. Returns None when editor is closed without saving. + Shows editor window. Returns immediately. + + Parameters: + - :param item: item data for the item that should be edited + - :param modifiers: collection of available modifiers + - :param slot: information about the slot """ - self._result = None + self._slot = slot self.insert_modifiers(modifiers) self._mark_combo.setCurrentText(item['mark']) self._rarity_combo.setCurrentText(item['rarity']) @@ -476,91 +522,31 @@ def edit_item(self, item: dict, modifiers: dict): 'mark': item['mark'], 'modifiers': [mod for mod in item['modifiers']] } - action = self.exec() + self.open() + + @Slot(int) + def finish_edit(self, action: int): + """ + Completes the edit action, resets the dialog and emits the data using the `dialog_result` + signal. + + Parameters: + - :param action: indicates whether the result should be saved (`1`) or not (`0`) + """ + slot = self._slot if action == 1: - self._result = { + edited_item = { 'item': self._item['item'], 'rarity': self._item['rarity'], 'mark': self._item['mark'], 'modifiers': [mod for mod in self._item['modifiers']] } + else: + edited_item = self.empty_item self._mark_combo.setCurrentText('') self._rarity_combo.setCurrentText('Common') for mod_combo in self._mod_combos: mod_combo.setCurrentText('') self._item = self.empty_item - return self._result - - -class ExportWindow(QDialog): - """ - Holds Export Window - """ - def __init__(self, sets, parent_window, data_getter: Callable): - super().__init__(parent=parent_window) - thick = sets.theme['app']['frame_thickness'] * sets.config['ui_scale'] - dialog_layout = VBoxLayout(margins=thick) - main_frame = create_frame(sets, size_policy=SMINMIN) - dialog_layout.addWidget(main_frame) - main_layout = VBoxLayout(margins=thick, spacing=thick) - content_frame = create_frame(sets, size_policy=SMINMIN) - content_layout = VBoxLayout(spacing=thick) - content_layout.setAlignment(ATOP) - - header_label = create_label(sets, 'Markdown Export:', 'label_heading') - content_layout.addWidget(header_label, alignment=ALEFT) - md_textedit = QPlainTextEdit() - button_def = { - 'default': {'margin-top': 0}, - 'Space Build': { - 'callback': lambda: md_textedit.setPlainText(data_getter('space', 'build')) - }, - 'Ground Build': { - 'callback': lambda: md_textedit.setPlainText(data_getter('ground', 'build')) - }, - 'Space Skills': { - 'callback': lambda: md_textedit.setPlainText(data_getter('space', 'skills')) - }, - 'Ground Skills': { - 'callback': lambda: md_textedit.setPlainText(data_getter('ground', 'skills')) - }, - } - top_buttons, (self._space_button, *_) = create_button_series(sets, button_def, ret=True) - top_buttons.setAlignment(AHCENTER) - content_layout.addLayout(top_buttons) - md_textedit.setSizePolicy(SMINMIN) - md_textedit.setStyleSheet(get_style_class(sets, 'QPlainTextEdit', 'textedit')) - md_textedit.setFont(theme_font(sets, 'textedit')) - md_textedit.setWordWrapMode(QTextOption.WrapMode.NoWrap) - content_layout.addWidget(md_textedit, stretch=1) - content_frame.setLayout(content_layout) - main_layout.addWidget(content_frame, stretch=1) - - seperator = create_frame(sets, style='light_frame', size_policy=SMINMAX) - seperator.setFixedHeight(1) - main_layout.addWidget(seperator) - footer_button_def = { - 'Copy': {'callback': lambda: sets.app.clipboard().setText(md_textedit.toPlainText())}, - 'Close': {'callback': lambda: self.done(0)} - } - footer_buttons = create_button_series(sets, footer_button_def) - footer_buttons.setAlignment(AHCENTER) - main_layout.addLayout(footer_buttons) - main_frame.setLayout(main_layout) - - self.setLayout(dialog_layout) - self.setWindowTitle('SETS - Markdown Export') - self.setStyleSheet(get_style(sets, 'dialog_window')) - - def invoke(self): - """ - Shows Export Window. - """ - window_rect = self.parent().geometry() - self.setGeometry( - window_rect.x() + window_rect.width() * 0.25, - window_rect.y() + window_rect.height() * 0.25, - window_rect.width() * 0.5, - window_rect.height() * 0.5) - self._space_button.click() - self.exec() + self._slot = None + self.dialog_result.emit(edited_item, slot) diff --git a/src/splash.py b/src/splash.py index 1cf087e..4898882 100644 --- a/src/splash.py +++ b/src/splash.py @@ -1,23 +1,123 @@ -def enter_splash(self): - """ - Shows splash screen - """ - self.widgets.loading_label.setText('Loading: ...') - self.widgets.splash_tabber.setCurrentIndex(1) +from PySide6.QtCore import QObject, Signal, Slot +from PySide6.QtWidgets import QLabel, QTabWidget -def exit_splash(self): - """ - Leaves splash screen - """ - self.widgets.splash_tabber.setCurrentIndex(0) +class SplashScreen(QObject): + """Manages splash screen""" + show: Signal = Signal(bool) + loading_text: Signal = Signal(str) + progress_visible: Signal = Signal(bool) + progress_init: Signal = Signal(int) + progress_step: Signal = Signal() -def splash_text(self, new_text: str): - """ - Updates the label of the splash screen with new text + def __init__(self): + super().__init__() + self.loading_label: QLabel + self.progress_label: QLabel + self.tabber: QTabWidget + self._progress_total: int = 0 + self._progress_current: int = 0 + self.show.connect(self._show_splash) + self.loading_text.connect(self._set_loading_text) + self.progress_visible.connect(self._show_progress) + self.progress_init.connect(self._init_progress) + self.progress_step.connect(self._increment_progress) - Parameters: - - :param new_text: will be displayed on the splsh screen - """ - self.widgets.loading_label.setText(new_text) + def show_splash(self, visible: bool): + """ + Shows/hides splash. + + Parameters: + - :param visible: `True` to show splash, `False` to hide it + """ + self.show.emit(visible) + + def init_progress(self, total_progress: int): + """ + Makes progress label ready. + + Parameters: + - :param total_progress: total number of steps + """ + self.progress_init.emit(total_progress) + + def increment_progress(self): + """ + Increments progress count by 1. + """ + self.progress_step.emit() + + def show_progress(self, visible: bool): + """ + Shows/hides progress label. + + Parameters: + - :param visible: `True` to show progress label, `False` to hide it + """ + self.progress_visible.emit(visible) + + def set_loading_text(self, message: str): + """ + Sets loading labels' text. + + Parameters: + - :param message: message to show + """ + self.loading_text.emit(message) + + @Slot(bool) + def _show_splash(self, visible: bool): + """ + Shows/hides splash. + + Parameters: + - :param visible: `True` to show splash, `False` to hide it + """ + if visible: + self.tabber.setCurrentIndex(1) + else: + self.tabber.setCurrentIndex(0) + + @Slot(int) + def _init_progress(self, total_progress: int): + """ + Makes progress label ready. + + Parameters: + - :param total_progress: total number of steps + """ + self._progress_total = total_progress + self._progress_current = 0 + self.progress_label.setText( + f'({self._progress_current:>4}/{self._progress_total:>4})') + self.progress_label.show() + + @Slot() + def _increment_progress(self): + """ + Increments progress count by 1. + """ + self._progress_current += 1 + self.progress_label.setText( + f'({self._progress_current:>4}/{self._progress_total:>4})') + + @Slot(bool) + def _show_progress(self, visible: bool): + """ + Shows/hides progress label. + + Parameters: + - :param visible: `True` to show progress label, `False` to hide it + """ + self.progress_label.setVisible(visible) + + @Slot(str) + def _set_loading_text(self, message: str): + """ + Sets loading labels' text. + + Parameters: + - :param message: message to show + """ + self.loading_label.setText(message) diff --git a/src/style.py b/src/style.py deleted file mode 100644 index adb4696..0000000 --- a/src/style.py +++ /dev/null @@ -1,174 +0,0 @@ -import copy - -from PySide6.QtGui import QFont - -WEIGHT_CONVERSION = { - 'normal': QFont.Weight.Normal, - 'bold': QFont.Weight.Bold, - 'extrabold': QFont.Weight.ExtraBold, - 'medium': QFont.Weight.Medium -} - - -def get_style(self, widget, override: dict = {}) -> str: - """ - Returns style sheet according to default style of widget with override style. - - Parameters: - - :param widget: None or str -> name of the widget style in self.theme (may be empty or None if - only the style in override should be applied) - - :param override: dict -> contains additional style (optional) - - :return: str containing css style sheet - """ - if widget is None or widget == '': - return get_css(self, override) - elif widget != 'app' and widget != 'defaults' and widget != 's.c' and widget in self.theme: - if len(override) > 0: - style = merge_style(self, self.theme[widget], override) - else: - style = self.theme[widget] - return get_css(self, style) - - -def get_style_class(self, class_name: str, widget, override={}) -> str: - """ - Returns style sheet according to default style of widget with override style. Style only - applies to class_name. Sub-controls, pseudo-states and descendant selectors (marked with "~") - defined in self.theme and override are correctly handled. - - Parameters: - - :param class_name: str -> name of the widget to be styled - - :param widget: None or str -> name of the widget style in self.theme (may be empty or None if - only the style in override should be applied) - - :param override: dict -> contains additional style (optional) - - :return: str containing css style sheet - """ - if widget is None or widget == '': - style = override - elif widget != 'app' and widget != 'defaults' and widget != 's.c' and widget in self.theme: - if len(override) > 0: - style = merge_style(self, self.theme[widget], override) - else: - style = self.theme[widget] - else: - raise KeyError( - f'Parameter widget=`{widget}` must be None or key of self.theme ' - 'except `app` or `defaults`.') - main = f'{class_name} {{{get_css(self, style)}}}' - for k, v in style.items(): - if k.startswith(':'): - main += f''' {class_name}{k} {{{get_css(self, v)}}}''' - elif k.startswith('~'): - main += f' {get_style_class(self, f"{class_name} {k[1:]}", None, v)}' - return main - - -def merge_style(self, s1: dict, s2: dict) -> dict: - """ - Returns new dictionary where the given styles are merged. - Up to one sub-dictionary is merged recursively. - - Parameters: - - :param s1: Style-dict 1 - - :param s2: Style-dict 2 - - :return: merged dictionary - """ - result = copy.deepcopy(s1) - for k, v in s2.items(): - if k in result.keys() and isinstance(result[k], dict) and isinstance(v, dict): - result[k].update(v) - else: - result[k] = v - return result - - -def get_css(self, style: dict) -> str: - """ - Converts style dictionary into css style sheet. Escapes '@' - shortcuts with their respective - values. - """ - css = str() - ui_scale = self.config['ui_scale'] - for key, val in style.items(): - if isinstance(val, str) and val.startswith('@'): - v = self.theme['defaults'][val[1:]] - else: - v = val - if key.startswith(':') or key.startswith('~') or key == 'font': - continue - elif isinstance(v, int): - css += f'{key}:{v * ui_scale}px;' - elif isinstance(v, tuple): - css += f'''{key}:{'px '.join(map(lambda s: str(s * ui_scale), v))}px;''' - else: - css += f'{key}:{v};' - return css - - -def theme_font(self, key=None, font_spec=()) -> QFont: - """ - Returns QFont object with font specified in self.theme or font_spec. Adds default fallback font - families. - - Parameters: - - :param key: key in self.theme to access font tuple like: self.theme[key]['font'] - - :param font_spec: font tuple consisting of family, size and weight OR font shortcut (optional) - - :return: configured QFont object - """ - try: - if len(font_spec) != 3 and isinstance(font_spec, tuple): - font_spec = self.theme[key]['font'] - if isinstance(font_spec, str) and font_spec.startswith('@'): - font = self.theme['defaults'][font_spec[1:]] - else: - font = font_spec - except KeyError: - font = self.theme['app']['font'] - font_family = (font[0], *self.theme['app']['font-fallback']) - font_size = int(font[1] * self.config['ui_scale']) - try: - font_weight = WEIGHT_CONVERSION[font[2]] - except KeyError: - font_weight = QFont.Weight.Normal - font = QFont(font_family, font_size, font_weight) - font.setHintingPreference(QFont.HintingPreference.PreferNoHinting) - font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) - return font - - -def create_style_sheet(self, d: dict) -> str: - """ - Creates Stylesheet from dictionary. Dictionary keys represent css selector. - - Parameters: - - :param d: dict -> style dictionary - - :return: string containing css sheet - """ - style = str() - for s, v in d.items(): - style += f'{s} {{{get_css(self, v)}}}' - return style - - -def prepare_tooltip_css(self): - """ - Converts dictionaries containing tooltip style to css - """ - ui_scale = self.config['ui_scale'] - tooltips = self.theme['tooltip'] - for tag, style in self.theme['tooltip_def'].items(): - css = '' - for prop, val in style.items(): - if isinstance(val, int): - unit = 'pt' if prop == 'font-size' else 'px' - css += f'{prop}:{val * ui_scale}{unit};' - elif isinstance(val, tuple): - css += f'''{prop}:{'px '.join(map(lambda s: str(s * ui_scale), val))}px;''' - else: - css += f'{prop}:{val};' - tooltips[tag] = css diff --git a/src/textedit.py b/src/textedit.py index fc80cba..402e8cc 100644 --- a/src/textedit.py +++ b/src/textedit.py @@ -1,36 +1,23 @@ from re import sub as re_sub from .constants import CAREER_ABBR, RARITY_COLORS, SKILL_PREFIXES, WIKI_URL +from .theme import TooltipCSS -def get_tooltip(self, name: str, type_: str, environment: str = 'space') -> str: - """ - Returns tooltip for trait. - - Parameters: - - :param name: name of the trait - - :param type_: type of the trait ("rep_traits", "traits", "starship_traits", ...) - - :param environment: "space" / "ground" - """ - if type_ == 'starship_traits': - return self.cache.starship_traits[name]['tooltip'] - else: - return self.cache.traits[environment][type_][name]['tooltip'] - - -def add_equipment_tooltip_header(self, item: dict, tooltip_body: str, item_type: str) -> str: +def add_equipment_tooltip_header( + item: dict[str, str], item_data: dict[str], tooltip_styles: TooltipCSS) -> str: """ Adds equipment header including name, mark, modifiers, rarity and item type to the tooltip body and returns the complete tooltip. Parameters: - :param item: item to create the tooltip for - - :param tooltip_body: already created tooltip body - - :param item_type: type of the item to add to the subtitle + - :param item_data: cargo data for the item + - :param tooltip_styles: used to style the tooltip """ rarity_color = f'color:{RARITY_COLORS[item['rarity']]};' - head_style = self.theme['tooltip']['equipment_name'] + rarity_color - subhead_style = self.theme['tooltip']['equipment_type_subheader'] + rarity_color + head_style = tooltip_styles.equipment_name + rarity_color + subhead_style = tooltip_styles.equipment_type_subheader + rarity_color item_title = item['item'] if item['mark'] != '' and item['mark'] is not None: item_title += ' ' + item['mark'] @@ -38,13 +25,14 @@ def add_equipment_tooltip_header(self, item: dict, tooltip_body: str, item_type: if mods != '': item_title += ' ' + mods tooltip = ( - f"

{item_title}

" - f"{item['rarity']} {self.cache.equipment[item_type][item['item']]['type']}

") - return tooltip + tooltip_body + f"

{item_title}

" + f"{item['rarity']} {item_data['type']}

") + return tooltip + item_data['tooltip'] def format_skill_tooltip( - self, skill_name: str, skill_data: dict, node_index: int, environment: str) -> str: + skill_name: str, skill_data: dict, node_index: int, environment: str, + tooltip_styles: TooltipCSS) -> str: """ Formats skill tooltip @@ -53,7 +41,10 @@ def format_skill_tooltip( - :param skill_data: contains skill details - :param node_index: index of the node within the skill group - :param environment: "space" / "ground" + - :param tooltip_styles: used to style the tooltip """ + head_style = f"{tooltip_styles.equipment_name}color:#ffd700;" + subhead_style = f"{tooltip_styles.equipment_type_subheader}color:#ffd700;" if environment == 'space': if skill_data['grouping'] == 'column': prefix = SKILL_PREFIXES[node_index] @@ -67,125 +58,83 @@ def format_skill_tooltip( else: prefix = '' global_description = skill_data['gdesc'][node_index] - head_style = f"{self.theme['tooltip']['equipment_name']}color:#ffd700;" - subhead_style = f"{self.theme['tooltip']['equipment_type_subheader']}color:#ffd700;" return ( - f"

{prefix}{skill_name}

" - f"{CAREER_ABBR[skill_data['career']]} {environment.capitalize()} Skill

" - f"

{global_description}

{skill_data['nodes'][node_index]['desc']}

") + f"

{prefix}{skill_name}

" + f"{CAREER_ABBR[skill_data['career']]} {environment.capitalize()} Skill

" + f"

{global_description}

{skill_data['nodes'][node_index]['desc']}

") elif environment == 'ground': - head_style = f"{self.theme['tooltip']['equipment_name']}color:#ffd700;" - subhead_style = f"{self.theme['tooltip']['equipment_type_subheader']}color:#ffd700;" return ( - f"

{skill_name}

" - f"Ground Skill

" - f"

{skill_data['gdesc']}

{skill_data['nodes'][node_index]['desc']}

") + f"

{skill_name}

" + f"Ground Skill

" + f"

{skill_data['gdesc']}

{skill_data['nodes'][node_index]['desc']}

") -def get_skill_unlock_tooltip_ground(self, unlock_id: int, unlock_choice: int): +def get_ultimate_skill_unlock_tooltip( + unlock: dict[str], unlock_choice: int, enhancements: int, tooltip_styles: TooltipCSS): """ - gets tooltip for ground unlock from cache and formats it + Formats tooltip for ultimate skill unlock. Parameters: - - :param unlock_id: id of the unlock, counted from the unlock with the lowest requirement - - :param unlock_choice: `0` (first choice; "down") or `1` (second choice; "up") - """ - unlock = self.cache.skills['ground_unlocks'][unlock_id]['nodes'][unlock_choice] - head_style = f"{self.theme['tooltip']['equipment_name']}color:#ffd700;" - subhead_style = f"{self.theme['tooltip']['equipment_type_subheader']}color:#ffd700;" - return ( - f"

{unlock['name']}

" - f"Ground Skill

{unlock['desc']}

") - - -def get_skill_unlock_tooltip_space(self, career: str, unlock_id: int, unlock_choice: int): + - :param unlock: contains unlock metadata and tooltips + - :param unlock_choice: selected enhancement (`-1` for no unlock) + - :param enhancements: number of enhancements + - :param tooltips_styles: used to style the tooltip """ - gets tooltip for space unlock from cache and formats it - - Parameters: - - :param career: "eng" / "sci" / "tac" - - :param unlock_id: id of the unlock, counted from the unlock with the lowest requirement - - :param unlock_choice: `0` (first choice; "down") or `1` (second choice; "up") - """ - unlock = self.cache.skills['space_unlocks'][career][unlock_id]['nodes'][unlock_choice] - head_style = f"{self.theme['tooltip']['equipment_name']}color:#ffd700;" - subhead_style = f"{self.theme['tooltip']['equipment_type_subheader']}color:#ffd700;" - return ( - f"

{unlock['name']}

" - f"Space Skill

{unlock['desc']}

") - - -def get_ultimate_skill_unlock_tooltip(self, career: str, unlock_choice: int, enhancements: int): - """ - gets tooltip for space unlock from cache and formats it - - Parameters: - - :param unlock_id: id of the unlock, counted from the unlock with the lowest requirement - - :param unlock_choice: `0` (first choice; "down") or `1` (second choice; "up") - """ - unlock = self.cache.skills['space_unlocks'][career][4] - head_style = f"{self.theme['tooltip']['equipment_name']}color:#ffd700;" - subhead_style = f"{self.theme['tooltip']['equipment_type_subheader']}color:#ffd700;" - enhancement_style = self.theme['tooltip']['skill_ultimate_name'] + enhancement_style = tooltip_styles.skill_ultimate_name tooltip = ( - f"

{unlock['name']}

" - f"Space Skill

{unlock['desc']}

") + f"

{unlock['name']}

" + f"

Space Skill

" + f"

{unlock['desc']}

") if enhancements == 1: tooltip += ( - f"

{unlock['options'][unlock_choice]['name']}

" - f"

{unlock['options'][unlock_choice]['desc']}

") + f"

{unlock['options'][unlock_choice]['name']}

" + f"

{unlock['options'][unlock_choice]['desc']}

") elif enhancements == 2: e1 = (unlock_choice - 1) % 3 e2 = (unlock_choice + 1) % 3 tooltip += ( - f"

{unlock['options'][e1]['name']}

" - f"

{unlock['options'][e1]['desc']}

" - f"

{unlock['options'][e2]['name']}

" - f"

{unlock['options'][e2]['desc']}

") + f"

{unlock['options'][e1]['name']}

" + f"

{unlock['options'][e1]['desc']}

" + f"

{unlock['options'][e2]['name']}

" + f"

{unlock['options'][e2]['desc']}

") elif enhancements != 0: for i in range(3): tooltip += ( - f"

{unlock['options'][i]['name']}

" - f"

{unlock['options'][i]['desc']}

") + f"

{unlock['options'][i]['name']}

" + f"

{unlock['options'][i]['desc']}

") return tooltip -# -------------------------------------------------------------------------------------------------- -# static functions -# -------------------------------------------------------------------------------------------------- - -def create_equipment_tooltip( - item: dict, head_style: str, subhead_style: str, who_style: str, tags) -> str: +def create_equipment_tooltip(item: dict, tooltip_style: TooltipCSS) -> str: """ Creates tooltip for equipment from raw item data. Parameters: - :param item: item data (from cargo table) - - :param head_style: css style for head - - :param subhead_style: css style for subhead - - :param who_style: css style for ship/career/... restriction information - - :param tags: css styles for the wikitext parser + - :param tooltip_style: object containing style data """ tooltip = '' if item['who'] is not None: - tooltip += f"

{item['who']}

" + tooltip += f"

{item['who']}

" for i in range(1, 10, 1): if item[f'head{i}'] is not None: - tooltip += f"

{format_wikitext(dewikify(item[f'head{i}']))}

" + tooltip += ( + f"

" + f"{format_wikitext(dewikify(item[f'head{i}']))}

") if item[f'subhead{i}'] is not None: tooltip += ( - f"

" - f"{format_wikitext(dewikify(item[f'subhead{i}']))}

") + f"

" + f"{format_wikitext(dewikify(item[f'subhead{i}']))}

") if item[f'text{i}'] is not None: tooltip += ( - f"

" - f"{parse_wikitext(dewikify(item[f'text{i}']), tags)}

") + f"

" + f"{parse_wikitext(dewikify(item[f'text{i}']), tooltip_style)}

") return tooltip def create_trait_tooltip( - name: str, description: str, type_: str, environment: str, head_style: str, - subhead_style: str, tags) -> str: + name: str, description: str, type_: str, environment: str, + styles: TooltipCSS) -> str: """ Creates tooltip for trait from trait description. @@ -194,25 +143,23 @@ def create_trait_tooltip( - :param description: description of the trait - :param type_: type of the trait; one of "traits", "rep_traits", "active_rep_traits" - :param environment: "space" / "ground" - - :param head_style: css style for head - - :param subhead_style: css style for subhead - - :param tags: css styles for the wikitext parser + - :param styles: object containing style data """ if type_ == 'traits': tooltip = ( - f"

{name}

" - f"Personal {environment.capitalize()} Trait

" - f"{parse_wikitext(dewikify(description), tags)}

") + f"

{name}

" + f"Personal {environment.capitalize()} Trait

" + f"{parse_wikitext(dewikify(description), styles)}

") elif type_ == 'rep_traits': tooltip = ( - f"

{name}

" - f"{environment.capitalize()} Reputation Trait

" - f"{parse_wikitext(dewikify(description), tags)}

") + f"

{name}

" + f"{environment.capitalize()} Reputation Trait

" + f"{parse_wikitext(dewikify(description), styles)}

") elif type_ == 'active_rep_traits': tooltip = ( - f"

{name}

" - f"Active {environment.capitalize()} Reputation Trait

" - f"{parse_wikitext(dewikify(description), tags)}

") + f"

{name}

" + f"Active {environment.capitalize()} Reputation Trait

" + f"{parse_wikitext(dewikify(description), styles)}

") else: tooltip = '' return tooltip @@ -367,3 +314,12 @@ def sanitize_equipment_name(name: str) -> str: def wiki_url(page_name: str, prefix: str = ''): return (WIKI_URL + prefix + page_name).replace(' ', '_') + + +def format_path(path: str): + if len(path) < 2: + return path + path = path.replace(chr(92), '/') + if path[1] == ':' and path[0] >= 'a' and path[0] <= 'z': + path = path[0].capitalize() + path[1:] + return path diff --git a/src/theme.py b/src/theme.py new file mode 100644 index 0000000..35b8e98 --- /dev/null +++ b/src/theme.py @@ -0,0 +1,792 @@ +import copy + +from PySide6.QtGui import QFont, QIcon, QPixmap + +WEIGHT_CONVERSION = { + 'normal': QFont.Weight.Normal, + 'bold': QFont.Weight.Bold, + 'extrabold': QFont.Weight.ExtraBold, + 'medium': QFont.Weight.Medium +} + + +class ThemeOptions: + """Contains Theme options affecting the UI, but not directly related to the style""" + + __slots__ = ('box_height', 'box_width', 'default_box_height', 'default_box_width') + + def __init__(self, initial_options: dict[str] = {}): + """ + Parameters: + - :param initial_options: options to use instead of defaults (optional) + """ + self.box_height: float = 64.0 + self.box_width: float = 49.0 + self.default_box_height: float = 64.0 + self.default_box_width: float = 49.0 + if len(initial_options) > 0: + for option_name in self.__slots__: + if option_value := initial_options.get(option_name): + setattr(self, option_name, option_value) + + +class TooltipCSS: + """ + Contains css used for styling tooltips. + """ + + __slots__ = ('boff_header', 'boff_subheader', 'equipment_head', 'equipment_name', + 'equipment_subhead', 'equipment_type_subheader', 'equipment_who', 'indent', 'li', + 'skill_ultimate_name', 'trait_header', 'trait_subheader', 'ul') + + def __init__(self, tooltips: dict[str, dict[str]], scale: float): + self.boff_header: str = self.get_tooltip_css(tooltips['boff_header'], scale) + self.boff_subheader: str = self.get_tooltip_css(tooltips['boff_subheader'], scale) + self.equipment_head: str = self.get_tooltip_css(tooltips['equipment_head'], scale) + self.equipment_name: str = self.get_tooltip_css(tooltips['equipment_name'], scale) + self.equipment_subhead: str = self.get_tooltip_css(tooltips['equipment_subhead'], scale) + self.equipment_type_subheader: str = self.get_tooltip_css( + tooltips['equipment_type_subheader'], scale) + self.equipment_who: str = self.get_tooltip_css(tooltips['equipment_who'], scale) + self.indent: str = self.get_tooltip_css(tooltips['indent'], scale) + self.li: str = self.get_tooltip_css(tooltips['li'], scale) + self.skill_ultimate_name: str = self.get_tooltip_css(tooltips['skill_ultimate_name'], scale) + self.trait_header: str = self.get_tooltip_css(tooltips['trait_header'], scale) + self.trait_subheader: str = self.get_tooltip_css(tooltips['trait_subheader'], scale) + self.ul: str = self.get_tooltip_css(tooltips['ul'], scale) + + def get_tooltip_css(self, style_data: dict[str], scale: float): + """ + Converts dictionary containing tooltip style to css + """ + css = str() + for prop, val in style_data.items(): + if isinstance(val, int): + unit = 'pt' if prop == 'font-size' else 'px' + css += f'{prop}:{val * scale}{unit};' + elif isinstance(val, tuple): + css += f'''{prop}:{'px '.join(map(lambda s: str(s * scale), val))}px;''' + else: + css += f'{prop}:{val};' + return css + + +class AppTheme: + """Encapsulates theme functions and data.""" + + def __init__(self, scale: float, theme_tree: dict[str] = {}, theme_options: dict[str] = {}): + """ + Parameters: + - :param scale: Used to adjust font sizes, margins, paddings, etc. + - :param theme_tree: theme data to use instead of default theme + - :param theme_tree: options that affect the UI, but are not directly related to the style + """ + self.scale: float = scale + self.icons: dict[str, QIcon | QPixmap] = dict() + self.opt: ThemeOptions = ThemeOptions(theme_options) + self.opt.box_height = self.opt.default_box_height * self.scale * 0.8 + self.opt.box_width = self.opt.default_box_width * self.scale * 0.8 + if len(theme_tree) > 0: + self._theme_data: dict[str, dict] = theme_tree + else: + self._theme_data: dict[str, dict] = self.get_default_theme() + self.tooltips: TooltipCSS = TooltipCSS(self._theme_data['tooltip_def'], scale) + + def __getitem__(self, key: str): + return self._theme_data[key] + + def get_style(self, widget: str, override: dict[str] = {}) -> str: + """ + Returns style sheet according to default style of widget with override style. Returns + empty string if widget style is not defined in current theme. + + Parameters: + - :param widget: name of the widget to grab the style for from the current theme + - :param override: contains additional style or override style to replace the default \ + style with (optional) + + :return: str containing css style sheet + """ + if widget in self._theme_data: + if len(override) > 0: + style = self.merge_style(self._theme_data[widget], override) + else: + style = self._theme_data[widget] + return self.get_css(style) + else: + return '' + + def get_style_class(self, class_name: str, widget: str, override: dict[str] = {}) -> str: + """ + Returns style sheet according to default style of widget with override style. Style only + applies to `class_name`. Sub-controls (prefixed with `::`), pseudo-states (prefixed with + `:`) and descendant selectors (prefixed with `~`) defined in current theme are formatted to + only apply to the given `class_name`. Returns empty string with wdget style is not defined + in current theme. + + Parameters: + - :param class_name: name of the widget class to be styled + - :param widget: name of the widget to grab the style for from the current theme; may be \ + empty string to only apply override styles + - :param override: contains additional style or override style to replace the default \ + style with (optional) + + :return: str containing css style sheet + """ + if widget == '': + style = override + elif widget in self._theme_data: + if len(override) > 0: + style: dict[str] = self.merge_style(self._theme_data[widget], override) + else: + style: dict[str] = self._theme_data[widget] + else: + return '' + style_sheet = f'{class_name} {{{self.get_css(style)}}}' + for prop, value in style.items(): + if prop.startswith(':'): + style_sheet += f''' {class_name}{prop} {{{self.get_css(value)}}}''' + elif prop.startswith('~'): + style_sheet += f' {self.get_style_class(f"{class_name} {prop[1:]}", '', value)}' + return style_sheet + + def merge_style(self, s1: dict[str], s2: dict[str]) -> dict[str]: + """ + Returns new dictionary where the given styles are merged. Values in the second style take + precedence. Up to one sub-dictionary is merged recursively. + + Parameters: + - :param s1: Style-dict 1 + - :param s2: Style-dict 2 + + :return: merged dictionary + """ + result = copy.deepcopy(s1) + for key, value in s2.items(): + if key in result.keys() and isinstance(result[key], dict) and isinstance(value, dict): + result[key].update(value) + continue + result[key] = value + return result + + def get_css(self, style: dict[str]) -> str: + """ + Converts style dictionary into css style sheet. Values starting with `@` are treated as + shortcuts and replaced with values from the `default` key of the current theme. Ignores + property `font`, sub-controls (prefixed with `::`), pseudo-states (prefixed with + `:`) and descendant selectors (prefixed with `~`). + + Parameters: + - :param style: dictionary containg style to be converted to css + + :return: css style sheet + """ + style_sheet = str() + for prop, raw_value in style.items(): + if isinstance(raw_value, str) and raw_value.startswith('@'): + prop_value = self._theme_data['defaults'][raw_value[1:]] + else: + prop_value = raw_value + if prop.startswith(':') or prop.startswith('~') or prop == 'font': + continue + elif isinstance(prop_value, int): + style_sheet += f'{prop}:{prop_value * self.scale}px;' + elif isinstance(prop_value, tuple): + scaled_values = map(lambda s: str(s * self.scale), prop_value) + style_sheet += f'''{prop}:{'px '.join(scaled_values)}px;''' + else: + style_sheet += f'{prop}:{prop_value};' + return style_sheet + + def get_font(self, widget: str = '', font_spec: tuple[str, int, str] | str = ()) -> QFont: + """ + Returns QFont object with font specified in current theme or font_spec. Adds default + fallback font families. + + Parameters: + - :param widget: name of style to get font from + - :param font_spec: font tuple consisting of family, size and weight OR font shortcut \ + (optional) + + :return: configured QFont object + """ + if len(font_spec) != 3 and isinstance(font_spec, tuple): + font_spec = self._theme_data[widget]['font'] + if isinstance(font_spec, str) and font_spec.startswith('@'): + font = self._theme_data['defaults'][font_spec[1:]] + else: + font = font_spec + font_family = (font[0], *self._theme_data['app']['font-fallback']) + font_size = int(font[1] * self.scale) + font_weight = WEIGHT_CONVERSION[font[2]] + font = QFont(font_family, font_size, font_weight) + font.setHintingPreference(QFont.HintingPreference.PreferNoHinting) + font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) + return font + + def create_style_sheet(self, d: dict[str, dict]) -> str: + """ + Creates Stylesheet from dictionary. Dictionary keys represent css selector. Ignores + property `font`, sub-controls (prefixed with `::`), pseudo-states (prefixed with + `:`) and descendant selectors (prefixed with `~`). + + Parameters: + - :param d: style dictionary + + :return: string containing style sheet + """ + style_sheet = str() + for prop, prop_value in d.items(): + style_sheet += f'{prop} {{{self.get_css(prop_value)}}}' + return style_sheet + + def get_default_theme(self) -> dict[str, dict]: + """ + Returns default theme. + """ + return { + # general style + 'app': { + 'bg': '#1a1a1a', + 'fg': '#eeeeee', + 'sets': '#c59129', + 'font': ('Overpass', 11, 'normal'), + 'heading': ('Overpass', 14, 'bold'), + 'subhead': ('Overpass', 12, 'medium'), + 'font-fallback': ('Yu Gothic UI', 'Nirmala UI', 'Microsoft YaHei UI', 'sans-serif'), + 'frame_thickness': 8, + # this styles every item of the given type + 'style': { + # scroll bar trough (invisible) + 'QScrollBar': { + 'background': 'none', + 'border-style': 'none', + 'border-radius': 0, + 'margin': 0 + }, + 'QScrollBar:vertical': { + 'width': 8, + }, + 'QScrollBar:horizontal': { + 'height': 8, + }, + # space above and below the scrollbar handle + 'QScrollBar::add-page, QScrollBar::sub-page': { + 'background': 'none' + }, + # scroll bar handle + 'QScrollBar::handle': { + 'background-color': 'rgba(100,100,100,.75)', + 'border-radius': 4, + 'border': 'none' + }, + # scroll bar arrow buttons + 'QScrollBar::add-line, QScrollBar::sub-line': { + 'height': 0 # hiding the arrow buttons + } + } + }, + # shortcuts, @bg -> means bg in this sub-dictionary + 'defaults': { + 'bg': '#1a1a1a', # background + 'mbg': '#242424', # medium background + 'lbg': '#404040', # light background + 'sets': '#c59129', # accent + 'lsets': '#60c59129', # light accent + 'font': ('Overpass', 11, 'normal'), + 'heading': ('Overpass', 14, 'bold'), + 'subhead': ('Overpass', 12, 'medium'), + 'small_text': ('Overpass', 10, 'normal'), + 'fg': '#eeeeee', # foreground (usually text) + 'mfg': '#bbbbbb', # medium foreground + 'bc': '#888888', # border color + 'bw': 1, # border width + 'br': 2, # border radius + 'sep': 2, # seperator -> width of major seperating lines + 'margin': 10, # default margin between widgets + 'csp': 5, # child spacing -> content margin + 'isp': 15, # item spacing + }, + # dark frame + 'frame': { + 'background-color': '@bg', + 'border-style': 'none', + 'margin': 0, + 'padding': 0 + }, + # medium frame + 'medium_frame': { + 'background-color': '@mbg', + 'margin': 0, + 'padding': 0 + }, + # light frame + 'light_frame': { + 'background': '@lbg', + 'margin': 0, + 'padding': 0 + }, + # default text (non-button, non-entry, non table) + 'label': { + 'color': '@fg', + 'margin': (3, 0, 3, 0), + 'qproperty-indent': '0', # disables auto-indent + 'border-style': 'none', + 'font': '@font' + }, + # default text (non-button, non-entry, non table) + 'hint_label': { + 'color': '@mfg', + 'margin': (3, 0, 3, 0), + 'qproperty-indent': '0', # disables auto-indent + 'border-style': 'none', + 'font': '@font' + }, + # heading label + 'label_heading': { + 'color': '@fg', + 'qproperty-indent': '0', + 'border-style': 'none', + 'font': '@heading' + }, + # label for subheading + 'label_subhead': { + 'color': '@fg', + 'qproperty-indent': '0', + 'border-style': 'none', + 'margin-bottom': 3, + 'font': '@subhead' + }, + # default button + 'button': { + 'background-color': 'none', + 'color': '@fg', + 'text-decoration': 'none', + 'border-width': '@bw', + 'border-style': 'solid', + 'border-color': '@sets', + 'margin': (3, 3, 3, 3), + 'padding': (2, 5, 0, 5), + 'font': ('Overpass', 13, 'medium'), + ':hover': { + 'border-color': '@bc' + }, + ':disabled': { + 'color': '@bc' + }, + # Tooltip + '~QToolTip': { + 'background-color': '@mbg', + 'border-style': 'solid', + 'border-color': '@lbg', + 'border-width': '@bw', + 'padding': (0, 0, 0, 0), + 'color': '@fg', + 'font': 'Overpass' + } + }, + # heavy button + 'heavy_button': { + 'background-color': '@sets', + 'color': '@fg', + 'text-decoration': 'none', + 'border-width': '@bw', + 'border-style': 'solid', + 'border-color': '@sets', + 'margin': (3, 3, 3, 3), + 'padding': (2, 5, 0, 5), + 'font': ('Overpass', 13, 'bold'), + ':hover': { + 'background-color': '@mbg' + }, + ':disabled': { + 'color': '@bc' + } + }, + # build item button + 'item': { + 'background-color': '#242424', + 'border-width': 1, + 'border-color': '#888888', + 'border-highlight-color': '#ffd700' + }, + # build item button + 'item_dark': { + 'background-color': '#1a1a1a', + 'border-width': 1, + 'border-color': '#404040', + }, + # checkbox + 'checkbox': { + '::indicator': { + 'width': 16, + 'height': 16, + 'border-style': 'solid', + 'border-width': '@bw', + 'border-color': '@bc', + 'background-color': '@lbg', + }, + '::indicator:hover': { + 'border-color': '@sets' + }, + '::indicator:checked': { + 'image': 'url(local_folder:check.svg)' + }, + '::indicator:unchecked': { + 'image': 'url(local_folder:uncheck.svg)', + } + }, + # holds sub-pages + 'tabber': { + 'background-color': 'none', + 'border': 'none', + 'margin': 0, + 'padding': 0, + '::pane': { + 'border': 'none', + } + }, + # default tabber buttons (hidden) + 'tabber_tab': { + '::tab': { + 'height': 0, + 'width': 0 + } + }, + # combo box + 'combobox': { + 'border-style': 'solid', + 'border-width': '@bw', + 'border-color': '@bc', + 'background-color': '@bg', + 'padding': (1, 5, 1, 5), + 'color': '@fg', + 'font': '@subhead', + '::down-arrow': { + 'image': 'url(local_folder:thick-chevron-down.svg)', + 'width': '@margin', + }, + '::drop-down': { + 'border-style': 'none', + 'padding': (2, 2, 2, 2) + }, + '~QAbstractItemView': { + 'background-color': '@mbg', + 'border-style': 'solid', + 'border-color': '@bc', + 'border-width': '@bw', + 'border-radius': '@br', + 'color': '@fg', + 'outline': '0', + '::item': { + 'border-width': '@bw', + 'border-style': 'solid', + 'border-color': '@mbg', + }, + '::item:hover': { + 'border-color': '@sets', + }, + } + }, + # additional style for doff combobox + 'doff_combo': { + 'color': '@fg', + 'border-style': 'none', + 'border-width': 0, + 'margin': 0, + 'font': '@small_text' + }, + # additional style for boff combobox + 'boff_combo': { + 'font': '@font', + ':disabled': { + 'border-color': '@bg', + 'border-left-width': 0, + 'padding-left': 0 + }, + '::down-arrow:disabled': { + 'image': 'none', + 'width': '@margin', + }, + }, + # auto-completion popup of combobox + 'popup': { + 'background-color': '@mbg', + 'border-style': 'solid', + 'border-color': '@bc', + 'border-width': '@bw', + 'border-radius': '@br', + 'color': '@fg', + 'outline': '0', + '::item': { + 'border-width': '@bw', + 'border-style': 'solid', + 'border-color': '@mbg', + }, + '::item:hover': { + 'border-color': '@sets', + }, + }, + # line of user-editable text + 'entry': { + 'background-color': '@mbg', + 'color': '@fg', + 'border-width': '@bw', + 'border-style': 'solid', + 'border-color': '@bc', + 'font': '@subhead', + 'selection-background-color': '@lsets', + # cursor is inside the line + ':focus': { + 'border-color': '@sets' + }, + ':hover': { + 'background-color': '@lbg' + } + }, + # for item tooltips + 'infobox': { + 'background-color': '#000000', + 'border-style': 'none', + 'color': '@fg', + 'font': '@font' + # 'margin': 0, + # 'padding': 0, + }, + 'infobox_frame': { + 'background-color': '#000000', + 'border-style': 'solid', + 'border-width': '@bw', + 'border-color': '@mbg', + 'border-radius': '@br', + # 'margin': 0, + # 'padding': '@sep', + }, + # tooltip for TooltipLabel + 'label_tooltip': { + 'color': '@fg', + 'background-color': '@bg', + 'border-color': '@lbg', + 'border-radius': '@br', + 'border-style': 'solid', + 'border-width': '@bw', + 'font': '@font', + 'padding': 2, + 'qproperty-indent': '0', # disables auto-indent + }, + # for formatting tooltip text, will contain css from tooltip_def + 'tooltip': {}, + 'tooltip_def': { + 'indent': { + 'margin': (0, 0, 0, 20), + }, + 'ul': { + 'margin': (0, 0, 0, 20), + '-qt-list-indent': '0', + }, + 'li': { + 'margin-bottom': 1, + }, + 'boff_header': { + 'color': '#42afca', + 'font-size': 'large', + 'font-weight': 'bold', + 'margin': 0 + }, + 'boff_subheader': { + 'font-size': 10, + 'margin': (0, 0, 20, 0) + }, + 'trait_header': { + 'color': '#42afca', + 'font-size': 'large', + 'font-weight': 'bold', + 'margin': 0, # padding: 0 + }, + 'trait_subheader': { + 'color': '#42afca', + 'font-size': 10, + 'margin': (0, 0, 20, 0), + }, + 'equipment_name': { + 'font-size': 'large', + 'font-weight': 'bold', + 'margin': 0 + }, + 'equipment_type_subheader': { + 'font-size': 10, + 'margin': (0, 0, 20, 0), + }, + 'equipment_head': { + 'color': '#42afca', + 'font-size': 12, + 'margin': (10, 0, 0, 0) + }, + 'equipment_subhead': { + 'color': '#f4f400', + 'font-size': 10, + 'margin': 0 + }, + 'equipment_who': { + 'color': '#ff6347', + 'font-size': 10, + 'margin': (0, 0, 10, 0) + }, + 'skill_ultimate_name': { + 'color': '#ffd700;', + 'font-size': 12, + 'margin': (10, 0, 0, 0) + }, + }, + # picker window + 'picker': { + 'background-color': '@bg', + 'border-color': '@sets', + 'border-width': 3, + 'border-style': 'solid', + 'border-radius': '@br' + }, + # list widget displaying items in picker + 'picker_list': { + 'background-color': '@bg', + 'color': '@fg', + 'border-style': 'none', + 'margin': 0, + 'font': '@font', + 'outline': '0', # removes dotted line around clicked item + '::item': { + 'border-width': '@bw', + 'border-style': 'solid', + 'border-color': '@bg', + }, + '::item:selected': { + 'background-color': '@bg', + 'border-width': '@bw', + 'border-style': 'solid', + 'border-color': '@bg', + }, + # selected but not the last click of the user + '::item:selected:!active': { + 'color': '@fg' + }, + '::item:hover': { + 'background-color': '@lbg', + }, + '~QScrollBar': { + 'border-style': 'none', + 'border': 'none', + 'border-radius': 0 + } + }, + # large text editor + 'textedit': { + 'background-color': '@mbg', + 'border-style': 'solid', + 'border-width': '@bw', + 'border-color': '@bc', + 'font': '@font', + 'color': '@fg', + 'padding': 3, + 'selection-background-color': '@lsets' + }, + # context menu + 'context_menu': { + 'background-color': '@bg', + 'border-color': '@lbg', + 'border-width': '@bw', + 'border-style': 'solid', + 'border-radius': '@br', + 'font': '@font', + 'padding': '@sep', + '::item': { + 'color': '@fg', + 'font': '@font', + 'border-color': '@bg', + 'border-radius': 0, + 'border-style': 'solid', + 'border-width': '@bw', + 'padding': (3, 3, 1, 10), + }, + '::icon': { + 'padding': (1, 1, 1, 10), + }, + '::item:selected': { + 'border-color': '@sets', + }, + '::item:disabled': { + 'color': '@mfg' + }, + '::item:disabled:selected': { + 'border-color': '@bg' + } + }, + # frame for duty officers + 'doff_frame': { + 'background-color': '@bg', + 'border-style': 'solid', + 'border-width': '@bw', + 'border-color': '@bc', + 'padding': 2 + }, + # segment of the bonus bar + 'bonus_bar': { + ':disabled': { + 'border-style': 'solid', + 'border-top-style': 'none', + 'border-bottom-style': 'none', + 'border-width': '@bw', + 'border-color': '@bc', + 'background-color': '@bg', + }, + ':checked': { + 'background-color': '@sets' + } + }, + # label holding career / ground icon + 'unlock_label': { + 'border-style': 'none', + 'border-top-style': 'solid', + 'border-top-width': 1, + 'border-top-color': '@bc', + 'font': '@font', + 'margin': (0, 0, 3, 0), + 'padding': (3, 10, 0, 10), + + }, + # horizontal seperator + 'hr': { + 'background-color': '@lbg', + 'border-style': 'none', + 'height': 1 + }, + # horizontal sliding selector + 'slider': { + 'font': ('Roboto Mono', 11, 'normal'), + 'color': '@fg', + '::groove:horizontal': { + 'border-style': 'none', + 'background-color': '@lbg', + 'border-radius': '@bw', + 'height': 3 + }, + '::handle:horizontal': { + 'border-style': 'solid', + 'border-width': '@bw', + 'border-color': '@bc', + 'background-color': '@bc', + 'width': 6, + 'margin-top': -7, + 'margin-bottom': -7 + }, + '::handle:horizontal:hover': { + 'border-color': '@sets' + }, + '::handle:horizontal:pressed': { + 'background-color': '#666666' + }, + }, + # small window + 'dialog_window': { + 'background-color': '@sets' + }, + } diff --git a/src/widgetbuilder.py b/src/widgetbuilder.py index 69c3672..05ceca0 100644 --- a/src/widgetbuilder.py +++ b/src/widgetbuilder.py @@ -1,155 +1,124 @@ from typing import Callable from PySide6.QtCore import Qt +from PySide6.QtGui import QValidator from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QCompleter, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, - QPushButton, QSizePolicy, QSlider, QVBoxLayout) - -from .callbacks import ( - boff_label_callback_ground, boff_profession_callback_space, doff_spec_callback, - doff_variant_callback, picker, skill_callback_ground, skill_callback_space, - skill_unlock_callback) -from .constants import ( - ABOTTOM, AHCENTER, ALEFT, ATOP, AVCENTER, CALLABLE, CAREERS, GROUND_BOFF_SPECS, SMAXMAX, - SMAXMIN, SMINMAX) -from .style import get_style, get_style_class, merge_style, theme_font -from .textedit import format_skill_tooltip -from .widgets import DoffCombobox, GridLayout, HBoxLayout, ItemButton, TooltipLabel, VBoxLayout - - -def create_frame(self, style='frame', style_override={}, size_policy=None) -> QFrame: + QCheckBox, QComboBox, QCompleter, QFrame, QLabel, QLineEdit, QPushButton, QSizePolicy, QSlider) +from .constants import ACENTER, ATOP, AVCENTER, CALLABLE, SMAXMAX, SMAXMIN, SMINMAX +from .theme import AppTheme +from .widgets import HBoxLayout, ItemButton, VBoxLayout + + +def create_frame2( + theme: AppTheme, style: str = 'frame', style_override: dict = {}, + size_policy: QSizePolicy | None = None) -> QFrame: """ - Creates a frame with default styling and parent + Creates a frame with default styling Parameters: - - :param style: style dict to override default style (optional) - - :param size_policy: size policy of the frame (optional) + - :param theme: reference to AppTheme + - :param style: key for theme, determines style preset + - :param style_override: style dict to override preset style + - :param size_policy: size policy of the frame :return: configured QFrame """ frame = QFrame() - frame.setStyleSheet(get_style(self, style, style_override)) - frame.setSizePolicy(size_policy if isinstance(size_policy, QSizePolicy) else SMAXMAX) + frame.setStyleSheet(theme.get_style(style, style_override)) + frame.setSizePolicy(size_policy if size_policy is not None else SMAXMAX) return frame -def create_label(self, text, style: str = 'label', style_override={}): +def create_label2(theme: AppTheme, text: str, style: str = 'label', style_override={}) -> QLabel: """ Creates a label according to style with parent. Parameters: + - :param theme: reference to AppTheme - :param text: text to be shown on the label - - :param style: name of the style as in self.theme - - :param style_override: style dict to override default style (optional) + - :param style: key for theme, determines style preset + - :param style_override: style dict to override preset style :return: configured QLabel """ label = QLabel() label.setText(text) - label.setStyleSheet(get_style(self, style, style_override)) + label.setStyleSheet(theme.get_style_class('QLabel', style, style_override)) label.setSizePolicy(SMAXMAX) if 'font' in style_override: - label.setFont(theme_font(self, style, style_override['font'])) + label.setFont(theme.get_font(style, style_override['font'])) else: - label.setFont(theme_font(self, style)) + label.setFont(theme.get_font(style)) return label -def create_button(self, text: str, style: str = 'button', style_override={}, toggle=None): - """ - Creates a button according to style with parent. - - Parameters: - - :param text: text to be shown on the button - - :param style: name of the style as in self.theme or style dict - - :param style_override: style dict to override default style (optional) - - :param toggle: True or False when button should be a toggle button, None when it should be a - normal button; the bool value indicates the default state of the button - - :return: configured QPushButton - """ - button = QPushButton(text) - button.setStyleSheet(get_style_class(self, 'QPushButton', style, style_override)) - if 'font' in style_override: - button.setFont(theme_font(self, style, style_override['font'])) - else: - button.setFont(theme_font(self, style)) - button.setCursor(Qt.CursorShape.PointingHandCursor) - button.setSizePolicy(SMAXMAX) - if isinstance(toggle, bool): - button.setCheckable(True) - button.setChecked(toggle) - return button - - -def create_button_series( - self, buttons: dict, style: str = 'button', shape: str = 'row', seperator: str = '', - ret=False): # QVBoxLayout | QHBoxLayout +def create_button_series2( + theme: AppTheme, buttons: dict[str, dict], style: str = 'button', shape: str = 'row', + separator: str = '', ret: bool = False) -> ( + VBoxLayout | HBoxLayout | tuple[VBoxLayout | HBoxLayout, list[QPushButton]]): """ Creates a row / column of buttons. Parameters: + - :param theme: reference to AppTheme - :param buttons: dictionary containing button details - key "default" contains style override for all buttons (optional) - all other keys represent one button, key will be the text on the button; value for the key contains dict with details for the specific button (all optional) - "callback": callable that will be called on button click - "style": individual style override dict - - "toggle": True or False when button should be a toggle button, None when it should be - a normal button; the bool value indicates the default state of the button + - "toggle": True or False when button should be a toggle button, None when it should + be a normal button; the bool value indicates the default state of the button - "stretch": stretch value for the button - "align": alignment flag for button - - "size": SizePolicy for button - - :param style: key for self.theme -> default style + - "size": size policy for button + - :param style: key for theme, determines style preset - :param shape: row / column - - :param seperator: string seperator displayed between buttons (optional) + - :param separator: string seperator displayed between buttons (optional) + - :param ret: set to true to return list of created buttons along with layout - :return: populated QVBoxlayout / QHBoxlayout + :return: populated VBoxlayout / HBoxlayout """ if 'default' in buttons: - defaults = merge_style(self, self.theme[style], buttons.pop('default')) + defaults = theme.merge_style(theme[style], buttons.pop('default')) else: - defaults = self.theme[style] + defaults = theme[style] if shape == 'column': - layout = QVBoxLayout() + layout = VBoxLayout() else: shape = 'row' - layout = QHBoxLayout() - - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - button_list = [] + layout = HBoxLayout() - if seperator != '': + if separator != '': sep_style = { - 'color': defaults['color'], 'margin': 0, 'padding': 0, 'background': '#00000000'} + 'color': defaults['color'], 'margin': 0, 'padding': 0, 'background': '#00000000'} + button_list = [] for i, (name, detail) in enumerate(buttons.items()): if 'style' in detail: - button_style = merge_style(self, defaults, detail['style']) + button_style = theme.merge_style(defaults, detail['style']) else: button_style = defaults toggle_button = detail['toggle'] if 'toggle' in detail else None - bt = create_button(self, name, style, button_style, toggle_button) - if 'size' in detail: - bt.setSizePolicy(detail['size']) + bt = create_button2(theme, name, style, button_style, toggle_button) if 'callback' in detail and isinstance(detail['callback'], CALLABLE): if toggle_button: bt.clicked[bool].connect(detail['callback']) else: bt.clicked.connect(detail['callback']) + if 'size' in detail: + bt.setSizePolicy(detail['size']) stretch = detail['stretch'] if 'stretch' in detail else 0 if 'align' in detail: layout.addWidget(bt, stretch, detail['align']) else: layout.addWidget(bt, stretch) button_list.append(bt) - if seperator != '' and i < (len(buttons) - 1): - sep_label = create_label(self, seperator, 'label', sep_style) + if separator != '' and i < (len(buttons) - 1): + sep_label = create_label2(theme, separator, 'label', sep_style) sep_label.setSizePolicy(SMAXMIN) - layout.addWidget(sep_label) + layout.addWidget(sep_label, alignment=ACENTER) if ret: return layout, button_list @@ -157,27 +126,78 @@ def create_button_series( return layout -def create_combo_box( - self, style: str = 'combobox', editable: bool = False, size_policy: QSizePolicy = None, - style_override: dict = {}, class_=QComboBox) -> QComboBox: +def create_button2( + theme: AppTheme, text: str, style: str = 'button', style_override: dict = {}, + toggle: bool = None): + """ + Creates a button according to style with parent. + + Parameters: + - :param theme: reference to AppTheme + - :param text: text to be shown on the button + - :param style: key for theme, determines style preset + - :param style_override: style dict to override preset style + - :param toggle: True or False when button should be a toggle button, None when it should be a \ + normal button; the bool value indicates the default state of the button + + :return: configured QPushButton + """ + button = QPushButton(text) + button.setStyleSheet(theme.get_style_class('QPushButton', style, style_override)) + if 'font' in style_override: + button.setFont(theme.get_font(style, style_override['font'])) + else: + button.setFont(theme.get_font(style)) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setSizePolicy(SMAXMAX) + if isinstance(toggle, bool): + button.setCheckable(True) + button.setChecked(toggle) + return button + + +def create_item_button2(theme: AppTheme) -> ItemButton: + """ + Creates Item Button. + + Parameters: + - :param theme: reference to AppTheme + """ + label = create_label2(theme, '', 'infobox') + frame = create_frame2(theme, 'infobox_frame') + margin = theme['defaults']['csp'] * theme.scale + layout = VBoxLayout(margin) + layout.addWidget(label, alignment=ATOP) + frame.setLayout(layout) + button = ItemButton( + theme.opt.box_width, theme.opt.box_height, theme['item'], label, frame, + margin + theme['defaults']['bw'] * theme.scale) + return button + + +def create_combo_box2( + theme: AppTheme, style: str = 'combobox', editable: bool = False, + size_policy: QSizePolicy = None, style_override: dict[str] = {}, + class_: type[QComboBox] = QComboBox) -> QComboBox: """ Creates a combobox with given style and returns it. Parameters: - - :param style: key for self.theme -> default style + - :param theme: reference to AppTheme + - :param style: key for theme, determines style preset - :param editable: set to True to make combobox editable - :param size_policy: size policy for combobox - - :param style_override: style dict to override default style + - :param style_override: style dict to override preset style - :param class_: custom constructor for combobox; must be QCombobox or subclass :return: styled QCombobox """ combo_box = class_() - combo_box.setStyleSheet(get_style_class(self, 'QComboBox', style, style_override)) + combo_box.setStyleSheet(theme.get_style_class('QComboBox', style, style_override)) if 'font' in style_override: - font = theme_font(self, style, style_override['font']) + font = theme.get_font(style, style_override['font']) else: - font = theme_font(self, style) + font = theme.get_font(style) combo_box.setFont(font) combo_box.setSizePolicy(SMINMAX if size_policy is None else size_policy) combo_box.setCursor(Qt.CursorShape.PointingHandCursor) @@ -189,23 +209,24 @@ def create_combo_box( combo_box.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) combo_box.completer().setFilterMode(Qt.MatchFlag.MatchContains) combo_box.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - combo_box.completer().popup().setStyleSheet(get_style_class(self, 'QListView', 'popup')) + combo_box.completer().popup().setStyleSheet(theme.get_style_class('QListView', 'popup')) combo_box.completer().popup().setFont(font) combo_box.lineEdit().setFont(font) return combo_box -def create_entry( - self, default_value='', validator=None, style: str = 'entry', - style_override: dict = {}, placeholder='') -> QLineEdit: +def create_entry2( + theme: AppTheme, default_value='', validator: QValidator | None = None, + style: str = 'entry', style_override: dict = {}, placeholder: str = '') -> QLineEdit: """ Creates an entry widget and styles it. Parameters: + - :param theme: reference to AppTheme - :param default_value: default value for the entry - :param validator: validator to validate entered characters against - - :param style: key for self.theme -> default style - - :param style_override: style dict to override default style + - :param style: key for theme, determines style preset + - :param style_override: style dict to override preset style - :param placeholder: placeholder shown when entry is empty :return: styled QLineEdit @@ -213,391 +234,46 @@ def create_entry( entry = QLineEdit(default_value) entry.setValidator(validator) entry.setPlaceholderText(placeholder) - entry.setStyleSheet(get_style_class(self, 'QLineEdit', style, style_override)) + entry.setStyleSheet(theme.get_style_class('QLineEdit', style, style_override)) if 'font' in style_override: - entry.setFont(theme_font(self, style, style_override['font'])) + entry.setFont(theme.get_font(style, style_override['font'])) else: - entry.setFont(theme_font(self, style)) + entry.setFont(theme.get_font(style)) entry.setCursor(Qt.CursorShape.IBeamCursor) entry.setSizePolicy(SMAXMAX) return entry -def create_checkbox(self, style: str = 'checkbox', style_override: dict = {}) -> QCheckBox: +def create_checkbox2( + theme: AppTheme, style: str = 'checkbox', style_override: dict = {}) -> QCheckBox: """ Creates checkbox and styles it. Parameters: - - :param style: key for self.theme -> default style - - :param style_override: style dict to override default style + - :param theme: reference to AppTheme + - :param style: key for theme, determines style preset + - :param style_override: style dict to override preset style """ checkbox = QCheckBox() - checkbox.setStyleSheet(get_style_class(self, 'QCheckBox', style, style_override)) + checkbox.setStyleSheet(theme.get_style_class('QCheckBox', style, style_override)) return checkbox -def create_item_button(self, style_override: dict = {}) -> ItemButton: - """ - Creates Item Button. - """ - label = create_label(self, '', 'infobox') - frame = create_frame(self, 'infobox_frame') - margin = self.theme['defaults']['csp'] * self.config['ui_scale'] - layout = VBoxLayout(margin) - layout.addWidget(label, alignment=ATOP) - frame.setLayout(layout) - button = ItemButton( - self.box_width, self.box_height, self.theme['item'], label, frame, - margin + self.theme['defaults']['bw'] * self.config['ui_scale']) - return button - - -def create_build_section( - self, label_text: str, button_count: int, environment: bool, build_key: str, - is_equipment: bool = False, label_store: str = '') -> QGridLayout: - """ - Creates a block of item buttons below a label. - - Parameters: - - :param label_text: text to be displayed above the buttons - - :param button_count: number of buttons to be created - - :param environment: "space" or "ground" - - :param build_key: key for self.build['space'/'ground'] - - :param is_equipment: True when items are equipment, False if items are abilities or traits - - :param label_store: stores category label in self.widgets.build[`label_store`] if set - """ - layout = QGridLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(self.theme['defaults']['margin'] * self.config['ui_scale']) - label = create_label(self, label_text, style_override={'margin': (0, 0, 6, 0)}) - label_size_policy = label.sizePolicy() - label_size_policy.setRetainSizeWhenHidden(True) - label.setSizePolicy(label_size_policy) - layout.addWidget(label, 0, 0, 1, button_count, alignment=ALEFT) - widget_storage = self.widgets.build[environment] - if label_store != '': - widget_storage[label_store] = label - for i in range(button_count): - button = create_item_button(self) - button.clicked.connect(lambda subkey=i, bt=button: picker( - self, environment, build_key, subkey, bt, is_equipment)) - button.rightclicked.connect( - lambda e, i=i: self.context_menu.invoke(e, build_key, i, environment)) - widget_storage[build_key][i] = button - layout.addWidget(button, 1, i, alignment=ALEFT) - return layout - - -def create_boff_station_space( - self, profession: str, specialization: str = '', boff_id: int = 0) -> QGridLayout: - """ - Creates a block of item buttons with label / Combobox representing boff station. - - Parameters: - - :param profession: "Tactical", "Science", "Engineering" or "Universal" - - :param specialization: specialization of the seat; None if it has no specialization - - :param boff_id: identifies the boff station - """ - layout = QGridLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(self.theme['defaults']['margin'] * self.config['ui_scale']) - layout.setColumnStretch(3, 1) - if specialization != '': - specialization = f' / {specialization}' - if profession == 'Universal': - label_options = ( - f'Tactical{specialization}', - f'Science{specialization}', - f'Engineering{specialization}' - ) - else: - label_options = (profession + specialization,) - widget_storage = self.widgets.build['space'] - label_layout = HBoxLayout(spacing=self.config['ui_scale'] * 3) - icon_label = TooltipLabel('', create_label(self, '', 'label_tooltip')) - widget_storage['boff_label_icons'][boff_id] = icon_label - label_layout.addWidget(icon_label, alignment=ALEFT) - icon_label.hide() - label = create_combo_box(self, size_policy=SMAXMAX, style_override=self.theme['boff_combo']) - label.currentTextChanged.connect(lambda new: boff_profession_callback_space(self, boff_id, new)) - label.addItems(label_options) - label_size_policy = label.sizePolicy() - label_size_policy.setRetainSizeWhenHidden(True) - label.setSizePolicy(label_size_policy) - widget_storage['boff_labels'][boff_id] = label - label_layout.addWidget(label, alignment=ALEFT) - layout.addLayout(label_layout, 0, 0, 1, 4, alignment=ALEFT) - for i in range(4): - button = create_item_button(self) - button.sizePolicy().setRetainSizeWhenHidden(True) - button.clicked.connect(lambda subkey=i, bt=button: picker( - self, 'space', 'boffs', subkey, bt, boff_id=boff_id)) - button.rightclicked.connect( - lambda e, i=i: self.context_menu.invoke(e, 'boffs', i, 'space', boff_id)) - layout.addWidget(button, 1, i, alignment=ALEFT) - widget_storage['boffs'][boff_id][i] = button - return layout - - -def create_boff_station_ground(self, boff_id: int) -> VBoxLayout: - """ - Creates a block of item buttons with label / Combobox representing boff station. - - Parameters: - - :param boff_id: identifies the boff station - """ - widget_storage = self.widgets.build['ground'] - m = self.theme['defaults']['margin'] * self.config['ui_scale'] - layout = VBoxLayout(spacing=m) - label_layout = HBoxLayout(spacing=m) - label_layout.setAlignment(ALEFT) - prof_label = create_combo_box(self, style_override=self.theme['boff_combo']) - prof_label.currentTextChanged.connect( - lambda new: boff_label_callback_ground(self, boff_id, 'boff_profs', new)) - prof_label.addItems(CAREERS) - widget_storage['boff_profs'][boff_id] = prof_label - label_layout.addWidget(prof_label) - spec_label = create_combo_box(self, style_override=self.theme['boff_combo']) - spec_label.currentTextChanged.connect( - lambda new: boff_label_callback_ground(self, boff_id, 'boff_specs', new)) - spec_label.addItems(GROUND_BOFF_SPECS) - widget_storage['boff_specs'][boff_id] = spec_label - label_layout.addWidget(spec_label) - layout.addLayout(label_layout) - button_layout = HBoxLayout(spacing=m) - button_layout.setAlignment(ALEFT) - for i in range(4): - button = create_item_button(self) - button.clicked.connect(lambda subkey=i, bt=button: picker( - self, 'ground', 'boffs', subkey, bt, boff_id=boff_id)) - button.rightclicked.connect( - lambda e, i=i: self.context_menu.invoke(e, 'boffs', i, 'ground', boff_id)) - button_layout.addWidget(button) - widget_storage['boffs'][boff_id][i] = button - layout.addLayout(button_layout) - return layout - - -def create_personal_trait_section(self, environment: str) -> QGridLayout: - """ - Creates build section for personal traits - """ - layout = QGridLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(self.theme['defaults']['margin'] * self.config['ui_scale']) - label = create_label(self, 'Personal Traits', style_override={'margin': (0, 0, 6, 0)}) - layout.addWidget(label, 0, 0, 1, 4, alignment=ALEFT) - widget_storage = self.widgets.build[environment] - for row in range(3): - for col in range(4): - i = row * 4 + col - button = create_item_button(self) - button.clicked.connect( - lambda subkey=i, bt=button: picker(self, environment, 'traits', subkey, bt)) - button.rightclicked.connect( - lambda e, i=i: self.context_menu.invoke(e, 'traits', i, environment)) - layout.addWidget(button, row + 1, col, alignment=ALEFT) - widget_storage['traits'][i] = button - # Last button is for innate trait and should not be clickable - button.setEnabled(False) - button.set_style(self.theme['item_dark']) - return layout - - -def create_starship_trait_section(self) -> QGridLayout: - """ - Creates build section for starship traits - """ - layout = QGridLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(self.theme['defaults']['margin'] * self.config['ui_scale']) - label = create_label(self, 'Starship Traits', style_override={'margin': (0, 0, 6, 0)}) - label.sizePolicy().setRetainSizeWhenHidden(True) - layout.addWidget(label, 0, 0, 1, 4, alignment=ALEFT) - widget_storage = self.widgets.build['space'] - for col in range(5): - button = create_item_button(self) - button.sizePolicy().setRetainSizeWhenHidden(True) - button.clicked.connect(lambda subkey=col, bt=button: picker( - self, 'space', 'starship_traits', subkey, bt)) - button.rightclicked.connect( - lambda e, i=col: self.context_menu.invoke(e, 'starship_traits', i, 'space')) - layout.addWidget(button, 1, col, alignment=ALEFT) - widget_storage['starship_traits'][col] = button - for col in range(2): - button = create_item_button(self) - button.sizePolicy().setRetainSizeWhenHidden(True) - button.clicked.connect(lambda subkey=col + 5, bt=button: picker( - self, 'space', 'starship_traits', subkey, bt)) - button.rightclicked.connect( - lambda e, i=col + 5: self.context_menu.invoke(e, 'starship_traits', i, 'space')) - layout.addWidget(button, 2, col, alignment=ALEFT) - widget_storage['starship_traits'][col + 5] = button - return layout - - -def create_doff_section(self, environment: str) -> GridLayout: - """ - Creates duty officer section - """ - spacing = self.theme['defaults']['bw'] * self.config['ui_scale'] - doff_layout = GridLayout(spacing=spacing) - doff_layout.setColumnStretch(1, 1) - for i in range(6): - spec_combo = create_combo_box(self, style_override=self.theme['doff_combo']) - spec_combo.currentTextChanged.connect( - lambda spec, i=i: doff_spec_callback(self, spec, environment, i)) - doff_layout.addWidget(spec_combo, i, 0) - self.widgets.build[environment]['doffs_spec'][i] = spec_combo - variant_combo = create_combo_box( - self, style_override=self.theme['doff_combo'], class_=DoffCombobox) - variant_combo.currentTextChanged.connect( - lambda variant, i=i: doff_variant_callback(self, variant, environment, i)) - doff_layout.addWidget(variant_combo, i, 1) - self.widgets.build[environment]['doffs_variant'][i] = variant_combo - return doff_layout - - -def create_skill_group_space(self, group_data: dict, id_offset: int) -> GridLayout: - """ - Creates a skill group (3 related skill nodes) in appropriate shape - - Parameters: - - :param group_data: skill group data - - :param id_offset: index of the first skill node in self.widgets and self.build - """ - layout = GridLayout(spacing=self.theme['defaults']['csp'] * self.config['ui_scale']) - # one skill with 3 ranks - if group_data['grouping'] == 'column': - for index, node in enumerate(group_data['nodes']): - button = create_item_button(self) - button.clicked.connect(lambda id=id_offset + index: skill_callback_space( - self, group_data['career'], id, 'column')) - # button.rightclicked.connect(lambda e: None) - button.skill_image_name = node['image'] - button.tooltip = format_skill_tooltip( - self, group_data['skill'], group_data, index, 'space') - self.widgets.build['space_skills'][group_data['career']][id_offset + index] = button - layout.addWidget(button, index, 0) - # == 'pair+1': one skill with 2 ranks and one sub-skill with 1 rank - # == 'separate': 3 separate skills - else: - button = create_item_button(self) - button.clicked.connect(lambda id=id_offset: skill_callback_space( - self, group_data['career'], id, group_data['grouping'])) - # button.rightclicked.connect(lambda e: None) - button.skill_image_name = group_data['nodes'][0]['image'] - button.tooltip = format_skill_tooltip( - self, group_data['skill'][0], group_data, 0, 'space') - layout.addWidget(button, 0, 0, 1, 2, alignment=AHCENTER | ABOTTOM) - self.widgets.build['space_skills'][group_data['career']][id_offset] = button - button = create_item_button(self) - button.clicked.connect(lambda id=id_offset + 1: skill_callback_space( - self, group_data['career'], id, group_data['grouping'])) - # button.rightclicked.connect(lambda e: None) - button.skill_image_name = group_data['nodes'][1]['image'] - button.tooltip = format_skill_tooltip( - self, group_data['skill'][1], group_data, 1, 'space') - layout.addWidget(button, 1, 0, alignment=ATOP) - self.widgets.build['space_skills'][group_data['career']][id_offset + 1] = button - button = create_item_button(self) - button.clicked.connect(lambda id=id_offset + 2: skill_callback_space( - self, group_data['career'], id, group_data['grouping'])) - # button.rightclicked.connect(lambda e: None) - button.skill_image_name = group_data['nodes'][2]['image'] - button.tooltip = format_skill_tooltip( - self, group_data['skill'][2], group_data, 2, 'space') - layout.addWidget(button, 1, 1, alignment=ATOP) - self.widgets.build['space_skills'][group_data['career']][id_offset + 2] = button - return layout - - -def create_skill_button_ground(self, group_data: dict, id: int, node_id: int) -> ItemButton: - """ - Creates ground skill button and returns it - - Parameters: - - :param group_data: skill group data - - :param id: index of the skill node in self.widgets and self.build - - :param node_id: 0 or 1 for first or second node - """ - button = create_item_button(self) - button.clicked.connect(lambda: skill_callback_ground(self, group_data['tree'], id)) - # button.rightclicked.connect(lambda e: None) - button.skill_image_name = group_data['nodes'][node_id]['image'] - button.tooltip = format_skill_tooltip( - self, group_data['nodes'][node_id]['name'], group_data, node_id, 'ground') - self.widgets.build['ground_skills'][group_data['tree']][id] = button - return button - - -def create_bonus_bar_segment( - self, bar: str, index: int, style: str = 'bonus_bar', - style_override: dict = {}) -> QPushButton: - """ - Creates segment of bar showing the spent skill points. - - Parameters: - - :param bar: identifies the bar ("tac" / "sci" / "eng" / "ground") - - :param index: index of the segment within the bar - - :param style: style key - - :param style_override: overrides style specified by self.theme - """ - seg = QPushButton() - seg.setEnabled(False) - seg.setCheckable(True) - seg.setStyleSheet(get_style_class(self, 'QPushButton', style, style_override)) - seg.setFixedSize(7 * self.config['ui_scale'], 17 * self.config['ui_scale']) - self.widgets.skill_bonus_bars[bar][index] = seg - return seg - - -def create_bonus_bar_space(self, career: str, layout: GridLayout, column: int): - """ - Creates bonus bar for space career and inserts it into the given layout. - - Parameters: - - :param career: "tac" / "eng" / "sci" - - :param layout: layout to insert the bar into - - :param column: column of the layout to use - """ - segment_index = 0 - button_index = 0 - for row in range(29, 5, -1): - if row % 6 == 0: - button = create_item_button(self) - button.clicked.connect(lambda i=button_index: skill_unlock_callback(self, career, i)) - layout.addWidget(button, row, column, alignment=AHCENTER) - self.widgets.build['skill_unlocks'][career][button_index] = button - button_index += 1 - else: - segment = create_bonus_bar_segment(self, career, segment_index) - layout.addWidget(segment, row, column, alignment=AHCENTER) - segment_index += 1 - for row in range(5, 1, -1): - segment = create_bonus_bar_segment(self, career, segment_index) - layout.addWidget(segment, row, column, alignment=AHCENTER) - segment_index += 1 - button = create_item_button(self) - button.clicked.connect(lambda: skill_unlock_callback(self, career, 4)) - layout.addWidget(button, 1, column, alignment=AHCENTER) - self.widgets.build['skill_unlocks'][career][4] = button - - -def create_annotated_slider( - self, default_value: int = 1, min: int = 0, max: int = 3, +def create_annotated_slider2( + theme: AppTheme, default_value: int = 1, min: int = 0, max: int = 3, style: str = 'slider', style_override_slider: dict = {}, style_override_label: dict = {}, - callback: Callable = lambda v: v) -> QHBoxLayout: + callback: Callable = lambda v: v) -> HBoxLayout: """ Creates Slider with label to display the current value. Parameters: + - :param theme: reference to AppTheme - :param default_value: start value for the slider - :param min: lowest value of the slider - :param max: highest value of the slider - - :param style: key for self.theme -> default style - - :param style_override_slider: style dict to override default style - - :param style_override_label: style dict to override default style + - :param style: key for theme, determines style preset + - :param style_override_slider: style dict to override preset style + - :param style_override_label: style dict to override preset style - :param callback: callable to be attached to the valueChanged signal of the slider; will be \ passed value the slider was moved to; must return value that the label should be set to @@ -608,11 +284,8 @@ def label_updater(new_value): new_text = callback(new_value) slider_label.setText(str(new_text)) - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 3) - layout.setSpacing(self.theme['defaults']['margin']) - slider_label = create_label( - self, '', style, style_override=style_override_label) + layout = HBoxLayout(margins=(0, 0, 0, 3), spacing=theme['defaults']['margin']) + slider_label = create_label2(theme, '', style, style_override=style_override_label) layout.addWidget(slider_label, alignment=AVCENTER) slider = QSlider(Qt.Orientation.Horizontal) slider.setRange(min, max) @@ -622,7 +295,8 @@ def label_updater(new_value): slider.setTickPosition(QSlider.TickPosition.NoTicks) slider.setFocusPolicy(Qt.FocusPolicy.WheelFocus) slider.setSizePolicy(SMINMAX) - slider.setStyleSheet(get_style_class(self, 'QSlider', style, style_override_slider)) + slider.setStyleSheet(theme.get_style_class('QSlider', style, style_override_slider)) + slider.setFixedHeight(22) slider.valueChanged.connect(label_updater) layout.addWidget(slider, stretch=1, alignment=AVCENTER) label_updater(default_value) diff --git a/src/widgets.py b/src/widgets.py index e957f95..d9b497c 100644 --- a/src/widgets.py +++ b/src/widgets.py @@ -1,224 +1,48 @@ from collections import namedtuple +from pathlib import Path +from typing import Callable, Generator, Iterable -from PySide6.QtCore import QEvent, QObject, QPoint, QRect, QSize, Qt, QThread, Signal, Slot -from PySide6.QtGui import QBrush, QColor, QCursor, QEnterEvent, QImage, QMouseEvent, QPainter, QPen +from PySide6.QtCore import QEvent, QPoint, QRect, QSize, Qt, QThread, Signal, Slot +from PySide6.QtGui import ( + QBrush, QColor, QCursor, QEnterEvent, QImage, QMouseEvent, QPainter, QPaintEvent, QPen) from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu, - QPlainTextEdit, QSizePolicy, QTabWidget, QVBoxLayout, QWidget) + QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel, QSizePolicy, QTabWidget, QVBoxLayout, + QWidget) -from .constants import AHCENTER, ATOP, EQUIPMENT_TYPES, SMINMIN +from .constants import AHCENTER, ATOP, SMINMIN +CHAR_TAB_MAP = { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + 4: 1, + 5: 2 +} -class WidgetStorage(): - """ - Stores Widgets - """ - def __init__(self): - self.splash_tabber: QTabWidget - self.loading_label: QLabel +class Tabbers(): + """Manages tabbers""" + + def __init__(self): self.build_tabber: QTabWidget self.build_frames: list[QFrame] = list() - self.sidebar: QFrame self.sidebar_tabber: QTabWidget self.sidebar_frames: list[QFrame] = list() - self.ship: dict = { - 'image': ShipImage, - 'button': ShipButton, - 'tier': QComboBox, - 'dc': TooltipLabel, - 'name': QLineEdit, - 'desc': QPlainTextEdit - } self.character_tabber: QTabWidget self.character_frames: list[QFrame] = list() - self.character: dict = { - 'name': QLineEdit, - 'elite': QCheckBox, - 'career': QComboBox, - 'faction': QComboBox, - 'species': QComboBox, - 'primary': QComboBox, - 'secondary': QComboBox, - } - self.ground_desc: QPlainTextEdit - - self.skill_bonus_bars = { - 'eng': [None] * 24, - 'sci': [None] * 24, - 'tac': [None] * 24, - 'ground': [None] * 10, - } - self.skill_count_ground: QLabel - self.skill_counts_space: dict = { - 'eng': None, - 'sci': None, - 'tac': None - } - - self.build: dict = { - 'space': { - 'active_rep_traits': [None] * 5, - 'aft_weapons': [None] * 5, - 'aft_weapons_label': None, - 'boffs': [[None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4, [None] * 4], - 'boff_labels': [None] * 6, - 'boff_label_icons': [None] * 6, - # 'boff_specs': [None] * 6, - 'core': [''], - 'deflector': [''], - 'devices': [None] * 6, - 'doffs_spec': [''] * 6, - 'doffs_variant': [''] * 6, - 'eng_consoles': [None] * 5, - 'eng_consoles_label': None, - 'engines': [''], - 'experimental': [None], - 'experimental_label': None, - 'fore_weapons': [None] * 5, - 'hangars': [None] * 2, - 'hangars_label': None, - 'rep_traits': [None] * 5, - 'sci_consoles': [None] * 5, - 'sci_consoles_label': None, - 'sec_def': [None], - 'sec_def_label': None, - 'shield': [''], - # 'ship': '', - # 'ship_desc': '', - # 'ship_name': '', - 'starship_traits': [None] * 7, - 'tac_consoles': [None] * 5, - 'tac_consoles_label': None, - # 'tier': '', - 'traits': [None] * 12, - 'uni_consoles': [None] * 3, - 'uni_consoles_label': None - }, - 'ground': { - 'active_rep_traits': [None] * 5, - 'armor': [''], - 'boffs': [[''] * 4, [''] * 4, [''] * 4, [''] * 4], - 'boff_profs': [''] * 4, - 'boff_specs': [''] * 4, - 'ground_devices': [None] * 5, - 'doffs_spec': [''] * 6, - 'doffs_variant': [''] * 6, - 'ev_suit': [''], - 'kit': [''], - 'kit_modules': [None] * 6, - 'rep_traits': [None] * 5, - 'personal_shield': [''], - 'traits': [None] * 12, - 'weapons': [''] * 2, - }, - 'space_skills': { - 'eng': [None] * 30, - 'sci': [None] * 30, - 'tac': [None] * 30 - }, - 'ground_skills': [ - [False] * 6, - [False] * 6, - [False] * 4, - [False] * 4, - ], - 'skill_unlocks': { - 'eng': [None] * 5, - 'sci': [None] * 5, - 'tac': [None] * 5, - 'ground': [None] * 5 - }, - 'skill_desc': { - 'space': None, - 'ground': None - } - } - - -class Cache(): - """ - Stores data - """ - def __init__(self): - self.reset_cache() - - def reset_cache(self, keep_static_data: bool = False): - self.ships: dict = dict() - self.equipment: dict = {type_: dict() for type_ in set(EQUIPMENT_TYPES.values())} - self.starship_traits: dict = dict() - self.traits: dict = { - 'space': { - 'traits': dict(), - 'rep_traits': dict(), - 'active_rep_traits': dict() - }, - 'ground': { - 'traits': dict(), - 'rep_traits': dict(), - 'active_rep_traits': dict() - } - } - self.ground_doffs: dict = dict() - self.space_doffs: dict = dict() - self.boff_abilities: dict = { - 'space': self.boff_dict(), - 'ground': self.boff_dict(), - 'all': dict() - } - - if not keep_static_data: - self.item_aliases: dict = dict() - self.skills = { - 'space': dict(), - 'space_unlocks': dict(), - 'ground': dict(), - 'ground_unlocks': dict(), - 'space_points_total': 0, - 'space_points_eng': 0, - 'space_points_sci': 0, - 'space_points_tac': 0, - 'space_points_rank': [0] * 5, - 'ground_points_total': 0, - } - - self.modifiers: dict = {type_: dict() for type_ in set(EQUIPMENT_TYPES.values())} - - self.empty_image: QImage - self.overlays: OverlayCache = OverlayCache() - self.icons: dict = dict() - self.images: dict = dict() - self.alt_images: dict = dict() - self.images_set: set = set() - self.images_populated: bool = False - self.images_failed: dict = dict() - - def boff_dict(self): - return { - 'Tactical': [list(), list(), list(), list()], - 'Engineering': [list(), list(), list(), list()], - 'Science': [list(), list(), list(), list()], - 'Intelligence': [list(), list(), list(), list()], - 'Command': [list(), list(), list(), list()], - 'Pilot': [list(), list(), list(), list()], - 'Temporal': [list(), list(), list(), list()], - 'Miracle Worker': [list(), list(), list(), list()], - } - - def __getitem__(self, key: str): - return getattr(self, key) - - -class OverlayCache(): - def __init__(self): - self.common: QImage - self.uncommon: QImage - self.rare: QImage - self.veryrare: QImage - self.ultrarare: QImage - self.epic: QImage - self.check: QImage + def switch(self, index): + """ + Callback to switch between tabs. Switches build and both sidebar tabs. + + Parameters: + - :param index: index to switch to (0: space build, 1: ground build, 2: space skills, + 3: ground skills, 4: library, 5: settings) + """ + self.build_tabber.setCurrentIndex(index) + self.sidebar_tabber.setCurrentIndex(index) + self.character_tabber.setCurrentIndex(CHAR_TAB_MAP[index]) class ImageLabel(QWidget): @@ -226,10 +50,10 @@ class ImageLabel(QWidget): Label displaying image that resizes according to its parents width while preserving aspect ratio. """ - def __init__(self, path: str = '', aspect_ratio: tuple[int, int] = (0, 0), *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, path: Path | None = None, aspect_ratio: tuple[int, int] = (0, 0)): + super().__init__() self._w, self._h = aspect_ratio - if path == '': + if path is None: self.p = QImage() else: self.p = QImage(path) @@ -243,7 +67,7 @@ def set_image(self, p: QImage): self._h = p.height() self.update() - def paintEvent(self, event): + def paintEvent(self, event: QPaintEvent): if not self.p.isNull(): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) @@ -466,77 +290,37 @@ def __init__(self, margins=0, spacing: int = 0, parent: QWidget = None): self.setSpacing(spacing) -class PySideThread(QThread): - def __init__(self, parent, finished_func, worker): - self.finished_func = finished_func - self.worker = worker - super().__init__(parent) - - def worker_finished(self): - if self.finished_func is not None: - self.finished_func() - self.quit() - - -class ThreadObject(QObject): - - start = Signal(tuple) - result = Signal(object) - update_splash = Signal(str) - finished = Signal() +class Thread(QThread): + """ + Thread based on QThread with convenience functionality. + """ + result: Signal = Signal(object) + done: Signal = Signal() - def __init__(self, func, *args, **kwargs) -> None: - self._func = func - self._args = args - self._kwargs = kwargs + def __init__(self, target: Callable, args: tuple = (), kwargs: dict[str] = {}): super().__init__() + self._target: Callable = target + self._args: tuple = args + self._kwargs: dict[str] = kwargs - @Slot() - def run(self, start_args=tuple()): - self._func(*self._args, *start_args, threaded_worker=self, **self._kwargs) - self.finished.emit() - - -def exec_in_thread( - self, func, *args, result=None, update_splash=None, finished=None, start_later=False, - **kwargs): - """ - Executes function `func` in separate thread. All positional and keyword parameters not listed - are passed to the function. The function must take a parameter `threaded_worker` which will - contain the worker object holding the signals: `start` (tuple), `result` (object), - `update_splash` (str), `finished` (no data) + def set_args(self, new_args: tuple) -> bool: + """ + Sets new arguments that should be passed to the target. Only works while thread is not + running. Returns `True` on success, `False` on failure. + """ + if self.isRunning(): + return False + else: + self._args = new_args + return True - Parameters: - - :param func: function to execute - - :param *args: positional parameters passed to the function [optional] - - :param result: callable that is executed when signal result is emitted (takes object) - [optional] - - :param update_splash: callable that is executed when signal update_splash is emitted - (takes str) [optional] - - :param finished: callable that is executed after `func` returns (takes no parameters) - [optional] - - :param start_later: set to True to defer execution of the function; makes this function - return signal that can be emitted to start execution. That signal takes a tuple with additional - positional parameters passed to `func` [optional] - - :param **kwargs: keyword parameters passed to the function [optional] - """ - worker = ThreadObject(func, *args, **kwargs) - thread = PySideThread(self.app, finished, worker) - if result is not None: - worker.result.connect(result) - if update_splash is not None: - worker.update_splash.connect(update_splash) - worker.moveToThread(thread) - if start_later: - worker.start.connect(worker.run) - else: - thread.started.connect(worker.run) - worker.finished.connect(thread.worker_finished) - thread.finished.connect(worker.deleteLater) - thread.finished.connect(thread.deleteLater) - thread.start(QThread.Priority.LowestPriority) - if start_later: - return worker.start + @Slot() + def run(self): + """ + This function will be executed in a separate thread. + """ + self.result.emit(self._target(*self._args, **self._kwargs)) + self.done.emit() class ShipButton(QLabel): @@ -560,46 +344,7 @@ def mousePressEvent(self, ev: QMouseEvent): super().mousePressEvent(ev) -TagStyles = namedtuple('TagStyles', ('ul', 'li', 'indent')) - -ItemSlot = namedtuple('ItemSlot', ('type', 'index', 'environment')) - - -class ContextMenu(QMenu): - """ - Custom context menu with data storage - """ - def __init__(self): - super().__init__() - self.clicked_slot: ItemSlot = None - self.clicked_boff_station: int = -1 - self.clicked_modifiers: dict = {} - self.copied_item: dict = None - self.copied_item_type: str = None - - def invoke(self, event: QMouseEvent, key: str, subkey: int, environment: str, boff: int = -1): - """ - Opens context menu for equipment - - Parameters: - - :param event: event containing the clicked point - - :param key: slot type in self.build[environment] - - :param subkey: slot index - - :param environment: "space" / "ground" - - :param boff: id of the boff station - """ - self.clicked_slot = ItemSlot(key, subkey, environment) - self.clicked_boff_station = boff - actions = self.actions() - if key in {'boffs', 'rep_traits', 'starship_traits', 'traits', 'active_rep_traits'}: - actions[0].setEnabled(False) - actions[1].setEnabled(False) - actions[4].setEnabled(False) - else: - actions[0].setEnabled(True) - actions[1].setEnabled(True) - actions[4].setEnabled(True) - self.exec(event.globalPos()) +ItemSlot = namedtuple('ItemSlot', ('environment', 'type', 'index', 'boff_id', 'is_equipment')) class DoffCombobox(QComboBox): @@ -621,6 +366,39 @@ def __iter__(self): return self._gen +class pixel_range(): + """ + Returns appropriate indices to access the RGB (not A) channels of the pixel row of `num` pixels, + as well as an 1-step increasing range index -> (range_index, pixel_index) + """ + def __init__(self, num: int = 0, range_start: int = 0, /): + def generator(): + counter = range_start + for index in range(0, num * 4, 4): + yield counter, index + counter += 1 + yield counter, index + 1 + counter += 1 + yield counter, index + 2 + counter += 1 + self.__gen = generator() + + def __iter__(self): + return self.__gen + + +def bundle[_T](*iterables: Iterable[_T]) -> Generator[_T, None, None]: + """ + Generator yielding the items of the given iterables in the order they were provided. + + Parameters: + - :param iterables: iterables to be bundled + """ + for inner_iterable in iterables: + for element in inner_iterable: + yield element + + class TooltipLabel(QLabel): """Label with tooltip""" def __init__(self, text: str, tooltip: QLabel):