mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 00:55:09 -07:00
Compare commits
39 commits
91519166f9
...
04dea3061b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04dea3061b | ||
|
|
f9d2fd3c34 | ||
|
|
dc67aed05e | ||
|
|
6823d54c1e | ||
|
|
8ca749c07d | ||
|
|
ba2900dcb9 | ||
|
|
a289d61765 | ||
|
|
073ec29c4d | ||
|
|
a5e94d8a4f | ||
|
|
b3c426cd43 | ||
|
|
c97e1c4149 | ||
|
|
ec41c103d1 | ||
|
|
d9cd2d1750 | ||
|
|
1405952f1b | ||
|
|
db2e159dca | ||
|
|
cb19922e55 | ||
|
|
ec39ec611b | ||
|
|
662f1b79cc | ||
|
|
eb1e34c5a6 | ||
|
|
dce9efcaa3 | ||
|
|
c45cb8ac32 | ||
|
|
1a6d9d7749 | ||
|
|
faffb5a837 | ||
|
|
9acb9739b2 | ||
|
|
59dd052143 | ||
|
|
51365960fe | ||
|
|
7b39cf98d5 | ||
|
|
6c1d1c7b58 | ||
|
|
456db56058 | ||
|
|
cca4af0ec7 | ||
|
|
e11c915a0c | ||
|
|
1c93309952 | ||
|
|
5ace88c111 | ||
|
|
69d32ed853 | ||
|
|
f4d5fc181d | ||
|
|
733deac0bb | ||
|
|
2a3a8982a6 | ||
|
|
87443d58f7 | ||
|
|
61c1215a30 |
277 changed files with 10423 additions and 1020 deletions
|
|
@ -18,12 +18,12 @@ RUN apt-get update && \
|
|||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-declarative-dev \
|
||||
qt6-image-formats-plugins \
|
||||
qt6-l10n-tools \
|
||||
qt6-multimedia-dev \
|
||||
qt6-declarative-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-shadertools-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-tools-dev \
|
||||
qt6-tools-dev-tools \
|
||||
qt6-websockets-dev \
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ RUN apt-get update && \
|
|||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-declarative-dev \
|
||||
qt6-image-formats-plugins \
|
||||
qt6-l10n-tools \
|
||||
qt6-multimedia-dev \
|
||||
qt6-declarative-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-shadertools-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-tools-dev \
|
||||
qt6-tools-dev-tools \
|
||||
qt6-websockets-dev \
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ RUN dnf install -y \
|
|||
ninja-build \
|
||||
openssl-devel \
|
||||
protobuf-devel \
|
||||
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
|
||||
qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \
|
||||
qt6-qtimageformats \
|
||||
rpm-build \
|
||||
xz-devel \
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ RUN dnf install -y \
|
|||
ninja-build \
|
||||
openssl-devel \
|
||||
protobuf-devel \
|
||||
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
|
||||
qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \
|
||||
qt6-qtimageformats \
|
||||
rpm-build \
|
||||
xz-devel \
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ RUN apt-get update && \
|
|||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-declarative-dev \
|
||||
qt6-image-formats-plugins \
|
||||
qt6-l10n-tools \
|
||||
qt6-multimedia-dev \
|
||||
qt6-declarative-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-shadertools-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-tools-dev \
|
||||
qt6-tools-dev-tools \
|
||||
qt6-websockets-dev \
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ RUN apt-get update && \
|
|||
libssl-dev \
|
||||
ninja-build \
|
||||
protobuf-compiler \
|
||||
qt6-declarative-dev \
|
||||
qt6-image-formats-plugins \
|
||||
qt6-l10n-tools \
|
||||
qt6-multimedia-dev \
|
||||
qt6-declarative-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-shadertools-dev \
|
||||
qt6-svg-dev \
|
||||
qt6-tools-dev \
|
||||
qt6-tools-dev-tools \
|
||||
qt6-websockets-dev \
|
||||
|
|
|
|||
|
|
@ -327,4 +327,32 @@ if [[ $MAKE_PACKAGE ]]; then
|
|||
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if [[ $RUNNER_OS == Windows ]]; then
|
||||
echo "::group::Check installer for build-tree artifacts"
|
||||
cd "$BUILD_DIR"
|
||||
package="$(find . -maxdepth 1 -type f -name 'Cockatrice-*.exe' -print -quit)"
|
||||
if [[ ! $package ]]; then
|
||||
echo "::error file=$0::Could not find installer to inspect"
|
||||
exit 1
|
||||
fi
|
||||
seven_zip="$(command -v 7z || true)"
|
||||
if [[ ! $seven_zip ]]; then
|
||||
seven_zip="/c/Program Files/7-Zip/7z.exe"
|
||||
fi
|
||||
if [[ ! -f $seven_zip ]]; then
|
||||
echo "::warning file=$0::7-Zip not found, skipping installer content check"
|
||||
else
|
||||
echo "Inspecting $package"
|
||||
# Fail the build if the installer contains any path left behind by the MSBuild or
|
||||
# Qt AUTOMOC tooling (build-tree artifacts must live in the build dir, not the install)
|
||||
if "$seven_zip" l "$package" |
|
||||
grep -E "_autogen|\.dir[\\/]|\.tlog|(^|[\\/])x64[\\/]|(^|[\\/])\.qt[\\/]|(^|[\\/])\.qsb[\\/]|(^|[\\/])\.lupdate[\\/]|CMakeFiles"; then
|
||||
echo "::error file=$0::Installer contains build-tree artifacts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installer content is clean"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
43
.github/workflows/desktop-build.yml
vendored
43
.github/workflows/desktop-build.yml
vendored
|
|
@ -272,8 +272,8 @@ jobs:
|
|||
make_package: 1
|
||||
override_target: 13
|
||||
package_suffix: "-macOS13_Intel"
|
||||
qt_version: 6.11.1
|
||||
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
|
||||
qt_version: 6.11.*
|
||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
soc: Intel
|
||||
type: Release
|
||||
use_ccache: 1
|
||||
|
|
@ -288,8 +288,8 @@ jobs:
|
|||
make_package: 1
|
||||
override_target: 14
|
||||
package_suffix: "-macOS14"
|
||||
qt_version: 6.11.1
|
||||
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
|
||||
qt_version: 6.11.*
|
||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
soc: Apple
|
||||
type: Release
|
||||
use_ccache: 1
|
||||
|
|
@ -304,8 +304,8 @@ jobs:
|
|||
make_package: 1
|
||||
override_target: 15
|
||||
package_suffix: "-macOS15"
|
||||
qt_version: 6.11.1
|
||||
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
|
||||
qt_version: 6.11.*
|
||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
soc: Apple
|
||||
type: Release
|
||||
use_ccache: 1
|
||||
|
|
@ -317,8 +317,8 @@ jobs:
|
|||
|
||||
ccache_eviction_age: 7d
|
||||
cmake_generator: Ninja
|
||||
qt_version: 6.11.1
|
||||
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
|
||||
qt_version: 6.11.*
|
||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
soc: Apple
|
||||
type: Debug
|
||||
use_ccache: 1
|
||||
|
|
@ -332,8 +332,8 @@ jobs:
|
|||
cmake_generator_platform: x64
|
||||
make_package: 1
|
||||
package_suffix: "-Win10"
|
||||
qt_version: 6.11.1
|
||||
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
|
||||
qt_version: 6.11.*
|
||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
type: Release
|
||||
|
||||
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
|
||||
|
|
@ -368,18 +368,20 @@ jobs:
|
|||
key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }}
|
||||
path: ${{ env.CCACHE_DIR }}
|
||||
|
||||
- name: "Install aqtinstall"
|
||||
- name: "[macOS] Install aqtinstall"
|
||||
if: matrix.os == 'macOS'
|
||||
run: pipx install aqtinstall
|
||||
|
||||
# Resolve given wildcard versions (e.g. Qt 6.6.*) to latest version via aqtinstall to avoid stale caches on new releases
|
||||
- name: "Resolve latest Qt patch version"
|
||||
- name: "[macOS] Resolve latest Qt from ${{ matrix.qt_version }} input"
|
||||
if: matrix.os == 'macOS'
|
||||
env:
|
||||
QT_VERSION: ${{ matrix.qt_version }}
|
||||
id: resolve_qt_version
|
||||
shell: bash
|
||||
run: .ci/resolve_latest_aqt_qt_version.sh "$QT_VERSION"
|
||||
|
||||
- name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries"
|
||||
- name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}"
|
||||
if: matrix.os == 'macOS'
|
||||
id: restore_qt
|
||||
uses: actions/cache/restore@v6
|
||||
|
|
@ -389,21 +391,22 @@ jobs:
|
|||
|
||||
# Using jurplel/install-qt-action to install Qt without using brew
|
||||
# Qt build using vcpkg either just fails or takes too long to build
|
||||
- name: "[macOS] Install fat Qt ${{ steps.resolve_qt_version.outputs.version }}"
|
||||
- name: "[macOS] Install fat Qt ${{ matrix.qt_version }}"
|
||||
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
cache: false
|
||||
dir: ${{ github.workspace }}
|
||||
# cache-key-prefix: Qt
|
||||
dir: ${{ github.workspace }} # thinning script depends on this location
|
||||
modules: ${{ matrix.qt_modules }}
|
||||
version: ${{ steps.resolve_qt_version.outputs.version }}
|
||||
version: ${{ matrix.qt_version }}
|
||||
|
||||
- name: "[macOS] Create thin Qt libraries"
|
||||
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
|
||||
run: .ci/thin_macos_qtlib.sh
|
||||
|
||||
- name: "[macOS] Cache thin Qt libraries"
|
||||
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
|
||||
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
key: ${{ steps.restore_qt.outputs.cache-primary-key }}
|
||||
|
|
@ -415,10 +418,10 @@ jobs:
|
|||
with:
|
||||
# Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released
|
||||
aqtsource: git+https://github.com/miurahr/aqtinstall.git
|
||||
cache: true
|
||||
cache: ${{ github.ref == 'refs/heads/master' }}
|
||||
cache-key-prefix: Qt
|
||||
modules: ${{ matrix.qt_modules }}
|
||||
version: ${{ steps.resolve_qt_version.outputs.version }}
|
||||
version: ${{ matrix.qt_version }}
|
||||
|
||||
- name: "[Windows] Install NSIS"
|
||||
if: matrix.os == 'Windows'
|
||||
|
|
@ -450,7 +453,7 @@ jobs:
|
|||
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
|
||||
TARGET_MACOS_VERSION: ${{ matrix.override_target }}
|
||||
USE_CCACHE: ${{ matrix.use_ccache }}
|
||||
VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite'
|
||||
VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}"
|
||||
VCPKG_DISABLE_METRICS: 1
|
||||
VCPKG_FEATURE_FLAGS: dependencygraph
|
||||
run: .ci/compile.sh --server --test --vcpkg
|
||||
|
|
|
|||
2
.github/workflows/docker-release.yml
vendored
2
.github/workflows/docker-release.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
|
||||
cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }}
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ if(UNIX)
|
|||
if(CPACK_GENERATOR STREQUAL "RPM")
|
||||
set(CPACK_RPM_PACKAGE_LICENSE "GPLv2")
|
||||
set(CPACK_RPM_MAIN_COMPONENT "cockatrice")
|
||||
set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats")
|
||||
set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qtimageformats, qt6-qtmultimedia, qt6-qtsvg, qt6-qttools")
|
||||
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
|
||||
set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice")
|
||||
# stop directories from making package conflicts
|
||||
|
|
@ -311,7 +311,7 @@ if(UNIX)
|
|||
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
|
||||
set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice")
|
||||
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins")
|
||||
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-image-formats-plugins, qt6-qpa-plugins")
|
||||
set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db
|
||||
endif()
|
||||
endif()
|
||||
|
|
|
|||
|
|
@ -387,19 +387,14 @@ SectionEnd
|
|||
|
||||
Section "un.Application" UnSecApplication
|
||||
SetShellVarContext all
|
||||
RMDir /r "$INSTDIR\plugins"
|
||||
RMDir /r "$INSTDIR\sounds"
|
||||
RMDir /r "$INSTDIR\themes"
|
||||
RMDir /r "$INSTDIR\translations"
|
||||
Delete "$INSTDIR\*.exe"
|
||||
Delete "$INSTDIR\*.dll"
|
||||
Delete "$INSTDIR\qt.conf"
|
||||
Delete "$INSTDIR\qdebug.txt"
|
||||
Delete "$INSTDIR\servatrice.sql"
|
||||
Delete "$INSTDIR\servatrice.ini.example"
|
||||
RMDir "$INSTDIR"
|
||||
|
||||
RMDir "$SMPROGRAMS\Cockatrice"
|
||||
; Remove the entire application directory so any file that is not part of
|
||||
; the installed payload (e.g. build-tree artifacts such as *.dir folders,
|
||||
; *_autogen and *.tlog files from a build) cannot survive between an
|
||||
; uninstall and a fresh reinstall.
|
||||
RMDir /r "$INSTDIR"
|
||||
|
||||
RMDir /r "$SMPROGRAMS\Cockatrice"
|
||||
|
||||
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
|
||||
SectionEnd
|
||||
|
|
|
|||
|
|
@ -45,11 +45,14 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp
|
||||
src/interface/widgets/dialogs/dlg_load_remote_deck.cpp
|
||||
src/interface/widgets/dialogs/dlg_local_game_options.cpp
|
||||
src/interface/widgets/dialogs/dlg_login_prompt.cpp
|
||||
src/interface/widgets/dialogs/dlg_manage_sets.cpp
|
||||
src/interface/widgets/dialogs/dlg_my_reports.cpp
|
||||
src/interface/widgets/dialogs/dlg_register.cpp
|
||||
src/interface/widgets/dialogs/dlg_report_user.cpp
|
||||
src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp
|
||||
src/interface/widgets/dialogs/dlg_share_deck.cpp
|
||||
src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp
|
||||
src/interface/widgets/dialogs/dlg_settings.cpp
|
||||
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
|
||||
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
|
||||
|
|
@ -57,6 +60,9 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||
src/interface/widgets/dialogs/override_printing_warning.cpp
|
||||
src/interface/widgets/dialogs/tip_of_the_day.cpp
|
||||
src/interface/widgets/deck_share/deck_share_utils.cpp
|
||||
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
|
||||
src/interface/widgets/deck_share/share_bar_widget.cpp
|
||||
src/filters/deck_filter_string.cpp
|
||||
src/filters/filter_builder.cpp
|
||||
src/filters/filter_tree_model.cpp
|
||||
|
|
@ -163,6 +169,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/palette_editor/palette_grid_widget.cpp
|
||||
src/interface/palette_editor/palette_editor_dialog.cpp
|
||||
src/interface/widgets/cards/additional_info/color_identity_widget.cpp
|
||||
src/interface/widgets/cards/additional_info/deck_color_identity.cpp
|
||||
src/interface/widgets/cards/additional_info/mana_cost_widget.cpp
|
||||
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
|
||||
src/interface/widgets/cards/art_crop_attribution.cpp
|
||||
|
|
@ -317,6 +324,8 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp
|
||||
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp
|
||||
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp
|
||||
src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp
|
||||
src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp
|
||||
src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp
|
||||
src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp
|
||||
src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp
|
||||
|
|
@ -388,6 +397,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/tabs/tab_logs.cpp
|
||||
src/interface/widgets/tabs/tab_message.cpp
|
||||
src/interface/widgets/tabs/tab_moderation.cpp
|
||||
src/interface/widgets/tabs/tab_public_decks.cpp
|
||||
src/interface/widgets/tabs/tab_report.cpp
|
||||
src/interface/widgets/tabs/tab_replays.cpp
|
||||
src/interface/widgets/tabs/tab_room.cpp
|
||||
|
|
@ -403,6 +413,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
|
||||
src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp
|
||||
src/interface/widgets/onboarding/banner_shader_config.h
|
||||
src/interface/widgets/onboarding/brand_colors.h
|
||||
src/interface/widgets/onboarding/first_run_wizard.cpp
|
||||
src/interface/widgets/onboarding/first_run_wizard.h
|
||||
src/interface/widgets/onboarding/first_run_wizard_page.cpp
|
||||
|
|
@ -443,6 +454,8 @@ set(cockatrice_SOURCES
|
|||
src/interface/intents/intent_login.h
|
||||
src/interface/intents/intent_open_server_room_by_name.cpp
|
||||
src/interface/intents/intent_open_server_room_by_name.h
|
||||
src/interface/intents/intent_open_shared_deck.cpp
|
||||
src/interface/intents/intent_open_shared_deck.h
|
||||
src/interface/intents/url_parser.cpp
|
||||
src/interface/intents/url_parser.h
|
||||
src/interface/widgets/server/user/user_info_popup.cpp
|
||||
|
|
@ -530,6 +543,7 @@ qt6_add_shaders(
|
|||
"src/interface/widgets/onboarding/shaders"
|
||||
FILES
|
||||
src/interface/widgets/onboarding/shaders/brand_banner.frag
|
||||
src/interface/widgets/onboarding/shaders/brand_plate.frag
|
||||
)
|
||||
|
||||
qt6_add_resources(
|
||||
|
|
@ -654,18 +668,35 @@ if(WIN32)
|
|||
set(qtconf_dest_dir .)
|
||||
|
||||
install(
|
||||
DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/"
|
||||
DIRECTORY "$<TARGET_FILE_DIR:cockatrice>/"
|
||||
DESTINATION ./
|
||||
FILES_MATCHING
|
||||
PATTERN "*.dll"
|
||||
PATTERN "*.pdb" EXCLUDE
|
||||
PATTERN "*.dir*" EXCLUDE
|
||||
PATTERN "*_autogen*" EXCLUDE
|
||||
PATTERN "*.tlog*" EXCLUDE
|
||||
PATTERN "CMakeFiles*" EXCLUDE
|
||||
PATTERN "x64*" EXCLUDE
|
||||
PATTERN ".qt*" EXCLUDE
|
||||
PATTERN ".qsb*" EXCLUDE
|
||||
PATTERN ".lupdate*" EXCLUDE
|
||||
)
|
||||
|
||||
install(
|
||||
DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/"
|
||||
DESTINATION ./
|
||||
FILES_MATCHING
|
||||
PATTERN "CMakeFiles" EXCLUDE
|
||||
PATTERN "*.ini"
|
||||
PATTERN "CMakeFiles*" EXCLUDE
|
||||
PATTERN "*.dir*" EXCLUDE
|
||||
PATTERN "*_autogen*" EXCLUDE
|
||||
PATTERN "*.tlog*" EXCLUDE
|
||||
PATTERN "*.pdb" EXCLUDE
|
||||
PATTERN "x64*" EXCLUDE
|
||||
PATTERN ".qt*" EXCLUDE
|
||||
PATTERN ".qsb*" EXCLUDE
|
||||
PATTERN ".lupdate*" EXCLUDE
|
||||
)
|
||||
|
||||
# Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls
|
||||
|
|
@ -718,10 +749,6 @@ Data = Resources\")
|
|||
"
|
||||
COMPONENT Runtime
|
||||
)
|
||||
|
||||
if(OPENSSL_FOUND)
|
||||
install(FILES ${OPENSSL_INCLUDE_DIRS} DESTINATION ./)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(Qt6LinguistTools_FOUND)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@
|
|||
<file>resources/icons/mana/W.svg</file>
|
||||
|
||||
<file>resources/backgrounds/home.png</file>
|
||||
<file>resources/backgrounds/home-dark.png</file>
|
||||
<file>resources/backgrounds/home-light.png</file>
|
||||
<file>resources/backgrounds/card_triplet.svg</file>
|
||||
<file>resources/backgrounds/placeholder_printing_selector.svg</file>
|
||||
|
||||
|
|
@ -363,6 +365,8 @@
|
|||
|
||||
<file>resources/usericons/pawn_single.svg</file>
|
||||
<file>resources/usericons/pawn_double.svg</file>
|
||||
<file>resources/usericons/pawn_dev_single.svg</file>
|
||||
<file>resources/usericons/pawn_dev_double.svg</file>
|
||||
<file>resources/usericons/pawn_donator_single.svg</file>
|
||||
<file>resources/usericons/pawn_donator_double.svg</file>
|
||||
<file>resources/usericons/pawn_judge_single.svg</file>
|
||||
|
|
|
|||
BIN
cockatrice/resources/backgrounds/home-dark.png
Normal file
BIN
cockatrice/resources/backgrounds/home-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
BIN
cockatrice/resources/backgrounds/home-light.png
Normal file
BIN
cockatrice/resources/backgrounds/home-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 16 MiB |
343
cockatrice/resources/usericons/pawn_dev_double.svg
Normal file
343
cockatrice/resources/usericons/pawn_dev_double.svg
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
id="svg5322"
|
||||
version="1.1"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
sodipodi:docname="pawn_dev_double.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs3">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 526.18109 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="744.09448 : 526.18109 : 1"
|
||||
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
|
||||
id="perspective5328"/>
|
||||
<inkscape:perspective
|
||||
id="perspective5305"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<linearGradient
|
||||
id="linearGradient5181">
|
||||
<stop
|
||||
style="stop-color:#0fbb00;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5183"/>
|
||||
<stop
|
||||
style="stop-color:#064400;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5185"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-2"
|
||||
id="radialGradient3606-7"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600-2">
|
||||
<stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-4"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-9"/>
|
||||
</linearGradient>
|
||||
<inkscape:perspective
|
||||
id="perspective5478"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<linearGradient
|
||||
id="linearGradient5189">
|
||||
<stop
|
||||
style="stop-color:#000ec9;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5191"/>
|
||||
<stop
|
||||
style="stop-color:#000657;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5193"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-4"
|
||||
id="radialGradient3606-1"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600-4">
|
||||
<stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-3"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-5"/>
|
||||
</linearGradient>
|
||||
<inkscape:perspective
|
||||
id="perspective5559"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5173"
|
||||
id="linearGradient5179"
|
||||
x1="167.33386"
|
||||
y1="178.83276"
|
||||
x2="244.78181"
|
||||
y2="178.83276"
|
||||
gradientUnits="userSpaceOnUse"/>
|
||||
<linearGradient
|
||||
id="linearGradient5173">
|
||||
<stop
|
||||
style="stop-color:#f50000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5175"/>
|
||||
<stop
|
||||
style="stop-color:#950000;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5177"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600"
|
||||
id="radialGradient5169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600">
|
||||
<stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
r="25.501276"
|
||||
fy="131.40274"
|
||||
fx="324.32715"
|
||||
cy="131.40274"
|
||||
cx="324.32715"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="radialGradient5574"
|
||||
xlink:href="#linearGradient3600"
|
||||
inkscape:collect="always"/>
|
||||
<inkscape:perspective
|
||||
id="perspective5663"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
id="radialGradient3606-8"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600-7">
|
||||
<stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-7"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-6"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
id="radialGradient5254"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"/>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5189-1"
|
||||
id="linearGradient5394"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="385.03503"
|
||||
y1="180.09546"
|
||||
x2="462.48297"
|
||||
y2="180.09546"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-360.365,847.52359)"/>
|
||||
<linearGradient
|
||||
id="linearGradient5189-1">
|
||||
<stop
|
||||
style="stop-color:#000ec9;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5191-0"/>
|
||||
<stop
|
||||
style="stop-color:#000657;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5193-4"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5173-1"
|
||||
id="linearGradient5581"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="167.33386"
|
||||
y1="178.83276"
|
||||
x2="244.78181"
|
||||
y2="178.83276"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-149.54484,848.74636)"/>
|
||||
<linearGradient
|
||||
id="linearGradient5173-1">
|
||||
<stop
|
||||
style="stop-color:#f50000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5175-5"/>
|
||||
<stop
|
||||
style="stop-color:#950000;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5177-3"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5181-9"
|
||||
id="linearGradient5782"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="282.50455"
|
||||
y1="181.61069"
|
||||
x2="359.95248"
|
||||
y2="181.61069"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-261.07526,846.05625)"/>
|
||||
<linearGradient
|
||||
id="linearGradient5181-9">
|
||||
<stop
|
||||
style="stop-color:#80d600;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5183-3"/>
|
||||
<stop
|
||||
style="stop-color:#80d600;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5185-0"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
y2="181.61069"
|
||||
x2="359.95248"
|
||||
y1="181.61069"
|
||||
x1="282.50455"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-175.71812,893.2775)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="linearGradient5799"
|
||||
xlink:href="#linearGradient5181-9"
|
||||
inkscape:collect="always"/>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:document-units="mm"
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="6.0735294"
|
||||
inkscape:cx="53.757869"
|
||||
inkscape:cy="53.840194"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1696"
|
||||
inkscape:window-height="1051"
|
||||
inkscape:window-x="98"
|
||||
inkscape:window-y="1118"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showguides="true">
|
||||
<sodipodi:guide
|
||||
position="49.829627,61.114263"
|
||||
orientation="1,0"
|
||||
id="guide1"
|
||||
inkscape:locked="false"/>
|
||||
</sodipodi:namedview>
|
||||
<metadata
|
||||
id="metadata4">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:title/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-952.36218)">
|
||||
<path
|
||||
style="fill-opacity:1;stroke:black;stroke-width:2.78220296;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
|
||||
d="M 49.84375 1.71875 C 36.719738 1.71875 26.0625 12.375988 26.0625 25.5 C 26.0625 32.977454 29.538325 39.612734 34.9375 43.96875 C 24.439951 49.943698 17.919149 62.196126 14.3125 75.65625 C 9.0380874 95.34065 30.224013 98.21875 49.84375 98.21875 C 69.463486 98.21875 90.549327 94.96715 85.375 75.65625 C 81.693381 61.916246 75.224585 49.827177 64.8125 43.9375 C 70.181573 39.580662 73.59375 32.953205 73.59375 25.5 C 73.59375 12.375988 62.967762 1.71875 49.84375 1.71875 z "
|
||||
transform="translate(0,952.36218)"
|
||||
id="left"/>
|
||||
<path
|
||||
style="opacity:1;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.73577702;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 51.28696,1001.834 0,-46.98372 1.434151,0.16768 c 5.155008,0.60274 9.462857,2.72154 12.938257,6.36366 4.74393,4.9715 6.87913,11.35611 6.16464,18.43328 -0.53702,5.31935 -3.09008,10.59498 -6.83833,14.13074 l -1.94072,1.83069 3.04083,2.20427 c 3.58084,2.5957 7.18975,6.4912 9.55296,10.3116 4.89572,7.9144 9.23593,21.4918 8.50487,26.6055 -0.81312,5.6877 -5.43872,9.6977 -13.62216,11.8093 -3.80822,0.9826 -7.68056,1.4713 -14.763321,1.8633 l -4.471177,0.2474 0,-46.9837 z"
|
||||
id="right"
|
||||
inkscape:connector-curvature="0"/>
|
||||
<path
|
||||
d="m 478.409,116.617 c -0.368,-4.271 -3.181,-7.94 -7.2,-9.403 -4.029,-1.472 -8.539,-0.47 -11.57,2.556 l -62.015,62.011 -68.749,-21.768 -21.768,-68.748 62.016,-62.016 c 3.035,-3.032 4.025,-7.543 2.563,-11.565 -1.477,-4.03 -5.137,-6.837 -9.417,-7.207 -37.663,-3.245 -74.566,10.202 -101.247,36.887 -36.542,36.545 -46.219,89.911 -29.083,135.399 -1.873,1.578 -3.721,3.25 -5.544,5.053 L 19.386,373.152 c -0.073,0.071 -0.145,0.149 -0.224,0.219 -24.345,24.346 -24.345,63.959 0,88.309 24.349,24.344 63.672,24.048 88.013,-0.298 0.105,-0.098 0.201,-0.196 0.297,-0.305 L 301.104,252.456 c 1.765,-1.773 3.404,-3.628 4.949,-5.532 45.5,17.167 98.9,7.513 135.474,-29.056 26.675,-26.687 40.131,-63.593 36.882,-101.251 z M 75.98,435.38 c -8.971,8.969 -23.5,8.963 -32.47,0 -8.967,-8.961 -8.967,-23.502 0,-32.466 8.97,-8.963 23.499,-8.963 32.47,0 8.967,8.964 8.967,23.505 0,32.466 z"
|
||||
id="path1"
|
||||
style="display:inline;fill:#A1A1A1;stroke-width:9.87059588;stroke-dasharray:none;fill-opacity:1;stroke:#000000;stroke-opacity:1"
|
||||
transform="matrix(0.19145387,0,0,0.19145387,4.0816072,956.99677)"
|
||||
inkscape:label="wrench" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
211
cockatrice/resources/usericons/pawn_dev_single.svg
Normal file
211
cockatrice/resources/usericons/pawn_dev_single.svg
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
id="svg5322"
|
||||
version="1.1"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
sodipodi:docname="pawn_dev_single.svg"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
|
||||
id="defs3"><inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 526.18109 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="744.09448 : 526.18109 : 1"
|
||||
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
|
||||
id="perspective5328" /><inkscape:perspective
|
||||
id="perspective5305"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><linearGradient
|
||||
id="linearGradient5181"><stop
|
||||
style="stop-color:#0fbb00;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5183" /><stop
|
||||
style="stop-color:#064400;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5185" /></linearGradient><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-2"
|
||||
id="radialGradient3606-7"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
|
||||
id="linearGradient3600-2"><stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-4" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-9" /></linearGradient><inkscape:perspective
|
||||
id="perspective5478"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><linearGradient
|
||||
id="linearGradient5189"><stop
|
||||
style="stop-color:#000ec9;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5191" /><stop
|
||||
style="stop-color:#000657;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5193" /></linearGradient><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-4"
|
||||
id="radialGradient3606-1"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
|
||||
id="linearGradient3600-4"><stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-3" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-5" /></linearGradient><inkscape:perspective
|
||||
id="perspective5559"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5173"
|
||||
id="linearGradient5179"
|
||||
x1="167.33386"
|
||||
y1="178.83276"
|
||||
x2="244.78181"
|
||||
y2="178.83276"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient5173"><stop
|
||||
style="stop-color:#f50000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5175" /><stop
|
||||
style="stop-color:#950000;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5177" /></linearGradient><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600"
|
||||
id="radialGradient5169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276" /><linearGradient
|
||||
id="linearGradient3600"><stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604" /></linearGradient><radialGradient
|
||||
r="25.501276"
|
||||
fy="131.40274"
|
||||
fx="324.32715"
|
||||
cy="131.40274"
|
||||
cx="324.32715"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="radialGradient5574"
|
||||
xlink:href="#linearGradient3600"
|
||||
inkscape:collect="always" /><inkscape:perspective
|
||||
id="perspective5663"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
id="radialGradient3606-8"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
|
||||
id="linearGradient3600-7"><stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-7" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-6" /></linearGradient><radialGradient
|
||||
r="25.501276"
|
||||
fy="131.40274"
|
||||
fx="324.32715"
|
||||
cy="131.40274"
|
||||
cx="324.32715"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="radialGradient5676"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||
inkscape:document-units="mm"
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="3.1582091"
|
||||
inkscape:cx="104.64791"
|
||||
inkscape:cy="56.04442"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1027"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showguides="true"><sodipodi:guide
|
||||
position="50.002551,111.99556"
|
||||
orientation="1,0"
|
||||
id="guide3"
|
||||
inkscape:locked="false" /></sodipodi:namedview><metadata
|
||||
id="metadata4"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-952.36218)"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;opacity:1;fill-opacity:1;stroke:#000000;stroke-width:2.7822;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 49.84375,1.71875 C 36.719738,1.71875 26.0625,12.375988 26.0625,25.5 c 0,7.477454 3.475825,14.112734 8.875,18.46875 -10.497549,5.974948 -17.018351,18.227376 -20.625,31.6875 -5.2744126,19.6844 15.911513,22.5625 35.53125,22.5625 19.619736,0 40.705577,-3.2516 35.53125,-22.5625 C 81.693381,61.916246 75.224585,49.827177 64.8125,43.9375 70.181573,39.580662 73.59375,32.953205 73.59375,25.5 c 0,-13.124012 -10.625988,-23.78125 -23.75,-23.78125 z"
|
||||
id="left"
|
||||
sodipodi:insensitive="true"
|
||||
transform="translate(0,952.36218)" />
|
||||
<path
|
||||
d="m 478.409,116.617 c -0.368,-4.271 -3.181,-7.94 -7.2,-9.403 -4.029,-1.472 -8.539,-0.47 -11.57,2.556 l -62.015,62.011 -68.749,-21.768 -21.768,-68.748 62.016,-62.016 c 3.035,-3.032 4.025,-7.543 2.563,-11.565 -1.477,-4.03 -5.137,-6.837 -9.417,-7.207 -37.663,-3.245 -74.566,10.202 -101.247,36.887 -36.542,36.545 -46.219,89.911 -29.083,135.399 -1.873,1.578 -3.721,3.25 -5.544,5.053 L 19.386,373.152 c -0.073,0.071 -0.145,0.149 -0.224,0.219 -24.345,24.346 -24.345,63.959 0,88.309 24.349,24.344 63.672,24.048 88.013,-0.298 0.105,-0.098 0.201,-0.196 0.297,-0.305 L 301.104,252.456 c 1.765,-1.773 3.404,-3.628 4.949,-5.532 45.5,17.167 98.9,7.513 135.474,-29.056 26.675,-26.687 40.131,-63.593 36.882,-101.251 z M 75.98,435.38 c -8.971,8.969 -23.5,8.963 -32.47,0 -8.967,-8.961 -8.967,-23.502 0,-32.466 8.97,-8.963 23.499,-8.963 32.47,0 8.967,8.964 8.967,23.505 0,32.466 z"
|
||||
id="path1"
|
||||
style="display:inline;fill:#A1A1A1;stroke-width:9.87059588;stroke-dasharray:none;fill-opacity:1;stroke:#000000;stroke-opacity:1"
|
||||
transform="matrix(0.19145387,0,0,0.19145387,4.0816072,956.99677)"
|
||||
inkscape:label="wrench" /></g></svg>
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
|
|
@ -1,6 +1,7 @@
|
|||
#include "abstract_card_item.h"
|
||||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../../interface/card_localization.h"
|
||||
#include "../../interface/card_picture_loader/card_picture_loader.h"
|
||||
#include "../game_scene.h"
|
||||
#include "../z_values.h"
|
||||
|
|
@ -26,6 +27,8 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
|
|||
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
|
||||
[this] { update(); });
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
|
||||
[this] { update(); });
|
||||
refreshCardInfo();
|
||||
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
|
||||
|
|
@ -171,7 +174,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
|
|||
if (SettingsCache::instance().debug().getShowCardId()) {
|
||||
prefix = "#" + QString::number(id) + " ";
|
||||
}
|
||||
nameStr = prefix + cardRef.name;
|
||||
nameStr = prefix + CardLocalization::displayName(getCardInfo());
|
||||
}
|
||||
painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor,
|
||||
translatedSize.height() - 6 * scaleFactor),
|
||||
|
|
|
|||
59
cockatrice/src/interface/card_localization.h
Normal file
59
cockatrice/src/interface/card_localization.h
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#ifndef COCKATRICE_CARD_LOCALIZATION_H
|
||||
#define COCKATRICE_CARD_LOCALIZATION_H
|
||||
|
||||
#include "../client/settings/cache_settings.h"
|
||||
|
||||
#include <QString>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
namespace CardLocalization
|
||||
{
|
||||
/**
|
||||
* @brief The language code selected for localized card text and images.
|
||||
*/
|
||||
inline QString displayLang()
|
||||
{
|
||||
return SettingsCache::instance().cardsDisplay().getCardLang();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Card name in the configured display language, falling back to English.
|
||||
* @param card The card to display.
|
||||
* @return The localized name, or an empty string for a null card.
|
||||
*/
|
||||
inline QString displayName(const CardInfoPtr &card)
|
||||
{
|
||||
return card.isNull() ? QString() : card->getLocalizedName(displayLang());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Card rules text in the configured display language, falling back to English.
|
||||
* @param card The card to display.
|
||||
* @return The localized text, or an empty string for a null card.
|
||||
*/
|
||||
inline QString displayText(const CardInfoPtr &card)
|
||||
{
|
||||
return card.isNull() ? QString() : card->getLocalizedText(displayLang());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Card name in the configured display language, falling back to English.
|
||||
* @param card The card to display.
|
||||
*/
|
||||
inline QString displayName(const CardInfo &card)
|
||||
{
|
||||
return card.getLocalizedName(displayLang());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Card rules text in the configured display language, falling back to English.
|
||||
* @param card The card to display.
|
||||
*/
|
||||
inline QString displayText(const CardInfo &card)
|
||||
{
|
||||
return card.getLocalizedText(displayLang());
|
||||
}
|
||||
} // namespace CardLocalization
|
||||
|
||||
#endif // COCKATRICE_CARD_LOCALIZATION_H
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
#include <QApplication>
|
||||
#include <QBuffer>
|
||||
#include <QDebug>
|
||||
#include <QDirIterator>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMainWindow>
|
||||
#include <QMovie>
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
#include <QThread>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/settings/cache_storage_settings.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <libcockatrice/settings/download_settings.h>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <utility>
|
||||
|
|
@ -37,8 +38,10 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
|
|||
&CardPictureLoader::picsPathChanged);
|
||||
connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this,
|
||||
&CardPictureLoader::picDownloadChanged);
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
|
||||
&CardPictureLoader::cardLangChanged);
|
||||
|
||||
qRegisterMetaType<ExactCard>();
|
||||
qRegisterMetaType<ExactCard>("ExactCard");
|
||||
connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded);
|
||||
|
||||
statusBar = new CardPictureLoaderStatusBar(nullptr);
|
||||
|
|
@ -206,7 +209,49 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
|
|||
card.emitPixmapUpdated();
|
||||
}
|
||||
|
||||
void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap)
|
||||
void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card)
|
||||
{
|
||||
const QString picsRoot = SettingsCache::instance().paths().getPicsPath();
|
||||
if (picsRoot.isEmpty() || !card) {
|
||||
return;
|
||||
}
|
||||
|
||||
QDir baseDir(picsRoot);
|
||||
if (!baseDir.cd("downloadedPics")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString name = card.getInfo().getCorrectedName();
|
||||
|
||||
QString set, collector, uuid;
|
||||
auto printing = card.getPrinting();
|
||||
if (printing.getSet()) {
|
||||
set = printing.getSet()->getCorrectedShortName();
|
||||
collector = printing.getProperty("num");
|
||||
uuid = printing.getUuid();
|
||||
}
|
||||
|
||||
for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) {
|
||||
QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid);
|
||||
|
||||
if (rel.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rel += ".png";
|
||||
rel = QDir::cleanPath(rel);
|
||||
|
||||
QString fullPath = baseDir.filePath(rel);
|
||||
|
||||
if (QFile::exists(fullPath)) {
|
||||
QFile::remove(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card,
|
||||
const QPixmap &pixmap,
|
||||
const bool allowOverwrite)
|
||||
{
|
||||
if (pixmap.isNull() || !card) {
|
||||
return;
|
||||
|
|
@ -266,8 +311,9 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const
|
|||
|
||||
QFileInfo outInfo(baseDir.filePath(relativePath));
|
||||
|
||||
// Do not overwrite existing files
|
||||
if (outInfo.exists()) {
|
||||
// Automatic cache writes (FILESYSTEM_CACHE) must never clobber an explicit user override.
|
||||
// Only the explicit override paths pass allowOverwrite == true.
|
||||
if (!allowOverwrite && outInfo.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -288,6 +334,122 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const
|
|||
}
|
||||
}
|
||||
|
||||
void CardPictureLoader::installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard)
|
||||
{
|
||||
// Overriding a card with itself is the reset case, not a real override: every code path below
|
||||
// would re-enter itself through emitPixmapUpdated(). Reject it outright.
|
||||
if (originalCard == overrideCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
CardInfoPtr cardPtr = overrideCard.getCardPtr();
|
||||
if (!cardPtr) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Heap-allocate so the lambda can capture it before the connection is made
|
||||
auto *connectionHandle = new QMetaObject::Connection;
|
||||
|
||||
*connectionHandle =
|
||||
connect(cardPtr.data(), &CardInfo::pixmapUpdated, cardPtr.data(),
|
||||
[originalCard, overrideCard, connectionHandle, this](const PrintingInfo &printing) {
|
||||
// All printings share the same CardInfo, so ignore updates triggered by any
|
||||
// other printing (e.g., the original card re-loading from disk).
|
||||
if (printing != overrideCard.getPrinting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QPixmap pixmap;
|
||||
if (QPixmapCache::find(overrideCard.getPixmapCacheKey(), &pixmap) && !pixmap.isNull()) {
|
||||
// The override art has resolved — persist it and reflect it immediately.
|
||||
// Retire the connection before emitting so the refresh can't re-enter.
|
||||
saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true);
|
||||
|
||||
QObject::disconnect(*connectionHandle);
|
||||
delete connectionHandle;
|
||||
|
||||
QPixmapCache::clear();
|
||||
originalCard.emitPixmapUpdated();
|
||||
return;
|
||||
}
|
||||
|
||||
// The art could not be resolved. Keep the connection armed so a late resolution
|
||||
// still lands, and surface a visible refusal instead of a silent no-op. An
|
||||
// override already on disk is left untouched and simply re-displayed.
|
||||
QPixmapCache::clear();
|
||||
if (!hasLocalOverrides(originalCard)) {
|
||||
QPixmap refusedPixmap;
|
||||
getCardBackLoadingFailedPixmap(refusedPixmap, QSize(480, 672));
|
||||
QPixmapCache::insert(originalCard.getPixmapCacheKey(), refusedPixmap);
|
||||
}
|
||||
originalCard.emitPixmapUpdated();
|
||||
});
|
||||
|
||||
// Now enqueue; if the image is already loading (deduplicated in the worker),
|
||||
// the signal will still fire when it completes
|
||||
CardPictureLoader::getInstance().worker->enqueueImageLoad(overrideCard);
|
||||
}
|
||||
|
||||
void CardPictureLoader::installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard)
|
||||
{
|
||||
// Same guard as installPrintingOverrideOnLoad: self-override is the reset case.
|
||||
if (originalCard == overrideCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
QPixmap pixmap;
|
||||
const QString key = overrideCard.getPixmapCacheKey();
|
||||
|
||||
if (QPixmapCache::find(key, &pixmap) && !pixmap.isNull()) {
|
||||
// Already cached — save immediately; the caller refreshes the card.
|
||||
saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache miss or previously failed load — enqueue load and wait for the signal.
|
||||
installPrintingOverrideOnLoad(originalCard, overrideCard);
|
||||
}
|
||||
|
||||
bool CardPictureLoader::hasLocalOverrides(const ExactCard &card)
|
||||
{
|
||||
const QString picsRoot = SettingsCache::instance().paths().getPicsPath();
|
||||
if (picsRoot.isEmpty() || !card) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir baseDir(picsRoot);
|
||||
if (!baseDir.cd("downloadedPics")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString name = card.getInfo().getCorrectedName();
|
||||
|
||||
QString set, collector, uuid;
|
||||
const PrintingInfo printing = card.getPrinting();
|
||||
if (printing.getSet()) {
|
||||
set = printing.getSet()->getCorrectedShortName();
|
||||
collector = printing.getProperty("num");
|
||||
uuid = printing.getUuid();
|
||||
}
|
||||
|
||||
for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) {
|
||||
QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid);
|
||||
|
||||
if (rel.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rel += ".png";
|
||||
rel = QDir::cleanPath(rel);
|
||||
|
||||
if (QFile::exists(baseDir.filePath(rel))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void CardPictureLoader::clearPixmapCache()
|
||||
{
|
||||
QPixmapCache::clear();
|
||||
|
|
@ -327,31 +489,11 @@ void CardPictureLoader::picsPathChanged()
|
|||
QPixmapCache::clear();
|
||||
}
|
||||
|
||||
bool CardPictureLoader::hasCustomArt()
|
||||
void CardPictureLoader::cardLangChanged()
|
||||
{
|
||||
auto picsPath = SettingsCache::instance().paths().getPicsPath();
|
||||
QDirIterator it(picsPath, QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
|
||||
// Check if there is at least one non-directory file in the pics path, other
|
||||
// than in the "downloadedPics" subdirectory.
|
||||
while (it.hasNext()) {
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0))
|
||||
QFileInfo dir(it.nextFileInfo());
|
||||
#else
|
||||
// nextFileInfo() is only available in Qt 6.3+, for previous versions, we build
|
||||
// the QFileInfo from a QString which requires more system calls.
|
||||
QFileInfo dir(it.next());
|
||||
#endif
|
||||
|
||||
if (it.fileName() == "downloadedPics") {
|
||||
continue;
|
||||
}
|
||||
|
||||
QDirIterator subIt(it.filePath(), QDir::Files, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
|
||||
if (subIt.hasNext()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
// Localized images are fetched via a different URL, but the in-memory
|
||||
// pixmap cache is keyed by card name/uuid, so drop everything cached
|
||||
// (including failure timestamps) to force a reload in the new language.
|
||||
QPixmapCache::clear();
|
||||
failedAt.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,10 +97,17 @@ public:
|
|||
static void cacheCardPixmaps(const QList<ExactCard> &cards);
|
||||
|
||||
/**
|
||||
* @brief Check if the user has custom card art in the picsPath directory.
|
||||
* @return True if any custom art exists.
|
||||
* @brief Check if a local override image already exists for the card.
|
||||
* @param card The card to check.
|
||||
* @return True if the card has at least one locally stored override image.
|
||||
*/
|
||||
static bool hasCustomArt();
|
||||
static bool hasLocalOverrides(const ExactCard &card);
|
||||
|
||||
/**
|
||||
* @brief Removes all locally stored override images for the card.
|
||||
* @param card The card to remove the override images of.
|
||||
*/
|
||||
static void deleteAllLocalOverrides(const ExactCard &card);
|
||||
|
||||
/**
|
||||
* @brief Clears the in-memory QPixmap cache for all cards.
|
||||
|
|
@ -120,7 +127,9 @@ public slots:
|
|||
* @param image Loaded QImage.
|
||||
*/
|
||||
void imageLoaded(const ExactCard &card, const QImage &image);
|
||||
void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap);
|
||||
void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap, bool allowOverwrite = false);
|
||||
void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard);
|
||||
void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard);
|
||||
|
||||
private slots:
|
||||
/**
|
||||
|
|
@ -134,6 +143,12 @@ private slots:
|
|||
* Clears the QPixmap cache to reload images.
|
||||
*/
|
||||
void picsPathChanged();
|
||||
|
||||
/**
|
||||
* @brief Triggered when the card language setting changes.
|
||||
* Clears the in-memory picture caches so images reload in the new language.
|
||||
*/
|
||||
void cardLangChanged();
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName,
|
|||
candidatePaths << picsPath + "/downloadedPics/" + setName + "/" + nameVariant;
|
||||
}
|
||||
|
||||
// Non-set-folder export schemes (e.g., Name_Set_Collector) write straight into
|
||||
// downloadedPics/; check there as a fallback so local overrides round-trip.
|
||||
candidatePaths << picsPath + "/downloadedPics/" + nameVariant;
|
||||
|
||||
for (const QString &path : candidatePaths) {
|
||||
QFileInfo fileInfo(path);
|
||||
QDir dir = fileInfo.dir();
|
||||
|
|
@ -105,7 +109,8 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName,
|
|||
|
||||
QStringList files = dir.entryList(QDir::Files);
|
||||
for (const QString &file : files) {
|
||||
if (!file.startsWith(baseName)) {
|
||||
QFileInfo fi(file);
|
||||
if (fi.completeBaseName() != baseName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,35 +148,25 @@ void CardPictureLoaderWorker::resetRequestQuota()
|
|||
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
|
||||
}
|
||||
|
||||
processQueuedRequests();
|
||||
updateTimerState();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::processQueuedRequests()
|
||||
{
|
||||
Q_ASSERT(thread() == QThread::currentThread());
|
||||
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
dispatchTimer.stop();
|
||||
requestTimer.stop();
|
||||
// QTimer must be started from the thread it lives in; if this public slot is ever reached from
|
||||
// another thread, replay it on the worker's event loop instead of letting start() fail silently.
|
||||
if (thread() != QThread::currentThread()) {
|
||||
QMetaObject::invokeMethod(this, &CardPictureLoaderWorker::processQueuedRequests, Qt::QueuedConnection);
|
||||
return;
|
||||
}
|
||||
// Start lazily from the worker's own thread: QTimer must be started in the thread it lives in.
|
||||
if (!requestTimer.isActive()) {
|
||||
requestTimer.start();
|
||||
}
|
||||
// Restarting an active timer would reset the pacing countdown, so a burst of enqueues could
|
||||
// keep starving the dispatcher; only start it when it has actually stopped.
|
||||
if (!dispatchTimer.isActive()) {
|
||||
dispatchTimer.start();
|
||||
}
|
||||
updateTimerState();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::dispatchQueuedRequest()
|
||||
{
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
// All queued requests have been dispatched; stop the pacing and quota-reset timers.
|
||||
dispatchTimer.stop();
|
||||
requestTimer.stop();
|
||||
// All queued requests have been dispatched; stop the pacing timers.
|
||||
updateTimerState();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +176,40 @@ void CardPictureLoaderWorker::dispatchQueuedRequest()
|
|||
}
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::updateTimerState()
|
||||
{
|
||||
// Never restart an active timer: that would reset the pacing countdown and a burst of enqueues
|
||||
// could keep starving the dispatcher, so only (re)start a timer that has actually stopped.
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
dispatchTimer.stop();
|
||||
// Forget per-second allowances once nothing is pending: a stale zero would otherwise delay
|
||||
// the next single request by a full quota-reset interval.
|
||||
hostQuotaRemaining.clear();
|
||||
} else if (!dispatchTimer.isActive()) {
|
||||
dispatchTimer.start();
|
||||
}
|
||||
|
||||
// The quota timer resets allowances every second and is also the only thing that heals a host
|
||||
// after a 429 (see resetRequestQuota). It must keep ticking while work is queued or a host is
|
||||
// still recovering below the ceiling, and only winds down once no host needs recovery anymore.
|
||||
// Keeping it alive during such idle periods lets reduced quotas recover as intended.
|
||||
bool hostRecovering = false;
|
||||
for (auto it = hostRequestQuota.cbegin(); it != hostRequestQuota.cend(); ++it) {
|
||||
if (it.value() < MAX_REQUESTS_PER_SEC) {
|
||||
hostRecovering = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!requestLoadQueue.isEmpty() || hostRecovering) {
|
||||
if (!requestTimer.isActive()) {
|
||||
requestTimer.start();
|
||||
}
|
||||
} else if (requestTimer.isActive()) {
|
||||
requestTimer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
bool CardPictureLoaderWorker::processSingleRequest()
|
||||
{
|
||||
for (int i = 0; i < requestLoadQueue.size(); ++i) {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ public slots:
|
|||
*/
|
||||
QNetworkReply *makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *workThread);
|
||||
|
||||
/** @brief Starts the pacing timers if there is queued work, stops them when the queue is empty. */
|
||||
/** @brief Ensures the pacing and quota-reset timers reflect the current queue and recovery state. */
|
||||
void processQueuedRequests();
|
||||
|
||||
/** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */
|
||||
|
|
@ -142,6 +142,9 @@ private:
|
|||
/** @brief Removes stale redirect entries older than TTL. */
|
||||
void cleanStaleEntries();
|
||||
|
||||
/** @brief Starts or stops the pacing and quota-reset timers to match the queue and recovery state. */
|
||||
void updateTimerState();
|
||||
|
||||
private slots:
|
||||
/** @brief Resets the request quota for rate-limiting. */
|
||||
void resetRequestQuota();
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ void CardPictureToLoad::populateSetUrls()
|
|||
}
|
||||
}
|
||||
|
||||
for (const QString &urlTemplate : urlTemplates) {
|
||||
const QStringList orderedTemplates = urlTemplates;
|
||||
for (const QString &urlTemplate : orderedTemplates) {
|
||||
QString transformedUrl = transformUrl(urlTemplate);
|
||||
|
||||
if (!transformedUrl.isEmpty()) {
|
||||
|
|
@ -282,8 +283,15 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const
|
|||
}
|
||||
|
||||
// language setting
|
||||
transformMap["!sflang!"] = QString(QCoreApplication::translate(
|
||||
"PictureLoader", "en", "code for scryfall's language property, not available for all languages"));
|
||||
const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang();
|
||||
transformMap["!sflang!"] = cardLang;
|
||||
|
||||
// The localized printing's own id is unknown, so Scryfall must resolve it by
|
||||
// its translated name (see populateSetUrls); expose that name for the
|
||||
// `/cards/named` template.
|
||||
if (cardLang != "en") {
|
||||
transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang);
|
||||
}
|
||||
|
||||
QString transformedUrl = urlTemplate;
|
||||
for (const QString &prop : transformMap.keys()) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H
|
||||
#define COCKATRICE_CONTEXT_OPEN_DECK_H
|
||||
|
||||
#include "context_connect_to_server.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
struct ContextOpenDeck
|
||||
{
|
||||
ContextConnectToServer serverContext;
|
||||
QString shareToken;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_CONTEXT_OPEN_DECK_H
|
||||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
Intent::Intent(QObject *parent) : QObject(parent)
|
||||
{
|
||||
// An intent is done as soon as it reports success or failure. Deleting it
|
||||
// also tears down its dependency chain and disconnects any signal wiring.
|
||||
// An intent is done as soon as it reports success, failure, or cancellation.
|
||||
// Deleting it also tears down its dependency chain and disconnects any signal wiring.
|
||||
connect(this, &Intent::finished, this, &QObject::deleteLater);
|
||||
connect(this, &Intent::failed, this, &QObject::deleteLater);
|
||||
connect(this, &Intent::cancelled, this, &QObject::deleteLater);
|
||||
}
|
||||
|
||||
Intent::~Intent() = default;
|
||||
|
|
@ -27,6 +28,7 @@ void Intent::runDependency(Intent *dependency)
|
|||
this->execute();
|
||||
});
|
||||
connect(dependency, &Intent::failed, this, &Intent::failed);
|
||||
connect(dependency, &Intent::cancelled, this, &Intent::cancelled);
|
||||
|
||||
dependency->execute();
|
||||
}
|
||||
|
|
@ -46,3 +48,11 @@ void Intent::emitFailed(const QString &reason)
|
|||
emit failed(reason);
|
||||
}
|
||||
}
|
||||
|
||||
void Intent::emitCancelled()
|
||||
{
|
||||
if (!completed) {
|
||||
completed = true;
|
||||
emit cancelled();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public:
|
|||
signals:
|
||||
void finished();
|
||||
void failed(QString reason);
|
||||
void cancelled();
|
||||
|
||||
protected:
|
||||
// --- Subclasses must implement these ---
|
||||
|
|
@ -29,6 +30,7 @@ protected:
|
|||
// Emit the outcome exactly once; ignore late signals after the intent is done.
|
||||
void emitFinished();
|
||||
void emitFailed(const QString &reason);
|
||||
void emitCancelled();
|
||||
|
||||
private:
|
||||
bool completed = false;
|
||||
|
|
|
|||
|
|
@ -19,13 +19,15 @@ bool IntentJoinServerGame::checkPrecondition() const
|
|||
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
// peerPort() reflects the actual TCP peer, which may differ from the
|
||||
// configured server port (e.g. when connecting through a proxy), so only
|
||||
// the hostname is compared here.
|
||||
if (remoteClient->peerName() != context->roomContext.serverContext.hostname) {
|
||||
// serverName()/serverPort() reflect the server the client was configured
|
||||
// to connect to, which may differ from the actual TCP peer (e.g. when
|
||||
// connecting through a proxy), so compare those configured values. A link
|
||||
// naming the same host on another port is a different server and must not
|
||||
// reuse the session there.
|
||||
if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) {
|
||||
if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
#include "intent_login.h"
|
||||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../widgets/dialogs/dlg_login_prompt.h"
|
||||
#include "libcockatrice/settings/servers_settings.h"
|
||||
|
||||
IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context)
|
||||
#include <QDialog>
|
||||
|
||||
IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context,
|
||||
bool _promptForMissingCredentials)
|
||||
: Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -29,5 +34,46 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
|
|||
|
||||
void IntentGetLoginCredentials::onPreconditionNotSatisfied()
|
||||
{
|
||||
emitFailed(tr("No saved credentials for this server"));
|
||||
// MainWindow::applyStartupDestination runs this intent on every launch for
|
||||
// users whose startup tab is Server / Server Room; keep that path quiet, as
|
||||
// it was before the link-driven sign-in dialog existed.
|
||||
if (!promptForMissingCredentials) {
|
||||
emitFailed(tr("No saved credentials for this server"));
|
||||
return;
|
||||
}
|
||||
|
||||
// No credentials saved for the target server: ask the user for them. They
|
||||
// opt into saving them so later links to the same server connect directly.
|
||||
const QString serverText = context->hostname + ":" + context->port;
|
||||
DlgLoginPrompt dialog(serverText);
|
||||
// ApplicationModal: the dialog has no parent (the intent is not a widget),
|
||||
// so WindowModal would not actually block any other window.
|
||||
dialog.setWindowModality(Qt::ApplicationModal);
|
||||
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
emitCancelled();
|
||||
return;
|
||||
}
|
||||
|
||||
context->username = dialog.username();
|
||||
context->password = dialog.password();
|
||||
|
||||
if (dialog.savePassword() && !context->username.isEmpty()) {
|
||||
ServersSettings &servers = SettingsCache::instance().servers();
|
||||
// The host may already be saved under a friendly name (e.g. a public-server
|
||||
// list entry) with no credentials; reuse that name instead of overwriting
|
||||
// it with the raw hostname when addNewServer updates the entry in place.
|
||||
QString saveName = context->hostname;
|
||||
const int existingIndex = servers.findServerIndex(context->hostname, context->port);
|
||||
if (existingIndex >= 0) {
|
||||
saveName =
|
||||
servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString();
|
||||
if (saveName.isEmpty()) {
|
||||
saveName = context->hostname;
|
||||
}
|
||||
}
|
||||
servers.addNewServer(saveName, context->hostname, context->port, context->username, context->password, true);
|
||||
}
|
||||
|
||||
emitFinished();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ class IntentGetLoginCredentials : public Intent
|
|||
Q_OBJECT
|
||||
|
||||
public:
|
||||
IntentGetLoginCredentials(ContextConnectToServer *_context);
|
||||
// When promptForMissingCredentials is false (the default) a server without
|
||||
// saved credentials fails silently; only intent chains from cockatrice://
|
||||
// links opt into the interactive sign-in dialog.
|
||||
explicit IntentGetLoginCredentials(ContextConnectToServer *_context, bool _promptForMissingCredentials = false);
|
||||
|
||||
protected:
|
||||
bool checkPrecondition() const override;
|
||||
|
|
@ -18,6 +21,7 @@ protected:
|
|||
|
||||
private:
|
||||
ContextConnectToServer *context;
|
||||
bool promptForMissingCredentials;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_INTENT_LOGIN_H
|
||||
|
|
|
|||
208
cockatrice/src/interface/intents/intent_open_shared_deck.cpp
Normal file
208
cockatrice/src/interface/intents/intent_open_shared_deck.cpp
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#include "intent_open_shared_deck.h"
|
||||
|
||||
#include "../deck_loader/deck_loader.h"
|
||||
#include "../widgets/dialogs/dlg_shared_decks_preview.h"
|
||||
#include "../widgets/tabs/tab_supervisor.h"
|
||||
#include "intent_connect_to_server.h"
|
||||
|
||||
#include <QMessageBox>
|
||||
#include <QTimer>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_download.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
|
||||
IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
|
||||
RemoteClient *_remoteClient,
|
||||
const CardDatabaseQuerier *_querier,
|
||||
std::unique_ptr<ContextOpenDeck> _context)
|
||||
: Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier),
|
||||
context(_context.release())
|
||||
{
|
||||
downloadTimer = new QTimer(this);
|
||||
downloadTimer->setSingleShot(true);
|
||||
downloadTimer->setInterval(15000);
|
||||
connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout);
|
||||
}
|
||||
|
||||
bool IntentOpenSharedDeck::checkPrecondition() const
|
||||
{
|
||||
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
// serverName()/serverPort() reflect the server the client was configured
|
||||
// to connect to, which may differ from the actual TCP peer (e.g. when
|
||||
// connecting through a proxy), so compare those configured values. The
|
||||
// share token must be resolved against the host the link named — a link to
|
||||
// the same host on another port is a different server.
|
||||
if (remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) != 0) {
|
||||
return false;
|
||||
}
|
||||
return QString::number(remoteClient->serverPort()) == context->serverContext.port;
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::onPreconditionSatisfied()
|
||||
{
|
||||
// Resolve the share token to its items first; a share can contain more than
|
||||
// one deck, and each item is downloaded by id. Time the round trip like the
|
||||
// downloads, so a silent server cannot hang the chain forever.
|
||||
listPhase = true;
|
||||
downloadTimer->start();
|
||||
|
||||
Command_DeckShareList cmd;
|
||||
cmd.set_token(context->shareToken.toStdString());
|
||||
|
||||
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished);
|
||||
remoteClient->sendCommand(pend);
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::onPreconditionNotSatisfied()
|
||||
{
|
||||
runDependency(new IntentConnectToServer(remoteClient, &context->serverContext));
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */)
|
||||
{
|
||||
downloadTimer->stop();
|
||||
listPhase = false;
|
||||
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
emitFailed(tr("The shared deck could not be found or has expired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext);
|
||||
if (resp.items_size() == 0) {
|
||||
emitFailed(tr("The shared deck is empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
QList<ServerInfo_DeckShareItem> items;
|
||||
items.reserve(resp.items_size());
|
||||
for (const ServerInfo_DeckShareItem &item : resp.items()) {
|
||||
items.append(item);
|
||||
itemNames.insert(item.id(), QString::fromStdString(item.name()));
|
||||
}
|
||||
|
||||
const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port;
|
||||
|
||||
// Ask the user which decks to open before downloading anything.
|
||||
previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()),
|
||||
resp.expires_at(), serverText, items);
|
||||
connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads);
|
||||
connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled);
|
||||
connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater);
|
||||
previewDialog->show();
|
||||
previewDialog->raise();
|
||||
previewDialog->activateWindow();
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::startDownloads(const QList<int> &itemIds)
|
||||
{
|
||||
pendingItemIds = itemIds;
|
||||
totalItems = itemIds.size();
|
||||
completedItems = 0;
|
||||
loadedDecks.clear();
|
||||
downloadNextItem();
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::downloadNextItem()
|
||||
{
|
||||
if (pendingItemIds.isEmpty()) {
|
||||
finishAll();
|
||||
return;
|
||||
}
|
||||
|
||||
currentItemId = pendingItemIds.takeFirst();
|
||||
downloadTimer->start();
|
||||
|
||||
Command_DeckShareDownload cmd;
|
||||
cmd.set_token(context->shareToken.toStdString());
|
||||
cmd.set_item_id(currentItemId);
|
||||
|
||||
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished);
|
||||
remoteClient->sendCommand(pend);
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::downloadShareFinished(const Response &response,
|
||||
const CommandContainer & /* commandContainer */)
|
||||
{
|
||||
downloadTimer->stop();
|
||||
|
||||
QString failureReason;
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
failureReason = tr("Failed to download the shared deck");
|
||||
} else {
|
||||
const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext);
|
||||
const QString deckString = QString::fromStdString(resp.deck());
|
||||
if (deckString.isEmpty()) {
|
||||
failureReason = tr("The shared deck is empty");
|
||||
} else {
|
||||
std::optional<LoadedDeck> deckOpt =
|
||||
DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID);
|
||||
if (!deckOpt) {
|
||||
failureReason = tr("The shared deck could not be loaded");
|
||||
} else {
|
||||
loadedDecks.append(deckOpt.value());
|
||||
++completedItems;
|
||||
previewDialog->setDownloadProgress(completedItems, totalItems,
|
||||
itemNames.value(currentItemId, tr("Unknown deck")));
|
||||
downloadNextItem();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onItemFailure(failureReason);
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::onItemFailure(const QString &reason)
|
||||
{
|
||||
downloadTimer->stop();
|
||||
|
||||
if (loadedDecks.isEmpty()) {
|
||||
previewDialog->deleteLater();
|
||||
emitFailed(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const int downloadedCount = loadedDecks.size();
|
||||
const QMessageBox::StandardButton answer = QMessageBox::question(
|
||||
previewDialog, tr("Open shared decks"),
|
||||
tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount)
|
||||
.arg(itemNames.value(currentItemId, tr("Unknown deck"))),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
|
||||
|
||||
if (answer == QMessageBox::Yes) {
|
||||
finishAll();
|
||||
} else {
|
||||
previewDialog->deleteLater();
|
||||
emitCancelled();
|
||||
}
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::onDownloadTimeout()
|
||||
{
|
||||
// The list phase has no preview dialog yet to report progress into; fail the
|
||||
// whole intent instead of letting the shared deck hang in limbo.
|
||||
if (listPhase) {
|
||||
emitFailed(tr("Timed out while loading the shared deck"));
|
||||
return;
|
||||
}
|
||||
onItemFailure(tr("Timed out while downloading the shared deck"));
|
||||
}
|
||||
|
||||
void IntentOpenSharedDeck::finishAll()
|
||||
{
|
||||
previewDialog->deleteLater();
|
||||
for (const LoadedDeck &deck : loadedDecks) {
|
||||
tabSupervisor->openDeckInNewTab(deck);
|
||||
}
|
||||
emitFinished();
|
||||
}
|
||||
60
cockatrice/src/interface/intents/intent_open_shared_deck.h
Normal file
60
cockatrice/src/interface/intents/intent_open_shared_deck.h
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H
|
||||
#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H
|
||||
|
||||
#include "contexts/context_open_deck.h"
|
||||
#include "intent.h"
|
||||
#include "remote_client.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QScopedPointer>
|
||||
#include <memory>
|
||||
|
||||
class TabSupervisor;
|
||||
struct LoadedDeck;
|
||||
class CardDatabaseQuerier;
|
||||
class DlgSharedDecksPreview;
|
||||
class QTimer;
|
||||
|
||||
class IntentOpenSharedDeck : public Intent
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
|
||||
RemoteClient *_remoteClient,
|
||||
const CardDatabaseQuerier *_querier,
|
||||
std::unique_ptr<ContextOpenDeck> _context);
|
||||
|
||||
protected:
|
||||
bool checkPrecondition() const override;
|
||||
void onPreconditionSatisfied() override;
|
||||
void onPreconditionNotSatisfied() override;
|
||||
|
||||
private slots:
|
||||
void listShareFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
void downloadShareFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
void onDownloadTimeout();
|
||||
|
||||
private:
|
||||
void startDownloads(const QList<int> &itemIds);
|
||||
void downloadNextItem();
|
||||
void onItemFailure(const QString &reason);
|
||||
void finishAll();
|
||||
|
||||
TabSupervisor *tabSupervisor;
|
||||
RemoteClient *remoteClient;
|
||||
const CardDatabaseQuerier *querier;
|
||||
QScopedPointer<ContextOpenDeck> context;
|
||||
DlgSharedDecksPreview *previewDialog = nullptr;
|
||||
QTimer *downloadTimer;
|
||||
QMap<int, QString> itemNames;
|
||||
QList<int> pendingItemIds;
|
||||
QList<LoadedDeck> loadedDecks;
|
||||
bool listPhase = true;
|
||||
int currentItemId = 0;
|
||||
int totalItems = 0;
|
||||
int completedItems = 0;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H
|
||||
|
|
@ -1,19 +1,28 @@
|
|||
#include "url_parser.h"
|
||||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../widgets/tabs/tab_room.h"
|
||||
#include "../widgets/tabs/tab_supervisor.h"
|
||||
#include "../window_main.h"
|
||||
#include "contexts/context_join_game.h"
|
||||
#include "contexts/context_open_deck.h"
|
||||
#include "intent.h"
|
||||
#include "intent_join_server_game.h"
|
||||
#include "intent_login.h"
|
||||
#include "intent_open_shared_deck.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMessageBox>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/settings/servers_settings.h>
|
||||
#include <memory>
|
||||
|
||||
inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser");
|
||||
|
||||
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
|
||||
{
|
||||
}
|
||||
|
|
@ -29,16 +38,33 @@ void IntentUrlParser::handle(const QString &urlStr)
|
|||
const QString action = url.host();
|
||||
QUrlQuery query(url);
|
||||
|
||||
qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action;
|
||||
|
||||
PendingIntentChain chain;
|
||||
Intent *firstIntent = nullptr;
|
||||
if (action == "joingame") {
|
||||
handleJoinGame(query);
|
||||
firstIntent = createJoinGameIntent(query, chain);
|
||||
} else if (action == "opendeck") {
|
||||
// handleOpenDeck(query);
|
||||
firstIntent = createOpenDeckIntent(query, chain);
|
||||
} else {
|
||||
qWarning() << "Unknown intent:" << action;
|
||||
}
|
||||
|
||||
if (firstIntent == nullptr) {
|
||||
// The link was invalid or the user declined the confirm: nothing runs.
|
||||
// Report the idle state when no other chain is queued so that a startup
|
||||
// launch (which skipped its own connection for this URL) falls back to it.
|
||||
if (!chainRunning && pendingChains.isEmpty()) {
|
||||
emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingChains.append(chain);
|
||||
startNextChain();
|
||||
}
|
||||
|
||||
void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
||||
Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain)
|
||||
{
|
||||
auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
|
||||
|
||||
|
|
@ -49,21 +75,21 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
|||
|
||||
if (ctx->roomContext.serverContext.hostname.isEmpty()) {
|
||||
showError(tr("Missing or empty hostname in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
ctx->roomContext.serverContext.port.toUShort(&ok);
|
||||
if (!ok) {
|
||||
showError(tr("Invalid or missing port in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok);
|
||||
|
||||
if (!ok) {
|
||||
showError(tr("Invalid or missing room id in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ok = false;
|
||||
|
|
@ -71,7 +97,7 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
|||
|
||||
if (!ok) {
|
||||
showError(tr("Invalid or missing game id in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded);
|
||||
|
|
@ -80,24 +106,33 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
|||
const QMessageBox::StandardButton answer = QMessageBox::question(
|
||||
mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
|
||||
if (answer != QMessageBox::Yes) {
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
|
||||
|
||||
// The join game intent owns the context and the credential lookup; once the
|
||||
// chain finishes (or fails) it deletes the whole tree.
|
||||
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
|
||||
auto joinGameIntent =
|
||||
new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
|
||||
auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx));
|
||||
joinGameIntent->setParent(this);
|
||||
|
||||
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
|
||||
getLoginCredentialsIntent->setParent(joinGameIntent);
|
||||
|
||||
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
|
||||
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
|
||||
chain.intents.append(joinGameIntent);
|
||||
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
|
||||
|
||||
getLoginCredentialsIntent->execute();
|
||||
Intent *firstIntent = joinGameIntent;
|
||||
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
|
||||
auto getLoginCredentialsIntent =
|
||||
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
|
||||
getLoginCredentialsIntent->setParent(joinGameIntent);
|
||||
chain.intents.insert(0, getLoginCredentialsIntent);
|
||||
|
||||
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
|
||||
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
|
||||
connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled);
|
||||
firstIntent = getLoginCredentialsIntent;
|
||||
}
|
||||
|
||||
return firstIntent;
|
||||
}
|
||||
|
||||
QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription)
|
||||
|
|
@ -134,3 +169,270 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context,
|
|||
.arg(gameDescription, gameIdStr, roomTab->getRoomName(), server)
|
||||
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server);
|
||||
}
|
||||
|
||||
Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain)
|
||||
{
|
||||
auto showError = [this](const QString &message) {
|
||||
QMessageBox::warning(mainWindow, tr("Open shared deck"), message);
|
||||
};
|
||||
|
||||
auto ctx = std::make_unique<ContextOpenDeck>();
|
||||
|
||||
ctx->serverContext.hostname = query.queryItemValue("hostname");
|
||||
ctx->serverContext.port = query.queryItemValue("port");
|
||||
ctx->shareToken = query.queryItemValue("share");
|
||||
|
||||
qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port"
|
||||
<< ctx->serverContext.port << "token length" << ctx->shareToken.length();
|
||||
|
||||
if (ctx->serverContext.hostname.isEmpty()) {
|
||||
showError(tr("Missing or empty hostname in the share link"));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const quint16 port = ctx->serverContext.port.toUShort(&ok);
|
||||
if (!ok || port == 0) {
|
||||
showError(tr("Invalid or missing port in the share link"));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (ctx->shareToken.isEmpty()) {
|
||||
showError(tr("Missing or empty share value in the share link"));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
|
||||
// The open deck download needs a connection to the link's server. Ask before
|
||||
// taking the session anywhere it isn't already, naming the host we would
|
||||
// connect to. Remember the link's target when it moves us away from a live
|
||||
// session so a failed or cancelled chain can restore the session it left.
|
||||
// The hostname is link-supplied and percent-decoded, so escape it: QMessageBox
|
||||
// renders AutoText, and markup in a hostname would otherwise flip the whole
|
||||
// prompt to rich text and let a link pad the message the user is shown.
|
||||
const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port);
|
||||
if (!alreadyConnected) {
|
||||
const QString target =
|
||||
QStringLiteral("%1:%2").arg(ctx->serverContext.hostname.toHtmlEscaped(), ctx->serverContext.port);
|
||||
|
||||
if (client->getStatus() == StatusLoggedIn) {
|
||||
const QString current =
|
||||
QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort()));
|
||||
const QMessageBox::StandardButton answer = QMessageBox::question(
|
||||
mainWindow, tr("Open shared deck"),
|
||||
tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
|
||||
if (answer != QMessageBox::Yes) {
|
||||
return nullptr;
|
||||
}
|
||||
chain.migrationTargetHost = ctx->serverContext.hostname;
|
||||
chain.migrationTargetPort = ctx->serverContext.port;
|
||||
chain.pendingRestore = true;
|
||||
} else {
|
||||
// Fresh connection is harmless to wander away from, but a server the
|
||||
// client has never been configured for deserves a harder warning (no
|
||||
// by default) so a stray link cannot silently steer the client there.
|
||||
const bool knownHost = SettingsCache::instance().servers().findHostIndex(ctx->serverContext.hostname) >= 0;
|
||||
const QMessageBox::StandardButton answer =
|
||||
knownHost
|
||||
? QMessageBox::question(mainWindow, tr("Open shared deck"),
|
||||
tr("Opening this share link connects you to %1.\n\nContinue?").arg(target))
|
||||
: QMessageBox::warning(mainWindow, tr("Open shared deck"),
|
||||
tr("Opening this share link connects you to %1, a server you have "
|
||||
"never connected to before.\n\nContinue?")
|
||||
.arg(target),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
||||
if (answer != QMessageBox::Yes) {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContextConnectToServer *serverContext = &ctx->serverContext;
|
||||
|
||||
// The open deck intent owns the context and the credential lookup; once
|
||||
// the chain finishes (or fails) it deletes the whole tree.
|
||||
auto openDeckIntent =
|
||||
new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx));
|
||||
openDeckIntent->setParent(this);
|
||||
chain.intents.append(openDeckIntent);
|
||||
connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
|
||||
|
||||
Intent *firstIntent = openDeckIntent;
|
||||
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
|
||||
auto getLoginCredentialsIntent =
|
||||
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
|
||||
getLoginCredentialsIntent->setParent(openDeckIntent);
|
||||
chain.intents.insert(0, getLoginCredentialsIntent);
|
||||
|
||||
connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute);
|
||||
connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed);
|
||||
connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled);
|
||||
firstIntent = getLoginCredentialsIntent;
|
||||
}
|
||||
|
||||
return firstIntent;
|
||||
}
|
||||
|
||||
bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const
|
||||
{
|
||||
// serverName() reflects the server the client was configured to connect to,
|
||||
// which may differ from the actual TCP peer (e.g. when connecting through a
|
||||
// proxy), so compare the configured host and port — exactly what a link
|
||||
// names. A link to the same host on another port is a different server and
|
||||
// must not silently reuse an existing session there.
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0 &&
|
||||
QString::number(client->serverPort()) == port;
|
||||
}
|
||||
|
||||
void IntentUrlParser::startNextChain()
|
||||
{
|
||||
if (chainRunning || pendingChains.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
chainRunning = true;
|
||||
|
||||
PendingIntentChain &chain = pendingChains.first();
|
||||
if (chain.intents.isEmpty()) {
|
||||
pendingChains.removeFirst();
|
||||
chainRunning = false;
|
||||
startNextChain();
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the session this chain moves away from now that it actually
|
||||
// runs. Chains are parsed while earlier ones are still queued, so a capture
|
||||
// at parse time would follow whichever server the chain before it settled
|
||||
// on, not the one the user is really on when this link is handled.
|
||||
if (chain.pendingRestore) {
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
chain.previousServerHost = client->serverName();
|
||||
chain.previousServerPort = QString::number(client->serverPort());
|
||||
}
|
||||
|
||||
// Only the last intent completes the chain; its terminal signal ends the
|
||||
// whole run. Cancellation of an intermediate intent (e.g. declined login
|
||||
// prompt) is forwarded onto the last intent in the chain builders above.
|
||||
Intent *finalIntent = chain.intents.last();
|
||||
connect(finalIntent, &Intent::finished, this, [this]() { chainEnded(true); });
|
||||
connect(finalIntent, &Intent::failed, this, [this]() { chainEnded(false); });
|
||||
connect(finalIntent, &Intent::cancelled, this, [this]() { chainEnded(false); });
|
||||
// Backstop: if the final intent is destroyed without emitting a terminal
|
||||
// signal (e.g. a network error dropped it while running), end the chain so
|
||||
// later links are not queued and dropped for the rest of the session.
|
||||
chainBackstopConnection = connect(finalIntent, &QObject::destroyed, this, &IntentUrlParser::onChainIntentDestroyed);
|
||||
|
||||
chain.intents.first()->execute();
|
||||
}
|
||||
|
||||
void IntentUrlParser::chainEnded(bool chainSucceeded)
|
||||
{
|
||||
chainRunning = false;
|
||||
QObject::disconnect(chainBackstopConnection);
|
||||
|
||||
const PendingIntentChain chain = pendingChains.takeFirst();
|
||||
|
||||
// Only a failed or cancelled chain restores the session the link migrated
|
||||
// away from; a successful one leaves the user where they are.
|
||||
if (chain.pendingRestore && !chainSucceeded) {
|
||||
restorePreviousServer(chain);
|
||||
}
|
||||
|
||||
startNextChain();
|
||||
|
||||
// Only report the terminal state once the queue has fully drained, so a
|
||||
// queued follow-up link keeps the startup fallback out of the picture.
|
||||
if (!chainRunning && pendingChains.isEmpty()) {
|
||||
emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn);
|
||||
}
|
||||
}
|
||||
|
||||
void IntentUrlParser::onChainIntentDestroyed()
|
||||
{
|
||||
if (!chainRunning) {
|
||||
return;
|
||||
}
|
||||
qCWarning(UrlParserLog) << "Share-link intent destroyed without a terminal signal; ending its chain";
|
||||
chainEnded(false);
|
||||
}
|
||||
|
||||
void IntentUrlParser::restorePreviousServer(const PendingIntentChain &chain)
|
||||
{
|
||||
if (chain.previousServerHost.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
const ClientStatus status = client->getStatus();
|
||||
|
||||
// A failed/cancelled chain can fire while the client is still settling the
|
||||
// in-flight connection attempt (wrong password, connect timeout). Only
|
||||
// decide once the client has settled into logged-in or disconnected;
|
||||
// deciding mid-connect would strand the user offline from their previous
|
||||
// server.
|
||||
if (status == StatusDisconnected || status == StatusLoggedIn) {
|
||||
restoreToPreviousServer(chain);
|
||||
return;
|
||||
}
|
||||
auto waitConnection = std::make_shared<QMetaObject::Connection>();
|
||||
*waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, chain, client, waitConnection]() {
|
||||
const ClientStatus settled = client->getStatus();
|
||||
if (settled == StatusDisconnected || settled == StatusLoggedIn) {
|
||||
QObject::disconnect(*waitConnection);
|
||||
restoreToPreviousServer(chain);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
|
||||
{
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
|
||||
// Back on the previous server already → nothing to undo.
|
||||
if (client->serverName().compare(chain.previousServerHost, Qt::CaseInsensitive) == 0 &&
|
||||
QString::number(client->serverPort()) == chain.previousServerPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When logged in somewhere, only intervene if that somewhere is the server
|
||||
// the link moved us to; if the user went elsewhere on their own, leave them.
|
||||
if (client->getStatus() == StatusLoggedIn) {
|
||||
const bool onMigrationTarget =
|
||||
client->serverName().compare(chain.migrationTargetHost, Qt::CaseInsensitive) == 0 &&
|
||||
QString::number(client->serverPort()) == chain.migrationTargetPort;
|
||||
if (!onMigrationTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
ServersSettings &servers = SettingsCache::instance().servers();
|
||||
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
|
||||
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
|
||||
const QString username =
|
||||
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
|
||||
const QString password =
|
||||
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
|
||||
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
|
||||
return;
|
||||
}
|
||||
client->disconnectFromServer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (client->getStatus() != StatusDisconnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The link's connection attempt failed: reconnect to the previous server
|
||||
// when credentials are saved, otherwise stay offline.
|
||||
ServersSettings &servers = SettingsCache::instance().servers();
|
||||
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
|
||||
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
|
||||
const QString username =
|
||||
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
|
||||
const QString password =
|
||||
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
|
||||
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,46 @@
|
|||
#ifndef COCKATRICE_URL_PARSER_H
|
||||
#define COCKATRICE_URL_PARSER_H
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QUrlQuery>
|
||||
|
||||
class Intent;
|
||||
class MainWindow;
|
||||
struct ContextJoinGame;
|
||||
|
||||
/**
|
||||
* @brief One queued intent chain with the session-migration bookkeeping for it.
|
||||
*
|
||||
* The restore fields are per-chain on purpose: chains are parsed while earlier
|
||||
* ones are still queued, so parser-wide state would let one chain's failure
|
||||
* consume the restore data another chain recorded.
|
||||
*/
|
||||
struct PendingIntentChain
|
||||
{
|
||||
QList<Intent *> intents;
|
||||
|
||||
// Snapshot of the session in place when this chain started running, so a
|
||||
// queued chain follows whichever server the chain before it settled on.
|
||||
QString previousServerHost;
|
||||
QString previousServerPort;
|
||||
|
||||
// Recorded at parse time when the user confirmed migrating away from a live
|
||||
// session to the host/port named by the link.
|
||||
QString migrationTargetHost;
|
||||
QString migrationTargetPort;
|
||||
bool pendingRestore = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Parses cockatrice:// links and runs them as serialized intent chains.
|
||||
*
|
||||
* Links are parsed by action (joingame/opendeck) and translated into an intent
|
||||
* chain. Chains are queued and run one at a time: a document can hand multiple
|
||||
* links to the window while an earlier chain still connects, and running two
|
||||
* connect chains concurrently tears the connection down. urlChainFinished is
|
||||
* emitted once the queue has fully drained.
|
||||
*/
|
||||
class IntentUrlParser : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
|
@ -12,12 +48,28 @@ class IntentUrlParser : public QObject
|
|||
public:
|
||||
IntentUrlParser(QObject *parent, MainWindow *mainWindow);
|
||||
void handle(const QString &urlStr);
|
||||
void handleJoinGame(const QUrlQuery &query);
|
||||
|
||||
signals:
|
||||
/** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */
|
||||
void urlChainFinished(bool connected);
|
||||
|
||||
private:
|
||||
Intent *createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain);
|
||||
Intent *createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain);
|
||||
QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription);
|
||||
[[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const;
|
||||
void startNextChain();
|
||||
void chainEnded(bool chainSucceeded);
|
||||
void onChainIntentDestroyed();
|
||||
void restorePreviousServer(const PendingIntentChain &chain);
|
||||
void restoreToPreviousServer(const PendingIntentChain &chain);
|
||||
|
||||
MainWindow *mainWindow;
|
||||
QList<PendingIntentChain> pendingChains;
|
||||
bool chainRunning = false;
|
||||
// Disconnects the destroyed-signal backstop once a chain ends, so an old
|
||||
// intent's deferred deletion cannot end the chain that runs after it.
|
||||
QMetaObject::Connection chainBackstopConnection;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_URL_PARSER_H
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#include "palette_editor_dialog.h"
|
||||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../theme_manager.h"
|
||||
#include "palette_generator.h"
|
||||
#include "palette_grid_widget.h"
|
||||
|
|
@ -11,31 +10,11 @@
|
|||
#include <QDialogButtonBox>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFrame>
|
||||
#include <QGuiApplication>
|
||||
#include <QLabel>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QStyleHints>
|
||||
#include <QTimer>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
|
||||
// Probe whether a directory is truly writable by trying to create and remove a
|
||||
// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably
|
||||
// on Windows where UAC VirtualStore can make a system dir appear writable).
|
||||
static bool isDirReallyWritable(const QString &dirPath)
|
||||
{
|
||||
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
|
||||
QFile f(probe);
|
||||
if (!f.open(QIODevice::WriteOnly)) {
|
||||
return false;
|
||||
}
|
||||
f.close();
|
||||
f.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent)
|
||||
: QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName)
|
||||
|
|
@ -46,14 +25,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
|
|||
// Resolve a writable directory for saving. Built-in (Default / Fusion) and
|
||||
// other read-only theme directories must be customised in the user-writable
|
||||
// themes directory; otherwise the write would fail or be lost on upgrade.
|
||||
if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) {
|
||||
saveDir = themeDirPath;
|
||||
} else {
|
||||
saveDir = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName);
|
||||
if (!QDir().mkpath(saveDir)) {
|
||||
qWarning() << "Failed to create palette save directory:" << saveDir;
|
||||
}
|
||||
}
|
||||
saveDir = ThemeManager::writableThemeDir(themeName);
|
||||
|
||||
// Load both scheme configs upfront so switching is instant
|
||||
loadSchemes();
|
||||
|
|
@ -214,7 +186,7 @@ void PaletteEditorDialog::retranslateUi()
|
|||
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
|
||||
saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower()));
|
||||
|
||||
if (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) {
|
||||
if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) {
|
||||
saveBtn->setEnabled(false);
|
||||
saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory"));
|
||||
}
|
||||
|
|
@ -297,7 +269,7 @@ void PaletteEditorDialog::onSave()
|
|||
if (it.key() == loadedScheme) {
|
||||
continue;
|
||||
}
|
||||
if (it.value().colors == savedConfig.value(it.key()).colors) {
|
||||
if (it.value() == savedConfig.value(it.key())) {
|
||||
continue;
|
||||
}
|
||||
if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) {
|
||||
|
|
@ -308,7 +280,7 @@ void PaletteEditorDialog::onSave()
|
|||
}
|
||||
|
||||
// Commit the active scheme last so the global colour scheme matches.
|
||||
if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) {
|
||||
if (workingConfig[loadedScheme] != savedConfig.value(loadedScheme)) {
|
||||
if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) {
|
||||
QMessageBox::warning(this, tr("Save failed"),
|
||||
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir));
|
||||
|
|
|
|||
|
|
@ -150,6 +150,17 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
|
|||
cfg.colors[CG::Disabled][CR::HighlightedText] = disText;
|
||||
cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black;
|
||||
|
||||
// Accent: same primary hue as Highlight, so palettes derived from a
|
||||
// QuickSetup accent always carry a matching Accent role.
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)
|
||||
set3(CR::Accent, hl, disText, hl);
|
||||
#endif
|
||||
|
||||
// Application role colors: Strong tracks the primary accent, while Soft is
|
||||
// the lightened, desaturated companion used for button-gradient highlights.
|
||||
cfg.appColors[AppColor::AccentStrong] = hl;
|
||||
cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0));
|
||||
|
||||
// BrightText
|
||||
QColor bright;
|
||||
if (achromatic) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "palette_grid_widget.h"
|
||||
|
||||
#include "../theme_manager.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
|
|
@ -45,6 +47,11 @@ static const QMap<QPalette::ColorRole, const char *> ROLE_DESCRIPTIONS = {
|
|||
{QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")},
|
||||
};
|
||||
|
||||
static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = {
|
||||
{AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")},
|
||||
{AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")},
|
||||
};
|
||||
|
||||
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
scroll = new QScrollArea(this);
|
||||
|
|
@ -122,6 +129,46 @@ void PaletteGridWidget::buildGrid(QWidget *host)
|
|||
grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter);
|
||||
}
|
||||
}
|
||||
|
||||
// Application color section: one ColorButton per role below the role grid.
|
||||
// These are not tied to a color group, so a single button spans the row.
|
||||
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
|
||||
|
||||
const int appHeaderRow = roles.size() + 1;
|
||||
|
||||
auto *appHeader = new QLabel(tr("App colors"), host);
|
||||
appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette"));
|
||||
QFont appHeaderFont = appHeader->font();
|
||||
appHeaderFont.setBold(true);
|
||||
appHeader->setFont(appHeaderFont);
|
||||
appHeader->setAutoFillBackground(true);
|
||||
appHeader->setContentsMargins(4, 4, 4, 4);
|
||||
grid->addWidget(appHeader, appHeaderRow, 0, 1, 4);
|
||||
headerLabels.append(appHeader);
|
||||
|
||||
for (int i = 0; i < appEnum.keyCount(); ++i) {
|
||||
auto role = static_cast<AppColor::Role>(appEnum.value(i));
|
||||
const int row = appHeaderRow + 1 + i;
|
||||
|
||||
if (i % 2 == 0) {
|
||||
for (int col = 0; col < 4; ++col) {
|
||||
auto *shade = new QWidget(host);
|
||||
shade->setAutoFillBackground(true);
|
||||
grid->addWidget(shade, row, col);
|
||||
rowShadeWidgets.push_back(shade);
|
||||
}
|
||||
}
|
||||
|
||||
auto *label = new QLabel(QString(appEnum.valueToKey(role)), host);
|
||||
label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {}));
|
||||
label->setContentsMargins(4, 2, 8, 2);
|
||||
grid->addWidget(label, row, 0);
|
||||
|
||||
auto *btn = new ColorButton(host);
|
||||
connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); });
|
||||
appColorButtons[role] = btn;
|
||||
grid->addWidget(btn, row, 1, Qt::AlignHCenter | Qt::AlignVCenter);
|
||||
}
|
||||
}
|
||||
|
||||
void PaletteGridWidget::changeEvent(QEvent *e)
|
||||
|
|
@ -166,6 +213,16 @@ void PaletteGridWidget::loadPalette(const PaletteConfig &cfg)
|
|||
colorButtons[group][role]->setColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
|
||||
for (int i = 0; i < appEnum.keyCount(); ++i) {
|
||||
auto role = static_cast<AppColor::Role>(appEnum.value(i));
|
||||
QColor color = cfg.appColors.value(role);
|
||||
if (!color.isValid()) {
|
||||
color = themeManager->appColor(role);
|
||||
}
|
||||
appColorButtons[role]->setColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
PaletteConfig PaletteGridWidget::currentPaletteConfig() const
|
||||
|
|
@ -176,5 +233,12 @@ PaletteConfig PaletteGridWidget::currentPaletteConfig() const
|
|||
cfg.colors[group][role] = colorButtons[group][role]->getColor();
|
||||
}
|
||||
}
|
||||
|
||||
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
|
||||
for (int i = 0; i < appEnum.keyCount(); ++i) {
|
||||
auto role = static_cast<AppColor::Role>(appEnum.value(i));
|
||||
cfg.appColors[role] = appColorButtons[role]->getColor();
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ private:
|
|||
void refreshChromePalettes();
|
||||
|
||||
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, ColorButton *>> colorButtons;
|
||||
QMap<AppColor::Role, ColorButton *> appColorButtons;
|
||||
QScrollArea *scroll;
|
||||
QWidget *gridHost;
|
||||
QVBoxLayout *layout;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
#define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff";
|
||||
#define DEFAULT_COLOR_MODERATOR_RIGHT "#000000";
|
||||
#define DEFAULT_COLOR_ADMIN "#ff2701";
|
||||
#define DEFAULT_COLOR_DEVELOPER "#B8B8B8"
|
||||
|
||||
/**
|
||||
* Clamps an svg render size so that rendering does not exceed a multiple of the requested size.
|
||||
|
|
@ -362,6 +361,10 @@ static QString getIconType(const bool isBuddy, const UserLevelFlags &userLevelFl
|
|||
return "pawn_judge";
|
||||
}
|
||||
|
||||
if (userLevelFlags.testFlag(ServerInfo_User::IsDeveloper)) {
|
||||
return "pawn_dev";
|
||||
}
|
||||
|
||||
if (!privLevel.isEmpty() && privLevel.toLower() != "none") {
|
||||
return QString("pawn_%1").arg(privLevel.toLower());
|
||||
}
|
||||
|
|
@ -382,8 +385,6 @@ QIcon UserLevelPixmapGenerator::generateIconDefault(int height,
|
|||
|
||||
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
|
||||
colorLeft = DEFAULT_COLOR_ADMIN;
|
||||
} else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
|
||||
colorLeft = DEFAULT_COLOR_DEVELOPER;
|
||||
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
|
||||
colorLeft = DEFAULT_COLOR_MODERATOR_LEFT;
|
||||
colorRight = DEFAULT_COLOR_MODERATOR_RIGHT;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ QString ThemeConfig::toIni() const
|
|||
out += "[Appearance]\n";
|
||||
out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme);
|
||||
out += "\n[Style]\n";
|
||||
out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName);
|
||||
out += QString("Name = %1\n").arg(styleName.isEmpty() ? "System" : styleName);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ bool ThemeConfig::save(const QString &themeDirPath) const
|
|||
|
||||
bool PaletteConfig::hasPalette() const
|
||||
{
|
||||
return !colors.isEmpty();
|
||||
return !colors.isEmpty() || !appColors.isEmpty();
|
||||
}
|
||||
|
||||
QString PaletteConfig::toToml() const
|
||||
|
|
@ -133,6 +133,24 @@ QString PaletteConfig::toToml() const
|
|||
out += "\n";
|
||||
}
|
||||
|
||||
if (!appColors.isEmpty()) {
|
||||
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
|
||||
|
||||
out += "[AppColors]\n";
|
||||
|
||||
for (auto it = appColors.cbegin(); it != appColors.cend(); ++it) {
|
||||
const char *roleName = appEnum.valueToKey(it.key());
|
||||
|
||||
if (!roleName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb));
|
||||
}
|
||||
|
||||
out += "\n";
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +170,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
|
|||
}
|
||||
|
||||
QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>();
|
||||
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
|
||||
|
||||
QString currentSection;
|
||||
QPalette::ColorGroup currentGroup = QPalette::Active;
|
||||
|
|
@ -202,6 +221,26 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
|
|||
}
|
||||
}
|
||||
|
||||
QColor color(value);
|
||||
|
||||
if (!color.isValid()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSection.compare("AppColors", Qt::CaseInsensitive) == 0) {
|
||||
if (key.startsWith("AppColor::")) {
|
||||
key = key.mid(10);
|
||||
}
|
||||
|
||||
int appRoleInt = appEnum.keyToValue(key.toUtf8().constData());
|
||||
|
||||
if (appRoleInt >= 0) {
|
||||
cfg.appColors[static_cast<AppColor::Role>(appRoleInt)] = color;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -216,11 +255,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
|
|||
continue;
|
||||
}
|
||||
|
||||
QColor color(value);
|
||||
|
||||
if (color.isValid()) {
|
||||
cfg.colors[currentGroup][static_cast<QPalette::ColorRole>(roleInt)] = color;
|
||||
}
|
||||
cfg.colors[currentGroup][static_cast<QPalette::ColorRole>(roleInt)] = color;
|
||||
}
|
||||
|
||||
return cfg;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,25 @@
|
|||
|
||||
#include <QColor>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
#include <QPalette>
|
||||
#include <QString>
|
||||
|
||||
// Application-specific color roles, layered on top of the fixed QPalette role
|
||||
// set. Stored in the same palette-<scheme>.toml under an [AppColors] section
|
||||
// and editable from the palette editor, so theme authors can control colors
|
||||
// beyond what Qt's palette can express.
|
||||
namespace AppColor
|
||||
{
|
||||
Q_NAMESPACE
|
||||
enum Role
|
||||
{
|
||||
AccentStrong,
|
||||
AccentSoft,
|
||||
};
|
||||
Q_ENUM_NS(Role)
|
||||
} // namespace AppColor
|
||||
|
||||
struct ThemeConfig
|
||||
{
|
||||
QString colorScheme;
|
||||
|
|
@ -21,7 +37,16 @@ struct ThemeConfig
|
|||
struct PaletteConfig
|
||||
{
|
||||
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, QColor>> colors;
|
||||
QMap<AppColor::Role, QColor> appColors;
|
||||
|
||||
bool operator==(const PaletteConfig &rhs) const
|
||||
{
|
||||
return colors == rhs.colors && appColors == rhs.appColors;
|
||||
}
|
||||
bool operator!=(const PaletteConfig &rhs) const
|
||||
{
|
||||
return !(*this == rhs);
|
||||
}
|
||||
bool hasPalette() const;
|
||||
QString toToml() const;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#include <QApplication>
|
||||
#include <QColor>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QLibraryInfo>
|
||||
#include <QMap>
|
||||
|
|
@ -21,7 +22,7 @@
|
|||
#include <Qt>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
|
||||
#define NONE_THEME_NAME "Default"
|
||||
#define SYSTEM_THEME_NAME "System"
|
||||
#define FUSION_THEME_NAME "Fusion"
|
||||
#define STYLE_CSS_NAME "style.css"
|
||||
#define HANDZONE_BG_NAME "handzone"
|
||||
|
|
@ -95,7 +96,7 @@ struct PaletteColorInfo
|
|||
static QString usableDefaultStyle(const QString &style)
|
||||
{
|
||||
// The Windows 11 native style is broken: when the OS default
|
||||
// ("Default" theme selection) would use it, fall back to the Vista style.
|
||||
// ("System" theme selection) would use it, fall back to the Vista style.
|
||||
// Explicitly choosing "windows11" in a theme is still honored.
|
||||
return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style;
|
||||
}
|
||||
|
|
@ -118,10 +119,16 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
|
|||
|
||||
void ThemeManager::ensureThemeDirectoryExists()
|
||||
{
|
||||
if (SettingsCache::instance().getThemeName().isEmpty() ||
|
||||
!getAvailableThemes().contains(SettingsCache::instance().getThemeName())) {
|
||||
auto &settings = SettingsCache::instance();
|
||||
|
||||
// Migrate the old "Default" theme name to "System"
|
||||
if (settings.getThemeName() == "Default") {
|
||||
settings.setThemeName(SYSTEM_THEME_NAME);
|
||||
}
|
||||
|
||||
if (settings.getThemeName().isEmpty() || !getAvailableThemes().contains(settings.getThemeName())) {
|
||||
qCInfo(ThemeManagerLog) << "Theme name not set, setting default value";
|
||||
SettingsCache::instance().setThemeName(NONE_THEME_NAME);
|
||||
settings.setThemeName(FUSION_THEME_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,11 +191,32 @@ QString ThemeManager::assetPath(QStringView prefix) const
|
|||
return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain;
|
||||
}
|
||||
|
||||
bool ThemeManager::isBuiltInTheme()
|
||||
// Probe whether a directory is truly writable by trying to create and remove a
|
||||
// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably
|
||||
// on Windows where UAC VirtualStore can make a system dir appear writable).
|
||||
bool ThemeManager::isDirReallyWritable(const QString &dirPath)
|
||||
{
|
||||
const auto themeName = SettingsCache::instance().getThemeName();
|
||||
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
|
||||
QFile f(probe);
|
||||
if (!f.open(QIODevice::WriteOnly)) {
|
||||
return false;
|
||||
}
|
||||
f.close();
|
||||
f.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME;
|
||||
QString ThemeManager::writableThemeDir(const QString &themeName)
|
||||
{
|
||||
// All theme writes go to the user themes directory regardless of whether
|
||||
// the resolved (system) theme directory happens to be writable. Even when a
|
||||
// write would succeed in-place, routing it to the user directory keeps the
|
||||
// install intact and guarantees changes survive upgrades.
|
||||
const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName);
|
||||
if (!QDir().mkpath(dirPath)) {
|
||||
qWarning() << "Failed to create theme save directory:" << dirPath;
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
// System (read-only) themes location, relative to the application binary.
|
||||
|
|
@ -213,9 +241,7 @@ QStringMap &ThemeManager::getAvailableThemes()
|
|||
// load themes from user profile dir
|
||||
dir.setPath(SettingsCache::instance().paths().getThemesPath());
|
||||
|
||||
// add default value
|
||||
availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default"));
|
||||
|
||||
availableThemes.insert(SYSTEM_THEME_NAME, dir.absoluteFilePath("System"));
|
||||
availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion"));
|
||||
|
||||
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
|
||||
|
|
@ -331,7 +357,7 @@ bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &col
|
|||
|
||||
void ThemeManager::setColorScheme(const QString &scheme)
|
||||
{
|
||||
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
|
||||
const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName());
|
||||
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
|
||||
|
||||
cfg.colorScheme = scheme;
|
||||
|
|
@ -342,7 +368,7 @@ void ThemeManager::setColorScheme(const QString &scheme)
|
|||
|
||||
void ThemeManager::setStyleName(const QString &styleName)
|
||||
{
|
||||
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
|
||||
const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName());
|
||||
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
|
||||
|
||||
cfg.styleName = styleName;
|
||||
|
|
@ -373,7 +399,7 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
|
|||
Q_UNUSED(activeScheme)
|
||||
#endif
|
||||
QString styleName = themeCfg.styleName;
|
||||
if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) {
|
||||
if (styleName.isEmpty() || styleName.compare("System", Qt::CaseInsensitive) == 0) {
|
||||
if (themeName == FUSION_THEME_NAME) {
|
||||
styleName = "Fusion";
|
||||
} else {
|
||||
|
|
@ -416,6 +442,8 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
|
|||
qApp->setPalette(base);
|
||||
qApp->setStyle(style);
|
||||
|
||||
currentAppColors = palCfg.appColors;
|
||||
|
||||
// Force every widget to re-polish and repaint immediately rather than
|
||||
// waiting for natural expose events, which produces a patchwork of old
|
||||
// and new colours during a live preview.
|
||||
|
|
@ -428,6 +456,35 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
|
|||
style->polish(widget);
|
||||
widget->update();
|
||||
}
|
||||
|
||||
emit paletteChanged();
|
||||
}
|
||||
|
||||
QColor ThemeManager::appColor(AppColor::Role role) const
|
||||
{
|
||||
const auto it = currentAppColors.constFind(role);
|
||||
if (it != currentAppColors.constEnd()) {
|
||||
return it.value();
|
||||
}
|
||||
|
||||
// QPalette::Accent was introduced in Qt 6.6 and several shipped palettes
|
||||
// set it to a value barely distinguishable from Window, so it is not a
|
||||
// reliable accent source. The selection highlight is the stable accent
|
||||
// (Accent defaults to Highlight when unset), and deriving from it
|
||||
// unconditionally keeps every Qt version rendering identically.
|
||||
const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight);
|
||||
|
||||
if (role == AppColor::AccentSoft) {
|
||||
constexpr int SOFT_SATURATION_PERCENT = 70;
|
||||
constexpr int SOFT_LIGHTNESS_OFFSET = 60;
|
||||
|
||||
// Light end of the gradient: same hue, softened and lightened
|
||||
return QColor::fromHsl(qMax(0, accent.hslHue()),
|
||||
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
|
||||
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
|
||||
}
|
||||
|
||||
return accent;
|
||||
}
|
||||
|
||||
void ThemeManager::themeChangedSlot()
|
||||
|
|
@ -464,8 +521,19 @@ void ThemeManager::themeChangedSlot()
|
|||
|
||||
// ── Load palette: custom first, then theme default ────────────────────
|
||||
PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme);
|
||||
if (!palette.hasPalette()) {
|
||||
palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme);
|
||||
const PaletteConfig themeDefault = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme);
|
||||
if (palette.hasPalette()) {
|
||||
// A custom palette written before [AppColors] existed carries no app
|
||||
// colors; merge the theme's shipped defaults so the identity colors
|
||||
// survive (hasPalette() counts an app-colors-only file as a palette,
|
||||
// so those are kept wholesale and never reach here empty).
|
||||
for (auto it = themeDefault.appColors.cbegin(); it != themeDefault.appColors.cend(); ++it) {
|
||||
if (!palette.appColors.contains(it.key())) {
|
||||
palette.appColors.insert(it.key(), it.value());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
palette = themeDefault;
|
||||
}
|
||||
|
||||
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ private:
|
|||
QString currentThemePath;
|
||||
std::array<QBrush, Role::MaxRole + 1> brushes;
|
||||
QStringMap availableThemes;
|
||||
QMap<AppColor::Role, QColor> currentAppColors;
|
||||
/*
|
||||
Internal cache for multiple backgrounds
|
||||
*/
|
||||
|
|
@ -65,7 +66,16 @@ protected:
|
|||
const QString &activeScheme);
|
||||
|
||||
public:
|
||||
bool isBuiltInTheme();
|
||||
// Resolves the directory to write theme changes to for the given theme
|
||||
// name. The resolved theme dir (user or system) is used when writable;
|
||||
// read-only system themes fall back to the user themes directory, creating
|
||||
// it if needed, so customisations never get lost on upgrade.
|
||||
static QString writableThemeDir(const QString &themeName);
|
||||
// Probe whether a directory is truly writable by trying to create and remove
|
||||
// a temporary file. QFileInfo::isWritable() on a directory is unreliable
|
||||
// (notably on Windows where UAC VirtualStore can make a system dir appear
|
||||
// writable).
|
||||
static bool isDirReallyWritable(const QString &dirPath);
|
||||
// Explicit color scheme of the theme: theme.cfg's ColorScheme setting
|
||||
// (Dark/Light), falling back to the OS color scheme when it is "System".
|
||||
bool isDarkMode(const QString &themeDirPath) const;
|
||||
|
|
@ -115,12 +125,17 @@ public:
|
|||
void reloadCurrentTheme();
|
||||
void previewPalette(const PaletteConfig &cfg, const QString &scheme);
|
||||
|
||||
// Resolves an application color role: the theme's stored [AppColors] value
|
||||
// when present, otherwise a palette-accent-derived fallback.
|
||||
QColor appColor(AppColor::Role role) const;
|
||||
|
||||
QBrush &getBgBrush(Role zone);
|
||||
QBrush getExtraBgBrush(Role zone, int zoneId = 0);
|
||||
protected slots:
|
||||
void themeChangedSlot();
|
||||
signals:
|
||||
void themeChanged();
|
||||
void paletteChanged();
|
||||
};
|
||||
|
||||
extern ThemeManager *themeManager;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
|
|||
}
|
||||
lastWidth = totalWidth;
|
||||
|
||||
const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
|
||||
const int totalHeight = qMax(0, totalWidth / 6); // Set height to 1/4 of the width
|
||||
setFixedHeight(totalHeight);
|
||||
|
||||
const int count = layout->count();
|
||||
|
|
@ -97,6 +97,10 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
|
|||
const int availableWidth = totalWidth - (spacing * (count - 1));
|
||||
const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
|
||||
|
||||
if (iconSize <= 0) {
|
||||
lastIconSize = iconSize;
|
||||
return;
|
||||
}
|
||||
if (iconSize == lastIconSize) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
#include "deck_color_identity.h"
|
||||
|
||||
#include <QSet>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db)
|
||||
{
|
||||
const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
|
||||
if (cardList.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QSet<QChar> colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G)
|
||||
|
||||
for (const QString &cardName : cardList) {
|
||||
CardInfoPtr currentCard = db->getCardInfo(cardName);
|
||||
if (currentCard) {
|
||||
const QString colors = currentCard->getColors(); // returns something like "WUB"
|
||||
for (const QChar &color : colors) {
|
||||
colorSet.insert(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the color identity is in WUBRG order
|
||||
QString colorIdentity;
|
||||
const QString wubrgOrder = "WUBRG";
|
||||
for (const QChar &color : wubrgOrder) {
|
||||
if (colorSet.contains(color)) {
|
||||
colorIdentity.append(color);
|
||||
}
|
||||
}
|
||||
|
||||
return colorIdentity;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
#define COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
class CardDatabaseQuerier;
|
||||
class DeckList;
|
||||
|
||||
/**
|
||||
* @brief Computes the color identity of a deck (e.g. "WUBRG") from the color
|
||||
* symbols of all cards in the main deck and sideboard, ordered WUBRG.
|
||||
*
|
||||
* Shared as a free function so the deck storage previews and the deck share
|
||||
* dialog compute identities identically.
|
||||
*
|
||||
* @param db Card database used to look up card color symbols.
|
||||
*/
|
||||
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db);
|
||||
|
||||
#endif // COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
|
|
@ -74,6 +74,8 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT
|
|||
|
||||
update();
|
||||
});
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
|
||||
&CardInfoPictureWidget::updatePixmap);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -133,12 +133,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
|
|||
path.addRoundedRect(glowRect, radius, radius);
|
||||
|
||||
// Soft outer glow
|
||||
QColor glowColor(0, 150, 255, 80); // subtle blu
|
||||
QColor glowColor = palette().color(QPalette::Highlight);
|
||||
glowColor.setAlpha(80);
|
||||
painter.setPen(QPen(glowColor, 6));
|
||||
painter.drawPath(path);
|
||||
|
||||
// Thin inner border for crispness
|
||||
QColor borderColor(0, 150, 255, 200);
|
||||
QColor borderColor = palette().color(QPalette::Highlight);
|
||||
borderColor.setAlpha(200);
|
||||
painter.setPen(QPen(borderColor, 2));
|
||||
painter.drawRoundedRect(pixmapRect, radius, radius);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "card_info_text_widget.h"
|
||||
|
||||
#include "../../../game_graphics/board/card_item.h"
|
||||
#include "../../card_localization.h"
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
|
|
@ -10,7 +11,7 @@
|
|||
#include <libcockatrice/card/game_specific_terms.h>
|
||||
#include <libcockatrice/card/relation/card_relation.h>
|
||||
|
||||
CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr)
|
||||
CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent)
|
||||
{
|
||||
propsLabel = new QLabel;
|
||||
propsLabel->setOpenExternalLinks(false);
|
||||
|
|
@ -39,6 +40,12 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(n
|
|||
grid->setRowStretch(1, 1);
|
||||
|
||||
retranslateUi();
|
||||
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, [this] {
|
||||
if (currentCard) {
|
||||
setCard(currentCard);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void CardInfoTextWidget::setTexts(const QString &propsText, const QString &textText)
|
||||
|
|
@ -60,7 +67,7 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard)
|
|||
|
||||
QString text = "<table width=\"100%\" border=0 cellspacing=0 cellpadding=0>";
|
||||
text += QString("<tr><td>%1</td><td width=\"5\"></td><td>%2</td></tr>")
|
||||
.arg(tr("Name:"), card->getName().toHtmlEscaped());
|
||||
.arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped());
|
||||
|
||||
if (!exactCard.getPrinting().isEmpty()) {
|
||||
QString setShort = exactCard.getPrinting().getSet()->getShortName().toHtmlEscaped();
|
||||
|
|
@ -94,7 +101,8 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard)
|
|||
}
|
||||
|
||||
text += "</table>";
|
||||
setTexts(text, card->getText());
|
||||
setTexts(text, CardLocalization::displayText(card));
|
||||
currentCard = exactCard;
|
||||
}
|
||||
|
||||
void CardInfoTextWidget::setInvalidCardName(const QString &cardName)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ private:
|
|||
QLabel *propsLabel;
|
||||
QScrollArea *propsScroll;
|
||||
QTextEdit *textLabel;
|
||||
CardInfoPtr info;
|
||||
ExactCard currentCard; ///< Last card set, re-rendered when the card language changes.
|
||||
void setTexts(const QString &propsText, const QString &textText);
|
||||
|
||||
public:
|
||||
|
|
|
|||
|
|
@ -27,18 +27,23 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
|
|||
const QColor &textColor,
|
||||
const QColor &outlineColor,
|
||||
const int fontSize,
|
||||
const Qt::Alignment alignment)
|
||||
const Qt::Alignment alignment,
|
||||
const bool _emitClickImmediately)
|
||||
: CardInfoPictureWithTextOverlayWidget(parent,
|
||||
hoverToZoomEnabled,
|
||||
raiseOnEnter,
|
||||
textColor,
|
||||
outlineColor,
|
||||
fontSize,
|
||||
alignment)
|
||||
alignment),
|
||||
emitClickImmediately(_emitClickImmediately)
|
||||
{
|
||||
singleClickTimer = new QTimer(this);
|
||||
singleClickTimer->setSingleShot(true);
|
||||
connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); });
|
||||
connect(singleClickTimer, &QTimer::timeout, this, [this]() {
|
||||
emit imageClicked(lastMouseEvent, this);
|
||||
emit imageSingleClicked();
|
||||
});
|
||||
connect(&SettingsCache::instance().visualDeckStorage(),
|
||||
&VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this,
|
||||
&CardInfoPictureWidget::setRaiseOnEnterEnabled);
|
||||
|
|
@ -47,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
|
|||
void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
lastMouseEvent = event;
|
||||
singleClickTimer->start(QApplication::doubleClickInterval());
|
||||
if (emitClickImmediately) {
|
||||
emit imageClicked(event, this);
|
||||
emit imageSingleClicked();
|
||||
} else {
|
||||
lastMouseEvent = event;
|
||||
singleClickTimer->start(QApplication::doubleClickInterval());
|
||||
}
|
||||
} else {
|
||||
emit imageClicked(event, this);
|
||||
event->accept();
|
||||
|
|
@ -58,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
|
|||
void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
singleClickTimer->stop(); // Prevent single-click logic
|
||||
emit imageDoubleClicked(lastMouseEvent, this);
|
||||
if (emitClickImmediately) {
|
||||
// Do not report a second single click for the second press of the
|
||||
// double-click; the consumer maps the double-click to select+open.
|
||||
lastMouseEvent = event;
|
||||
emit imageDoubleClicked(event, this);
|
||||
} else {
|
||||
singleClickTimer->stop(); // Prevent single-click logic
|
||||
emit imageDoubleClicked(lastMouseEvent, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,21 +20,38 @@ class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlay
|
|||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Constructs a DeckPreviewCardPictureWidget.
|
||||
* @param parent The parent widget.
|
||||
* @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over.
|
||||
* @param raiseOnEnter If the widget raises its border when the mouse enters.
|
||||
* @param textColor The color of the overlay text.
|
||||
* @param outlineColor The color of the outline around the text.
|
||||
* @param fontSize The font size of the overlay text.
|
||||
* @param alignment The alignment of the text within the overlay.
|
||||
* @param emitClickImmediately If true, a left click is reported immediately on click
|
||||
* instead of after the double-click interval. Use this for selection surfaces
|
||||
* where reacting to a double-click (select-and-open) would needlessly delay the
|
||||
* single-click feedback. The double-click signal is still emitted.
|
||||
*/
|
||||
explicit DeckPreviewCardPictureWidget(QWidget *parent,
|
||||
bool hoverToZoomEnabled = false,
|
||||
bool raiseOnEnter = false,
|
||||
const QColor &textColor = Qt::white,
|
||||
const QColor &outlineColor = Qt::black,
|
||||
int fontSize = 12,
|
||||
Qt::Alignment alignment = Qt::AlignCenter);
|
||||
Qt::Alignment alignment = Qt::AlignCenter,
|
||||
bool _emitClickImmediately = false);
|
||||
|
||||
signals:
|
||||
void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
|
||||
void imageSingleClicked();
|
||||
void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
|
||||
|
||||
private:
|
||||
QTimer *singleClickTimer;
|
||||
QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event
|
||||
bool emitClickImmediately;
|
||||
|
||||
protected:
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
|
|
|
|||
|
|
@ -345,7 +345,9 @@ ExactCard DeckEditorDeckDockWidget::getCurrentCard()
|
|||
if (!current.isValid()) {
|
||||
return {};
|
||||
}
|
||||
const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
|
||||
// The display role holds the localized card name; the edit role always carries the
|
||||
// canonical English name needed to look the card up in the database.
|
||||
const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
const QString cardProviderID = current.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString();
|
||||
const QModelIndex gparent = current.parent().parent();
|
||||
|
||||
|
|
@ -518,8 +520,11 @@ void DeckEditorDeckDockWidget::syncBannerCardComboBoxSelectionWithDeck()
|
|||
|
||||
void DeckEditorDeckDockWidget::setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus)
|
||||
{
|
||||
const QModelIndex proxyIndex = proxy->mapFromSource(newCardIndex);
|
||||
|
||||
deckView->clearSelection();
|
||||
deckView->setCurrentIndex(newCardIndex);
|
||||
deckView->setCurrentIndex(proxyIndex);
|
||||
deckView->scrollTo(proxyIndex);
|
||||
recursiveExpand(newCardIndex);
|
||||
|
||||
if (!preserveWidgetFocus) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
#include "deck_state_manager.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list_history_manager.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
DeckStateManager::DeckStateManager(QObject *parent)
|
||||
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
|
||||
deckListModel(new DeckListModel(this, deckList)), historyManager(new DeckListHistoryManager(this))
|
||||
{
|
||||
deckListModel->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang());
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, deckListModel,
|
||||
[this](const QString &lang) { deckListModel->setDisplayLanguage(lang); });
|
||||
|
||||
connect(historyManager, &DeckListHistoryManager::undoRedoStateChanged, this, [this] {
|
||||
setModified(true);
|
||||
emit historyChanged();
|
||||
|
|
@ -260,7 +267,10 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx)
|
|||
return false;
|
||||
}
|
||||
|
||||
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
|
||||
// The display role holds the localized card name; the edit role always carries the
|
||||
// canonical English name needed to look the card up in the database.
|
||||
QString displayCardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
|
||||
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString();
|
||||
QModelIndex gparent = idx.parent().parent();
|
||||
|
||||
|
|
@ -277,7 +287,7 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx)
|
|||
|
||||
QString reason = tr("Moved to %1 1 × \"%2\" (%3)") //
|
||||
.arg(otherZoneName)
|
||||
.arg(cardName)
|
||||
.arg(displayCardName)
|
||||
.arg(providerId);
|
||||
|
||||
return modifyDeck(reason, [&idx, &cardName, &providerId, &otherZoneName](auto model) {
|
||||
|
|
@ -291,9 +301,8 @@ bool DeckStateManager::removeCardAtIndex(const QModelIndex &idx)
|
|||
return false;
|
||||
}
|
||||
|
||||
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
|
||||
|
||||
QString reason = tr("Removed \"%1\" (all copies)").arg(cardName);
|
||||
QString reason =
|
||||
tr("Removed \"%1\" (all copies)").arg(idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString());
|
||||
|
||||
return modifyDeck(reason, [&idx](auto model) { return model->removeRow(idx.row(), idx.parent()); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
#include "deck_share_utils.h"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QGuiApplication>
|
||||
#include <QLocale>
|
||||
#include <QTimeZone>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
|
||||
|
||||
namespace DeckShareUtils
|
||||
{
|
||||
|
||||
QString buildShareLink(const AbstractClient *client, const QString &token)
|
||||
{
|
||||
QUrl url;
|
||||
url.setScheme(QStringLiteral("cockatrice"));
|
||||
url.setHost(QStringLiteral("opendeck"));
|
||||
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("share"), token);
|
||||
query.addQueryItem(QStringLiteral("hostname"), client->serverName());
|
||||
query.addQueryItem(QStringLiteral("port"), QString::number(client->serverPort()));
|
||||
url.setQuery(query);
|
||||
|
||||
return url.toString(QUrl::FullyEncoded);
|
||||
}
|
||||
|
||||
QString copyShareLinkToClipboard(const QString &link)
|
||||
{
|
||||
QGuiApplication::clipboard()->setText(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
QString formatShareExpiry(const QDateTime &expiry)
|
||||
{
|
||||
return QLocale().toString(expiry.toLocalTime(), QLocale::ShortFormat);
|
||||
}
|
||||
|
||||
ShareResponse handleShareResponse(const AbstractClient *client, const Response &response)
|
||||
{
|
||||
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
|
||||
const QString token = QString::fromStdString(resp.token());
|
||||
|
||||
const QString link = buildShareLink(client, token);
|
||||
copyShareLinkToClipboard(link);
|
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
|
||||
#else
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
|
||||
#endif
|
||||
|
||||
return {link, expiry};
|
||||
}
|
||||
|
||||
} // namespace DeckShareUtils
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* @file deck_share_utils.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DECK_SHARE_UTILS_H
|
||||
#define DECK_SHARE_UTILS_H
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
class AbstractClient;
|
||||
class Response;
|
||||
|
||||
/**
|
||||
* @brief Shared helpers for creating temporary deck shares.
|
||||
*/
|
||||
namespace DeckShareUtils
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief The outcome of a successful share-create response.
|
||||
*/
|
||||
struct ShareResponse
|
||||
{
|
||||
QString link; ///< The share link that was copied to the clipboard.
|
||||
QDateTime expiry; ///< When the share expires (UTC).
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Builds the cockatrice:// link for a freshly created deck share.
|
||||
* @param client Used to embed the target server's hostname and port.
|
||||
* @param token The share token from Response_DeckShareCreate.
|
||||
*/
|
||||
QString buildShareLink(const AbstractClient *client, const QString &token);
|
||||
|
||||
/**
|
||||
* @brief Copies the share link to the clipboard.
|
||||
* @return The link that was copied.
|
||||
*/
|
||||
QString copyShareLinkToClipboard(const QString &link);
|
||||
|
||||
/**
|
||||
* @brief Formats the expiration timestamp for a share.
|
||||
*/
|
||||
QString formatShareExpiry(const QDateTime &expiry);
|
||||
|
||||
/**
|
||||
* @brief Handles a successful Response_DeckShareCreate: builds the share link,
|
||||
* copies it to the clipboard, and derives the share expiry.
|
||||
* @param client Used to embed the target server's hostname and port.
|
||||
* @param response The successful response carrying the share token and expiry.
|
||||
*/
|
||||
ShareResponse handleShareResponse(const AbstractClient *client, const Response &response);
|
||||
|
||||
} // namespace DeckShareUtils
|
||||
|
||||
#endif // DECK_SHARE_UTILS_H
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
#include "share_bar_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
|
||||
ShareBarWidget::ShareBarWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(12, 10, 12, 10);
|
||||
layout->setSpacing(8);
|
||||
|
||||
hintLabel = new QLabel(this);
|
||||
hintLabel->setWordWrap(true);
|
||||
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setMaximumWidth(260);
|
||||
|
||||
countLabel = new QLabel(this);
|
||||
|
||||
cancelButton = new QPushButton(this);
|
||||
connect(cancelButton, &QPushButton::clicked, this, &ShareBarWidget::cancelRequested);
|
||||
|
||||
createButton = new QPushButton(this);
|
||||
createButton->setDefault(true);
|
||||
connect(createButton, &QPushButton::clicked, this, &ShareBarWidget::createRequested);
|
||||
|
||||
layout->addWidget(hintLabel, 1);
|
||||
layout->addWidget(nameEdit);
|
||||
layout->addWidget(countLabel);
|
||||
layout->addStretch();
|
||||
layout->addWidget(cancelButton);
|
||||
layout->addWidget(createButton);
|
||||
|
||||
setLayout(layout);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void ShareBarWidget::retranslateUi()
|
||||
{
|
||||
nameEdit->setPlaceholderText(tr("Share name"));
|
||||
cancelButton->setText(tr("Cancel"));
|
||||
createButton->setText(tr("Create share link"));
|
||||
}
|
||||
|
||||
QString ShareBarWidget::name() const
|
||||
{
|
||||
return nameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
void ShareBarWidget::setName(const QString &value)
|
||||
{
|
||||
nameEdit->setText(value);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setCountText(const QString &text)
|
||||
{
|
||||
countLabel->setText(text);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setHintText(const QString &text, bool visible)
|
||||
{
|
||||
hintLabel->setText(text);
|
||||
hintLabel->setVisible(visible);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setCreateEnabled(bool enabled)
|
||||
{
|
||||
createButton->setEnabled(enabled);
|
||||
}
|
||||
|
||||
void ShareBarWidget::focusName()
|
||||
{
|
||||
nameEdit->setFocus();
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* @file share_bar_widget.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef SHARE_BAR_WIDGET_H
|
||||
#define SHARE_BAR_WIDGET_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
|
||||
/**
|
||||
* @brief The activated toolbar used to create a temporary deck share.
|
||||
*
|
||||
* A single reusable component shared by the local visual deck storage and the
|
||||
* remote server deck storage tabs, so the share workflow renders identically in
|
||||
* both places. It owns its own widgets, strings, and layout; the owning tab only
|
||||
* sets the count/hint text and reacts to the create/cancel signals.
|
||||
*/
|
||||
class ShareBarWidget final : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ShareBarWidget(QWidget *parent = nullptr);
|
||||
|
||||
void retranslateUi();
|
||||
|
||||
/** @return The trimmed name entered by the user. */
|
||||
[[nodiscard]] QString name() const;
|
||||
|
||||
/** @brief Resets the name field to the given default. */
|
||||
void setName(const QString &name);
|
||||
|
||||
/** @brief Sets the selected-count summary label text. */
|
||||
void setCountText(const QString &text);
|
||||
|
||||
/** @brief Sets the explainer hint text, showing it when @p visible is true. */
|
||||
void setHintText(const QString &text, bool visible);
|
||||
|
||||
/** @brief Enables or disables the create-share-link button (guards double submission). */
|
||||
void setCreateEnabled(bool enabled);
|
||||
|
||||
/** @brief Moves keyboard focus to the name field. */
|
||||
void focusName();
|
||||
|
||||
signals:
|
||||
void createRequested();
|
||||
void cancelRequested();
|
||||
|
||||
private:
|
||||
QLabel *hintLabel;
|
||||
QLineEdit *nameEdit;
|
||||
QLabel *countLabel;
|
||||
QPushButton *cancelButton;
|
||||
QPushButton *createButton;
|
||||
};
|
||||
|
||||
#endif // SHARE_BAR_WIDGET_H
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
#include "shared_deck_preview_widget.h"
|
||||
|
||||
#include "../cards/additional_info/color_identity_widget.h"
|
||||
#include "../cards/deck_preview_card_picture_widget.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
|
||||
SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &deckName,
|
||||
const QString &bannerCardName,
|
||||
const QString &colorIdentity,
|
||||
const QString &gameFormat,
|
||||
const QString &deckToolTip)
|
||||
: QWidget(parent)
|
||||
{
|
||||
bannerCardDisplayWidget =
|
||||
new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true);
|
||||
bannerCardDisplayWidget->setScaleFactor(100);
|
||||
const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}});
|
||||
bannerCardDisplayWidget->setCard(bannerCard);
|
||||
bannerCardDisplayWidget->setOverlayText(deckName);
|
||||
setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip);
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
setBaseAccessibleName(deckName);
|
||||
|
||||
colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity);
|
||||
colorIdentityWidget->setVisible(!colorIdentity.isEmpty());
|
||||
|
||||
// gameFormat is server-supplied and the QLabel renders AutoText, so escape it.
|
||||
gameFormatLabel = new QLabel(gameFormat.toHtmlEscaped(), this);
|
||||
gameFormatLabel->setAlignment(Qt::AlignCenter);
|
||||
gameFormatLabel->setVisible(!gameFormat.isEmpty());
|
||||
|
||||
selectionCheckBox = new QCheckBox(this);
|
||||
selectionCheckBox->setToolTip(tr("Select this deck"));
|
||||
// The tile itself is focusable (Space/Enter toggles); keep the checkbox
|
||||
// from creating a second tab stop per tile.
|
||||
selectionCheckBox->setFocusPolicy(Qt::NoFocus);
|
||||
|
||||
// Selection frame reused from the deck-preview selection covenant: a
|
||||
// palette(highlight) border around the banner card, shown while selected.
|
||||
selectionFrame = new QFrame(bannerCardDisplayWidget);
|
||||
selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
selectionFrame->setStyleSheet(QStringLiteral(
|
||||
"QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }"));
|
||||
selectionFrame->setVisible(false);
|
||||
|
||||
auto *selectionRow = new QHBoxLayout;
|
||||
selectionRow->addWidget(selectionCheckBox);
|
||||
selectionRow->addStretch(1);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->addLayout(selectionRow);
|
||||
layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter);
|
||||
layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter);
|
||||
layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter);
|
||||
setLayout(layout);
|
||||
|
||||
connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
updateSelectionVisual(checked);
|
||||
emit selectionToggled(checked);
|
||||
});
|
||||
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this,
|
||||
&SharedDeckPreviewWidget::toggleSelection);
|
||||
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
|
||||
&SharedDeckPreviewWidget::activate);
|
||||
}
|
||||
|
||||
bool SharedDeckPreviewWidget::isSelected() const
|
||||
{
|
||||
return selectionCheckBox->isChecked();
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::setSelected(bool selected)
|
||||
{
|
||||
if (isSelected() == selected) {
|
||||
return;
|
||||
}
|
||||
selectionCheckBox->setChecked(selected);
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::updateSelectionVisual(bool selected)
|
||||
{
|
||||
selectionFrame->setVisible(selected);
|
||||
selectionFrame->raise();
|
||||
if (selected) {
|
||||
setAccessibleName(baseAccessibleName + tr(" (selected)"));
|
||||
} else {
|
||||
setAccessibleName(baseAccessibleName);
|
||||
}
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name)
|
||||
{
|
||||
baseAccessibleName = name;
|
||||
setAccessibleName(name);
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::toggleSelection()
|
||||
{
|
||||
setSelected(!isSelected());
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::activate()
|
||||
{
|
||||
setSelected(true);
|
||||
emit activated();
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
updateSelectionFrameGeometry();
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::updateSelectionFrameGeometry()
|
||||
{
|
||||
if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) {
|
||||
return;
|
||||
}
|
||||
selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1));
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) {
|
||||
toggleSelection();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
QWidget::keyPressEvent(event);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* @file shared_deck_preview_widget.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef SHARED_DECK_PREVIEW_WIDGET_H
|
||||
#define SHARED_DECK_PREVIEW_WIDGET_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class ColorIdentityWidget;
|
||||
class DeckPreviewCardPictureWidget;
|
||||
class QCheckBox;
|
||||
class QFrame;
|
||||
class QKeyEvent;
|
||||
class QLabel;
|
||||
class QResizeEvent;
|
||||
class CardDatabaseQuerier;
|
||||
|
||||
/**
|
||||
* @brief A selectable preview tile for a deck that has no local file.
|
||||
*
|
||||
* Renders a banner card picture (looked up by name in the card database), the
|
||||
* deck name, color identity and game format. Used to preview decks shared via a
|
||||
* cockatrice:// link (metadata from Command_DeckShareList) and the deck
|
||||
* currently open in the deck editor.
|
||||
*
|
||||
* Selection follows the deck-preview covenant: the tile reports its click
|
||||
* immediately (no double-click interval delay), a palette(highlight) frame
|
||||
* marks the selected tile, and Space/Enter toggles selection from the keyboard.
|
||||
* A double click selects the tile and emits activated() so the caller can open
|
||||
* just that deck.
|
||||
*/
|
||||
class SharedDeckPreviewWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SharedDeckPreviewWidget(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &deckName,
|
||||
const QString &bannerCardName,
|
||||
const QString &colorIdentity,
|
||||
const QString &gameFormat = QString(),
|
||||
const QString &deckToolTip = QString());
|
||||
|
||||
[[nodiscard]] bool isSelected() const;
|
||||
void setSelected(bool selected);
|
||||
|
||||
void setBaseAccessibleName(const QString &name);
|
||||
|
||||
signals:
|
||||
void selectionToggled(bool selected);
|
||||
void activated();
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void toggleSelection();
|
||||
void activate();
|
||||
|
||||
private:
|
||||
void updateSelectionVisual(bool selected);
|
||||
void updateSelectionFrameGeometry();
|
||||
|
||||
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
|
||||
ColorIdentityWidget *colorIdentityWidget;
|
||||
QLabel *gameFormatLabel;
|
||||
QCheckBox *selectionCheckBox;
|
||||
QFrame *selectionFrame;
|
||||
QString baseAccessibleName;
|
||||
};
|
||||
|
||||
#endif // SHARED_DECK_PREVIEW_WIDGET_H
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
#include "dlg_login_prompt.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Sign in"));
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
mainLayout->addWidget(
|
||||
new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this));
|
||||
|
||||
auto *formLayout = new QFormLayout;
|
||||
usernameEdit = new QLineEdit(this);
|
||||
passwordEdit = new QLineEdit(this);
|
||||
passwordEdit->setEchoMode(QLineEdit::Password);
|
||||
formLayout->addRow(tr("Username:"), usernameEdit);
|
||||
formLayout->addRow(tr("Password:"), passwordEdit);
|
||||
mainLayout->addLayout(formLayout);
|
||||
|
||||
savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this);
|
||||
mainLayout->addWidget(savePasswordCheckBox);
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
mainLayout->addWidget(buttonBox);
|
||||
|
||||
usernameEdit->setFocus();
|
||||
}
|
||||
|
||||
QString DlgLoginPrompt::username() const
|
||||
{
|
||||
return usernameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
QString DlgLoginPrompt::password() const
|
||||
{
|
||||
return passwordEdit->text();
|
||||
}
|
||||
|
||||
bool DlgLoginPrompt::savePassword() const
|
||||
{
|
||||
return savePasswordCheckBox->isChecked();
|
||||
}
|
||||
40
cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h
Normal file
40
cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @file dlg_login_prompt.h
|
||||
* @ingroup ConnectionDialogs
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DLG_LOGIN_PROMPT_H
|
||||
#define DLG_LOGIN_PROMPT_H
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
class QCheckBox;
|
||||
class QLineEdit;
|
||||
|
||||
/**
|
||||
* @brief Small sign-in dialog used when a cockatrice:// link needs credentials
|
||||
* that are not saved for the target server.
|
||||
*
|
||||
* The entered name and password are handed to the intent chain; when the user
|
||||
* opts to save them, they are stored in the server settings so that later links
|
||||
* to the same server connect seamlessly.
|
||||
*/
|
||||
class DlgLoginPrompt : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr);
|
||||
|
||||
[[nodiscard]] QString username() const;
|
||||
[[nodiscard]] QString password() const;
|
||||
[[nodiscard]] bool savePassword() const;
|
||||
|
||||
private:
|
||||
QLineEdit *usernameEdit;
|
||||
QLineEdit *passwordEdit;
|
||||
QCheckBox *savePasswordCheckBox;
|
||||
};
|
||||
|
||||
#endif // DLG_LOGIN_PROMPT_H
|
||||
|
|
@ -416,6 +416,11 @@ void DlgSettings::setTab(int index)
|
|||
}
|
||||
}
|
||||
|
||||
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
|
||||
{
|
||||
return pages.value(static_cast<int>(which));
|
||||
}
|
||||
|
||||
void DlgSettings::updateLanguage()
|
||||
{
|
||||
qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ public:
|
|||
|
||||
explicit DlgSettings(QWidget *parent = nullptr);
|
||||
void setTab(int index);
|
||||
AbstractSettingsPage *page(SettingsPage which) const;
|
||||
|
||||
private slots:
|
||||
void onTabClicked(int index);
|
||||
|
|
|
|||
96
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp
Normal file
96
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#include "dlg_share_deck.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../cards/additional_info/deck_color_identity.h"
|
||||
#include "../deck_share/deck_share_utils.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/settings/network_settings.h>
|
||||
|
||||
DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_deck, QWidget *_parent)
|
||||
: QDialog(_parent), client(_client), deck(_deck), shareTimeoutTimer(new QTimer(this))
|
||||
{
|
||||
setWindowTitle(tr("Share deck"));
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setText(tr("Shared deck"));
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
form->addRow(tr("Share name:"), nameEdit);
|
||||
layout->addLayout(form);
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create share link"));
|
||||
buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel"));
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgShareDeck::actShare);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgShareDeck::reject);
|
||||
this->buttonBox = buttonBox;
|
||||
layout->addWidget(buttonBox);
|
||||
|
||||
shareTimeoutTimer->setSingleShot(true);
|
||||
shareTimeoutTimer->setInterval(
|
||||
static_cast<int>((static_cast<qint64>(SettingsCache::instance().network().getTimeOut()) + 1) *
|
||||
SettingsCache::instance().network().getKeepAlive() * 1000));
|
||||
connect(shareTimeoutTimer, &QTimer::timeout, this, &DlgShareDeck::onShareTimeout);
|
||||
}
|
||||
|
||||
void DlgShareDeck::actShare()
|
||||
{
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
|
||||
|
||||
Command_DeckShareCreate cmd;
|
||||
cmd.set_name(nameEdit->text().trimmed().toStdString());
|
||||
if (cmd.name().empty()) {
|
||||
cmd.set_name(tr("Shared deck").toStdString());
|
||||
}
|
||||
|
||||
DeckShareItem *item = cmd.add_items();
|
||||
item->set_deck_list(deck->writeToString_Native().toStdString());
|
||||
item->set_color_identity(getDeckColorIdentity(*deck, CardDatabaseManager::query()).toStdString());
|
||||
|
||||
PendingCommand *pend = client->prepareSessionCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished);
|
||||
client->sendCommand(pend);
|
||||
shareTimeoutTimer->start();
|
||||
}
|
||||
|
||||
void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/)
|
||||
{
|
||||
shareTimeoutTimer->stop();
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
|
||||
QMessageBox::critical(this, tr("Share deck"),
|
||||
tr("Failed to create the share link (server response code %1).")
|
||||
.arg(QString::number(static_cast<int>(response.response_code()))));
|
||||
return;
|
||||
}
|
||||
|
||||
const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response);
|
||||
|
||||
QMessageBox::information(this, tr("Share deck"),
|
||||
tr("Share link created and copied to the clipboard:\n\n%1\n\n"
|
||||
"The share expires on %2.")
|
||||
.arg(share.link, DeckShareUtils::formatShareExpiry(share.expiry)));
|
||||
accept();
|
||||
}
|
||||
|
||||
void DlgShareDeck::onShareTimeout()
|
||||
{
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
|
||||
QMessageBox::warning(this, tr("Share deck"), tr("The server did not respond in time. Try again."));
|
||||
}
|
||||
46
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h
Normal file
46
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* @file dlg_share_deck.h
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DLG_SHARE_DECK_H
|
||||
#define DLG_SHARE_DECK_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QSharedPointer>
|
||||
|
||||
class AbstractClient;
|
||||
class CommandContainer;
|
||||
class DeckList;
|
||||
class QDialogButtonBox;
|
||||
class QLineEdit;
|
||||
class QTimer;
|
||||
class Response;
|
||||
|
||||
/**
|
||||
* @brief Slim dialog to create a temporary share for the deck open in the editor.
|
||||
*
|
||||
* Asks for a share name, sends Command_DeckShareCreate for the single inline
|
||||
* deck, and copies the resulting link to the clipboard.
|
||||
*/
|
||||
class DlgShareDeck : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_deck, QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void actShare();
|
||||
void shareFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
void onShareTimeout();
|
||||
|
||||
private:
|
||||
AbstractClient *client;
|
||||
QSharedPointer<DeckList> deck;
|
||||
QLineEdit *nameEdit;
|
||||
QDialogButtonBox *buttonBox;
|
||||
QTimer *shareTimeoutTimer;
|
||||
};
|
||||
|
||||
#endif // DLG_SHARE_DECK_H
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
#include "dlg_shared_decks_preview.h"
|
||||
|
||||
#include "../deck_share/shared_deck_preview_widget.h"
|
||||
#include "../general/layout_containers/flow_widget.h"
|
||||
|
||||
#include <QCloseEvent>
|
||||
#include <QDateTime>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
|
||||
|
||||
DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &shareName,
|
||||
qint64 expiresAt,
|
||||
const QString &serverText,
|
||||
const QList<ServerInfo_DeckShareItem> &items)
|
||||
: QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Open shared decks"));
|
||||
resize(700, 500);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
|
||||
// shareName and serverText come from the share server, so escape them: the
|
||||
// QLabels render AutoText and markup would otherwise be shown as rich text.
|
||||
auto *titleLabel =
|
||||
new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this);
|
||||
QFont titleFont = titleLabel->font();
|
||||
titleFont.setBold(true);
|
||||
titleFont.setPointSize(titleFont.pointSize() + 2);
|
||||
titleLabel->setFont(titleFont);
|
||||
mainLayout->addWidget(titleLabel);
|
||||
|
||||
if (!serverText.isEmpty()) {
|
||||
mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText.toHtmlEscaped()), this));
|
||||
}
|
||||
|
||||
if (expiresAt > 0) {
|
||||
const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate);
|
||||
mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this));
|
||||
}
|
||||
|
||||
downloadStatusLabel = new QLabel(this);
|
||||
downloadStatusLabel->setVisible(false);
|
||||
mainLayout->addWidget(downloadStatusLabel);
|
||||
|
||||
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
|
||||
mainLayout->addWidget(flowWidget, 1);
|
||||
|
||||
for (const ServerInfo_DeckShareItem &item : items) {
|
||||
QStringList tags;
|
||||
for (const auto &tag : item.tags()) {
|
||||
tags.append(QString::fromStdString(tag));
|
||||
}
|
||||
|
||||
auto *tile = new SharedDeckPreviewWidget(
|
||||
this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()),
|
||||
QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", "));
|
||||
flowWidget->addNavigableWidget(tile);
|
||||
tiles.append(tile);
|
||||
itemIds.append(item.id());
|
||||
}
|
||||
|
||||
if (tiles.size() == 1) {
|
||||
tiles.first()->setSelected(true);
|
||||
}
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(this);
|
||||
openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole);
|
||||
openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole);
|
||||
buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole);
|
||||
mainLayout->addWidget(buttonBox);
|
||||
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() {
|
||||
onCancel();
|
||||
close();
|
||||
});
|
||||
|
||||
// Esc calls QDialog::reject() directly (which hides the dialog without a
|
||||
// close event), so route it through the same guarded cancel as the button.
|
||||
connect(this, &QDialog::rejected, this, [this]() {
|
||||
onCancel();
|
||||
close();
|
||||
});
|
||||
|
||||
connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected);
|
||||
connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) {
|
||||
if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) {
|
||||
openAll();
|
||||
}
|
||||
});
|
||||
|
||||
for (SharedDeckPreviewWidget *tile : tiles) {
|
||||
connect(tile, &SharedDeckPreviewWidget::selectionToggled, this,
|
||||
&DlgSharedDecksPreview::updateOpenSelectedEnabled);
|
||||
}
|
||||
for (int i = 0; i < tiles.size(); ++i) {
|
||||
const int itemId = itemIds.at(i);
|
||||
// Double-clicking a tile selects it and opens just that deck.
|
||||
connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() {
|
||||
resultEmitted = true;
|
||||
setDownloading(true);
|
||||
emit openRequested(QList<int>{itemId});
|
||||
});
|
||||
}
|
||||
updateOpenSelectedEnabled();
|
||||
}
|
||||
|
||||
QList<int> DlgSharedDecksPreview::selectedItemIds() const
|
||||
{
|
||||
QList<int> selectedIds;
|
||||
for (int i = 0; i < tiles.size(); ++i) {
|
||||
if (tiles.at(i)->isSelected()) {
|
||||
selectedIds.append(itemIds.at(i));
|
||||
}
|
||||
}
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::openSelected()
|
||||
{
|
||||
const QList<int> selectedIds = selectedItemIds();
|
||||
if (selectedIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
resultEmitted = true;
|
||||
setDownloading(true);
|
||||
emit openRequested(selectedIds);
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::openAll()
|
||||
{
|
||||
resultEmitted = true;
|
||||
setDownloading(true);
|
||||
emit openRequested(itemIds);
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::setDownloading(bool downloading)
|
||||
{
|
||||
if (downloadInProgress == downloading) {
|
||||
return;
|
||||
}
|
||||
downloadInProgress = downloading;
|
||||
downloadStatusLabel->setVisible(downloading);
|
||||
for (SharedDeckPreviewWidget *tile : tiles) {
|
||||
tile->setEnabled(!downloading);
|
||||
}
|
||||
openSelectedButton->setEnabled(!downloading);
|
||||
openAllButton->setEnabled(!downloading);
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString ¤tDeckName)
|
||||
{
|
||||
if (!downloadInProgress) {
|
||||
return;
|
||||
}
|
||||
downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName));
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::updateOpenSelectedEnabled()
|
||||
{
|
||||
openSelectedButton->setEnabled(!selectedItemIds().isEmpty());
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::onCancel()
|
||||
{
|
||||
if (!resultEmitted || downloadInProgress) {
|
||||
resultEmitted = true;
|
||||
emit cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
onCancel();
|
||||
QDialog::closeEvent(event);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QList>
|
||||
|
||||
class FlowWidget;
|
||||
class QCloseEvent;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class ServerInfo_DeckShareItem;
|
||||
class SharedDeckPreviewWidget;
|
||||
class CardDatabaseQuerier;
|
||||
|
||||
/**
|
||||
* @brief Non-modal preview of the decks contained in a shared-deck link.
|
||||
*
|
||||
* Lets the user pick which of the shared decks to open before anything is
|
||||
* downloaded. Emits openRequested with the ids of the chosen decks, or
|
||||
* cancelled when the user closes the dialog without choosing. Once the user
|
||||
* picks, the dialog switches into a "downloading" state: the tiles and open
|
||||
* buttons are disabled, a progress label shows the current download and Cancel
|
||||
* stays functional so the download can be aborted.
|
||||
*/
|
||||
class DlgSharedDecksPreview : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DlgSharedDecksPreview(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &shareName,
|
||||
qint64 expiresAt,
|
||||
const QString &serverText,
|
||||
const QList<ServerInfo_DeckShareItem> &items);
|
||||
|
||||
void setDownloadProgress(int done, int total, const QString ¤tDeckName);
|
||||
|
||||
public slots:
|
||||
void setDownloading(bool downloading);
|
||||
|
||||
signals:
|
||||
void openRequested(const QList<int> &itemIds);
|
||||
void cancelled();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void openSelected();
|
||||
void openAll();
|
||||
void updateOpenSelectedEnabled();
|
||||
void onCancel();
|
||||
|
||||
private:
|
||||
QList<int> selectedItemIds() const;
|
||||
|
||||
FlowWidget *flowWidget;
|
||||
QList<SharedDeckPreviewWidget *> tiles;
|
||||
QList<int> itemIds;
|
||||
QPushButton *openSelectedButton;
|
||||
QPushButton *openAllButton;
|
||||
QLabel *downloadStatusLabel;
|
||||
bool resultEmitted = false;
|
||||
bool downloadInProgress = false;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
|
|
@ -11,8 +11,8 @@ namespace HomeTabButtonColor
|
|||
*/
|
||||
enum Source
|
||||
{
|
||||
Automatic, ///< Extract color from background, or use theme color if no background
|
||||
FromBackground, ///< Always extract color from background
|
||||
FromThemeColors, ///< Use the theme's identity accent colors
|
||||
FromBackground, ///< Extract colour from the background image
|
||||
};
|
||||
|
||||
struct Entry
|
||||
|
|
@ -23,7 +23,7 @@ struct Entry
|
|||
|
||||
inline QList<Entry> all()
|
||||
{
|
||||
static QList<Entry> entries = {{Automatic, QT_TR_NOOP("Automatic")},
|
||||
static QList<Entry> entries = {{FromThemeColors, QT_TR_NOOP("From theme colors")},
|
||||
{FromBackground, QT_TR_NOOP("Extract from background")}};
|
||||
|
||||
return entries;
|
||||
|
|
@ -33,12 +33,12 @@ inline QList<Entry> all()
|
|||
* Safely converts an int into the corresponding Source.
|
||||
*
|
||||
* @param value The int value
|
||||
* @return The Source. Returns Source::Automatic if the value is not within range
|
||||
* @return The Source. Returns Source::FromThemeColors if the value is not within range
|
||||
*/
|
||||
inline Source intToSource(int value)
|
||||
{
|
||||
if (value > FromBackground) {
|
||||
return Automatic; // default
|
||||
return FromThemeColors; // default
|
||||
}
|
||||
|
||||
return static_cast<Source>(value);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
#include "home_tab_button_color.h"
|
||||
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPushButton>
|
||||
|
|
@ -21,8 +23,7 @@
|
|||
#include <libcockatrice/settings/paths_settings.h>
|
||||
|
||||
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
|
||||
: QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))),
|
||||
overlay(themePixmap(QStringLiteral("cockatrice")))
|
||||
: QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home")))
|
||||
{
|
||||
layout = new QGridLayout(this);
|
||||
|
||||
|
|
@ -54,6 +55,8 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
|
|||
// Lambda is cleaner to read than overloading this
|
||||
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this,
|
||||
[this] { repaint(); });
|
||||
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundDimChanged, this,
|
||||
[this] { repaint(); });
|
||||
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
|
||||
&HomeWidget::initializeBackgroundFromSource);
|
||||
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
|
||||
|
|
@ -61,12 +64,18 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
|
|||
// Scheme flips (light/dark/system with an OS switch) fire on themeManager,
|
||||
// not on SettingsCache::themeChanged, so re-resolve the variant background.
|
||||
connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource);
|
||||
connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateButtonsToBackgroundColor);
|
||||
connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateLogoOverlay);
|
||||
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this,
|
||||
&HomeWidget::updateButtonsToBackgroundColor);
|
||||
}
|
||||
|
||||
void HomeWidget::initializeBackgroundFromSource()
|
||||
{
|
||||
// The featured logo is theme/scheme-derived too; reload it alongside the
|
||||
// background so a theme or appearance switch doesn't leave it stale.
|
||||
updateLogoOverlay();
|
||||
|
||||
if (CardDatabaseManager::getInstance()->getLoadStatus() != LoadStatus::Ok) {
|
||||
connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this,
|
||||
&HomeWidget::initializeBackgroundFromSource);
|
||||
|
|
@ -105,32 +114,24 @@ void HomeWidget::loadBackgroundSourceDeck()
|
|||
backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList();
|
||||
}
|
||||
|
||||
static bool isDefaultBackgroundAndTheme()
|
||||
static QPair<QColor, QColor> paletteDerivedButtonColors()
|
||||
{
|
||||
QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource();
|
||||
return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme;
|
||||
return {themeManager->appColor(AppColor::AccentStrong), themeManager->appColor(AppColor::AccentSoft)};
|
||||
}
|
||||
|
||||
QPair<QColor, QColor> HomeWidget::determineButtonColor() const
|
||||
{
|
||||
static QPair defaultColor = {QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)};
|
||||
|
||||
auto colorSource =
|
||||
HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex());
|
||||
|
||||
switch (colorSource) {
|
||||
case HomeTabButtonColor::Automatic: {
|
||||
if (isDefaultBackgroundAndTheme()) {
|
||||
return defaultColor;
|
||||
} else {
|
||||
return extractDominantColors(background);
|
||||
}
|
||||
}
|
||||
case HomeTabButtonColor::FromThemeColors:
|
||||
return paletteDerivedButtonColors();
|
||||
case HomeTabButtonColor::FromBackground:
|
||||
return extractDominantColors(background);
|
||||
}
|
||||
|
||||
return defaultColor;
|
||||
return paletteDerivedButtonColors();
|
||||
}
|
||||
|
||||
void HomeWidget::setRandomCard(ExactCard &newCard)
|
||||
|
|
@ -234,10 +235,10 @@ QGroupBox *HomeWidget::createButtons()
|
|||
QVBoxLayout *boxLayout = new QVBoxLayout;
|
||||
boxLayout->setAlignment(Qt::AlignHCenter);
|
||||
|
||||
QLabel *logoLabel = new QLabel;
|
||||
logoLabel->setPixmap(overlay.scaledToWidth(200, Qt::SmoothTransformation));
|
||||
logoLabel = new QLabel;
|
||||
logoLabel->setAlignment(Qt::AlignCenter);
|
||||
boxLayout->addWidget(logoLabel);
|
||||
updateLogoOverlay();
|
||||
boxLayout->addSpacing(25);
|
||||
|
||||
connectButton = new HomeStyledButton("Connect/Play", gradientColors);
|
||||
|
|
@ -365,13 +366,15 @@ void HomeWidget::paintEvent(QPaintEvent *event)
|
|||
painter.drawPixmap(topLeft, toDraw);
|
||||
}
|
||||
|
||||
// Draw translucent black overlay with rounded corners
|
||||
QRectF overlayRect(5, 5, width() - 10, height() - 10);
|
||||
QPainterPath roundedRectPath;
|
||||
roundedRectPath.addRoundedRect(overlayRect, 20, 20);
|
||||
if (SettingsCache::instance().appearance().getHomeTabBackgroundDim()) {
|
||||
// Draw translucent black overlay with rounded corners
|
||||
QRectF overlayRect(5, 5, width() - 10, height() - 10);
|
||||
QPainterPath roundedRectPath;
|
||||
roundedRectPath.addRoundedRect(overlayRect, 20, 20);
|
||||
|
||||
QColor semiTransparentBlack(0, 0, 0, static_cast<int>(255 * 0.33));
|
||||
painter.fillPath(roundedRectPath, semiTransparentBlack);
|
||||
QColor semiTransparentBlack(0, 0, 0, static_cast<int>(255 * 0.33));
|
||||
painter.fillPath(roundedRectPath, semiTransparentBlack);
|
||||
}
|
||||
|
||||
// Card name overlay (above the attribution, bottom-right)
|
||||
QString cardName;
|
||||
|
|
@ -436,3 +439,56 @@ void HomeWidget::paintEvent(QPaintEvent *event)
|
|||
|
||||
QWidget::paintEvent(event);
|
||||
}
|
||||
|
||||
void HomeWidget::updateLogoOverlay()
|
||||
{
|
||||
// Emulate cockatrice.svg in Qt rather than rendering the baked-in SVG.
|
||||
// The SVG has no separate plate: the gradient fills the bird's silhouette
|
||||
// paths (light #c9fd62/AccentSoft at the top-left, dark #139740/AccentStrong
|
||||
// toward the bottom-right — the SVG's linearGradient4265-7-8 stops along
|
||||
// its userSpaceOnUse axis), and the white highlight path
|
||||
// (cockatrice-logo-white) sits on top. So we paint that gradient clipped to
|
||||
// the full logo silhouette (the full-color logo's alpha), then overlay the
|
||||
// white mark. Colours stay fully theme-driven and independent of the static
|
||||
// greens baked into the SVG.
|
||||
const QColor strong = themeManager->appColor(AppColor::AccentStrong);
|
||||
const QColor soft = themeManager->appColor(AppColor::AccentSoft);
|
||||
|
||||
const QPixmap silhouette = themePixmap(QStringLiteral("cockatrice")).scaledToWidth(200, Qt::SmoothTransformation);
|
||||
const QPixmap whiteMark =
|
||||
themePixmap(QStringLiteral("cockatrice-logo-white")).scaledToWidth(200, Qt::SmoothTransformation);
|
||||
if (silhouette.isNull() || whiteMark.isNull()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QPixmap composite(silhouette.size());
|
||||
composite.fill(Qt::transparent);
|
||||
|
||||
{
|
||||
QPainter painter(&composite);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform);
|
||||
|
||||
// Recreate cockatrice.svg's own gradient geometry (linearGradient
|
||||
// 4265-7-8, userSpaceOnUse): light AccentSoft at S=(-8.097,-97.746),
|
||||
// dark AccentStrong at E=(162.455,295.208), on the SVG's 300x300
|
||||
// canvas. Scale those coordinates to this composite's size.
|
||||
const qreal scale = composite.width() / 300.0;
|
||||
QLinearGradient gradient(QPointF(-8.097, -97.746) * scale, QPointF(162.455, 295.208) * scale);
|
||||
gradient.setColorAt(0.0, soft);
|
||||
gradient.setColorAt(1.0, strong);
|
||||
painter.fillRect(composite.rect(), gradient);
|
||||
|
||||
// Clip the gradient to the full logo silhouette exactly as the SVG's
|
||||
// gradient paths are confined to the bird.
|
||||
painter.setCompositionMode(QPainter::CompositionMode_DestinationIn);
|
||||
painter.drawPixmap(0, 0, silhouette);
|
||||
|
||||
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
|
||||
painter.drawPixmap(0, 0, whiteMark);
|
||||
}
|
||||
|
||||
if (logoLabel) {
|
||||
logoLabel->setPixmap(composite);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@
|
|||
#include <QWidget>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
|
||||
class QGridLayout;
|
||||
class QLabel;
|
||||
|
||||
class HomeWidget : public QWidget
|
||||
{
|
||||
|
||||
|
|
@ -41,13 +44,14 @@ private:
|
|||
QPixmap background;
|
||||
CardInfoPictureArtCropWidget *backgroundSourceCard = nullptr;
|
||||
DeckList backgroundSourceDeck;
|
||||
QPixmap overlay;
|
||||
QLabel *logoLabel = nullptr;
|
||||
QPair<QColor, QColor> gradientColors;
|
||||
HomeStyledButton *connectButton;
|
||||
|
||||
void setRandomCard(ExactCard &newCard);
|
||||
void loadBackgroundSourceDeck();
|
||||
QPair<QColor, QColor> determineButtonColor() const;
|
||||
void updateLogoOverlay();
|
||||
};
|
||||
|
||||
#endif // HOME_WIDGET_H
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include "flow_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QResizeEvent>
|
||||
#include <QScrollArea>
|
||||
#include <QSizePolicy>
|
||||
|
|
@ -80,13 +81,35 @@ FlowWidget::FlowWidget(QWidget *parent,
|
|||
/**
|
||||
* @brief Adds a widget to the flow layout within the FlowWidget.
|
||||
*
|
||||
* Plain widgets are not filtered for arrow keys: intercepting them would steal
|
||||
* Up/Down/Left/Right from controls that use them (combo boxes, spin boxes
|
||||
* etc.). Widgets that want keyboard navigation between flow items must be
|
||||
* added via addNavigableWidget instead.
|
||||
*
|
||||
* @param widget_to_add The widget to add to the flow layout.
|
||||
*/
|
||||
void FlowWidget::addWidget(QWidget *widget_to_add) const
|
||||
void FlowWidget::addWidget(QWidget *widget_to_add)
|
||||
{
|
||||
flowLayout->addWidget(widget_to_add);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Adds a widget and routes its arrow keys to FlowWidget focus navigation.
|
||||
*
|
||||
* The widget is filtered for arrow-key events so keyboard navigation between
|
||||
* the flow items keeps working even when the flow sits inside a QScrollArea,
|
||||
* which swallows arrow keys before they can reach FlowWidget::keyPressEvent.
|
||||
* Only widgets added through this method are affected; anything that needs its
|
||||
* own arrow keys should use plain addWidget.
|
||||
*
|
||||
* @param widget_to_add The widget to add to the flow layout.
|
||||
*/
|
||||
void FlowWidget::addNavigableWidget(QWidget *widget_to_add)
|
||||
{
|
||||
widget_to_add->installEventFilter(this);
|
||||
flowLayout->addWidget(widget_to_add);
|
||||
}
|
||||
|
||||
void FlowWidget::insertWidgetAtIndex(QWidget *toInsert, int index)
|
||||
{
|
||||
flowLayout->insertWidgetAtIndex(toInsert, index);
|
||||
|
|
@ -177,6 +200,66 @@ QLayoutItem *FlowWidget::itemAt(int index) const
|
|||
return flowLayout->itemAt(index);
|
||||
}
|
||||
|
||||
void FlowWidget::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
if (moveFocus(event)) {
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
QWidget::keyPressEvent(event);
|
||||
}
|
||||
|
||||
bool FlowWidget::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::KeyPress && moveFocus(static_cast<QKeyEvent *>(event))) {
|
||||
return true;
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
bool FlowWidget::moveFocus(QKeyEvent *event)
|
||||
{
|
||||
// Keyboard navigation between the flow items: arrow keys move focus just
|
||||
// like clicking the sibling tiles would. Only items that can take keyboard
|
||||
// focus (e.g. the deck-preview tiles in shared-deck links) are visited.
|
||||
const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down;
|
||||
const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up;
|
||||
if (!moveForward && !moveBackward) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QList<QWidget *> focusableItems;
|
||||
for (int i = 0; i < flowLayout->count(); ++i) {
|
||||
QWidget *item = flowLayout->itemAt(i)->widget();
|
||||
if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) {
|
||||
focusableItems.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (focusableItems.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int currentIndex = -1;
|
||||
for (int i = 0; i < focusableItems.size(); ++i) {
|
||||
if (focusableItems.at(i)->hasFocus()) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const int delta = moveForward ? 1 : -1;
|
||||
int nextIndex;
|
||||
if (currentIndex < 0) {
|
||||
nextIndex = moveForward ? 0 : focusableItems.size() - 1;
|
||||
} else {
|
||||
nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size();
|
||||
}
|
||||
focusableItems.value(nextIndex)->setFocus();
|
||||
event->accept();
|
||||
return true;
|
||||
}
|
||||
|
||||
int FlowWidget::count() const
|
||||
{
|
||||
return flowLayout->count();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include "../../../layouts/flow_layout.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QLoggingCategory>
|
||||
#include <QScrollArea>
|
||||
#include <QWidget>
|
||||
|
|
@ -28,7 +29,8 @@ public:
|
|||
Qt::ScrollBarPolicy horizontalPolicy,
|
||||
Qt::ScrollBarPolicy verticalPolicy);
|
||||
|
||||
void addWidget(QWidget *widget_to_add) const;
|
||||
void addWidget(QWidget *widget_to_add);
|
||||
void addNavigableWidget(QWidget *widget_to_add);
|
||||
void insertWidgetAtIndex(QWidget *toInsert, int index);
|
||||
void removeWidget(QWidget *widgetToRemove) const;
|
||||
void clearLayout();
|
||||
|
|
@ -43,9 +45,15 @@ public slots:
|
|||
void setSpacing(int hSpacing, int vSpacing);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
|
||||
private:
|
||||
/// @brief Moves keyboard focus to an adjacent flow item for an arrow-key event.
|
||||
/// @return True when the event was an arrow key and was handled.
|
||||
bool moveFocus(QKeyEvent *event);
|
||||
|
||||
Qt::Orientation flowDirection;
|
||||
QHBoxLayout *mainLayout;
|
||||
FlowLayout *flowLayout;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
|
|||
aSaveDeckAs = new QAction(QString(), this);
|
||||
connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs);
|
||||
|
||||
aShareDeck = new QAction(QString(), this);
|
||||
connect(aShareDeck, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actShareDeck);
|
||||
|
||||
aLoadDeckFromClipboard = new QAction(QString(), this);
|
||||
connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard);
|
||||
|
||||
|
|
@ -96,6 +99,7 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
|
|||
addMenu(loadRecentDeckMenu);
|
||||
addAction(aSaveDeck);
|
||||
addAction(aSaveDeckAs);
|
||||
addAction(aShareDeck);
|
||||
addSeparator();
|
||||
addAction(aLoadDeckFromClipboard);
|
||||
addMenu(editDeckInClipboardMenu);
|
||||
|
|
@ -120,6 +124,7 @@ void DeckEditorMenu::setSaveStatus(bool newStatus)
|
|||
{
|
||||
aSaveDeck->setEnabled(newStatus);
|
||||
aSaveDeckAs->setEnabled(newStatus);
|
||||
aShareDeck->setEnabled(newStatus);
|
||||
aSaveDeckToClipboard->setEnabled(newStatus);
|
||||
aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus);
|
||||
aSaveDeckToClipboardRaw->setEnabled(newStatus);
|
||||
|
|
@ -157,6 +162,7 @@ void DeckEditorMenu::retranslateUi()
|
|||
aClearRecents->setText(tr("Clear"));
|
||||
aSaveDeck->setText(tr("&Save deck"));
|
||||
aSaveDeckAs->setText(tr("Save deck &as..."));
|
||||
aShareDeck->setText(tr("Share deck..."));
|
||||
|
||||
aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard..."));
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ public:
|
|||
QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard,
|
||||
*aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo,
|
||||
*aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite,
|
||||
*aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose;
|
||||
*aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aShareDeck,
|
||||
*aClose;
|
||||
QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu;
|
||||
|
||||
void setSaveStatus(bool newStatus);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ class BannerShaderConfig : public QObject
|
|||
Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged)
|
||||
Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged)
|
||||
Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged)
|
||||
Q_PROPERTY(QColor glowColor READ glowColor WRITE setGlowColor NOTIFY glowColorChanged)
|
||||
Q_PROPERTY(QColor brandStrong READ brandStrong WRITE setBrandStrong NOTIFY brandStrongChanged)
|
||||
Q_PROPERTY(QColor brandSoft READ brandSoft WRITE setBrandSoft NOTIFY brandSoftChanged)
|
||||
Q_PROPERTY(qreal vignetteMin READ vignetteMin WRITE setVignetteMin NOTIFY vignetteMinChanged)
|
||||
|
||||
Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged)
|
||||
Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged)
|
||||
|
|
@ -185,6 +189,54 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
QColor glowColor() const
|
||||
{
|
||||
return m_glowColor;
|
||||
}
|
||||
void setGlowColor(const QColor &c)
|
||||
{
|
||||
if (c != m_glowColor) {
|
||||
m_glowColor = c;
|
||||
emit glowColorChanged();
|
||||
}
|
||||
}
|
||||
|
||||
QColor brandStrong() const
|
||||
{
|
||||
return m_brandStrong;
|
||||
}
|
||||
void setBrandStrong(const QColor &c)
|
||||
{
|
||||
if (c != m_brandStrong) {
|
||||
m_brandStrong = c;
|
||||
emit brandStrongChanged();
|
||||
}
|
||||
}
|
||||
|
||||
QColor brandSoft() const
|
||||
{
|
||||
return m_brandSoft;
|
||||
}
|
||||
void setBrandSoft(const QColor &c)
|
||||
{
|
||||
if (c != m_brandSoft) {
|
||||
m_brandSoft = c;
|
||||
emit brandSoftChanged();
|
||||
}
|
||||
}
|
||||
|
||||
qreal vignetteMin() const
|
||||
{
|
||||
return m_vignetteMin;
|
||||
}
|
||||
void setVignetteMin(qreal v)
|
||||
{
|
||||
if (v != m_vignetteMin) {
|
||||
m_vignetteMin = v;
|
||||
emit vignetteMinChanged();
|
||||
}
|
||||
}
|
||||
|
||||
bool logoVisible() const
|
||||
{
|
||||
return m_logoVisible;
|
||||
|
|
@ -222,6 +274,10 @@ signals:
|
|||
void colorAChanged();
|
||||
void colorBChanged();
|
||||
void accentChanged();
|
||||
void glowColorChanged();
|
||||
void brandStrongChanged();
|
||||
void brandSoftChanged();
|
||||
void vignetteMinChanged();
|
||||
void logoVisibleChanged();
|
||||
void logoGlowChanged();
|
||||
|
||||
|
|
@ -239,9 +295,16 @@ private:
|
|||
|
||||
bool m_frontIsA = true;
|
||||
|
||||
// Curated fallback seed values -- BannerHost overwrites these with
|
||||
// palette-derived colours (see shader_banner_widget.cpp) before the first
|
||||
// paint, so they only matter as a safe pre-first-apply default.
|
||||
QColor m_colorA{0x1A, 0x1A, 0x20};
|
||||
QColor m_colorB{0x0E, 0x0E, 0x12};
|
||||
QColor m_accent{0x8B, 0xDD, 0x6B};
|
||||
QColor m_glowColor{Qt::white};
|
||||
QColor m_brandStrong{0x13, 0x97, 0x40};
|
||||
QColor m_brandSoft{0xC9, 0xFD, 0x62};
|
||||
qreal m_vignetteMin = 0.62;
|
||||
|
||||
bool m_logoVisible = false;
|
||||
qreal m_logoGlow = 1.0;
|
||||
|
|
|
|||
15
cockatrice/src/interface/widgets/onboarding/brand_colors.h
Normal file
15
cockatrice/src/interface/widgets/onboarding/brand_colors.h
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#ifndef BRAND_COLORS_H
|
||||
#define BRAND_COLORS_H
|
||||
|
||||
#include <QColor>
|
||||
|
||||
/** @brief Cockatrice brand green.
|
||||
*
|
||||
* Single source of truth for the onboarding brand accent: it backs the
|
||||
* banner's shader-accent uniform as the curated fallback when the active
|
||||
* palette resolves no usable Highlight, and it preseads the wizard's
|
||||
* QuickSetupPanel so a freshly generated palette keeps the brand identity
|
||||
* until the user picks their own look. */
|
||||
inline const QColor kCockatriceBrandGreen(0x8B, 0xDD, 0x6B);
|
||||
|
||||
#endif // BRAND_COLORS_H
|
||||
|
|
@ -14,10 +14,31 @@
|
|||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPalette>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
/** @brief A theme's shipped identity accent, immune to any user- or auto-
|
||||
* generated palette that may currently be masking appColor(). */
|
||||
QColor themeIdentityAccent(const QString &themeDirPath, const QString &themeName)
|
||||
{
|
||||
for (const QString &scheme : {QStringLiteral("Light"), QStringLiteral("Dark")}) {
|
||||
const PaletteConfig cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme);
|
||||
if (cfg.appColors.contains(AppColor::AccentStrong)) {
|
||||
return cfg.appColors.value(AppColor::AccentStrong);
|
||||
}
|
||||
const QColor highlight = cfg.colors.value(QPalette::Active).value(QPalette::Highlight);
|
||||
if (highlight.isValid()) {
|
||||
return highlight;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
|
||||
{
|
||||
themeCombo = new QComboBox(this);
|
||||
|
|
@ -30,6 +51,15 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
|
|||
|
||||
quickSetupPanel = new QuickSetupPanel(this);
|
||||
|
||||
// Seed the picker from the current theme's own identity accent rather than
|
||||
// a hardcoded brand green: Plasma seeds violet, Fusion green, etc., and it
|
||||
// is immune to stale generated palettes that may mask appColor(). This was
|
||||
// initially a brand-green workaround from before Fusion became the default.
|
||||
// setAccentColor blocks signals, so this never triggers a generation.
|
||||
lastSeededTheme = SettingsCache::instance().getThemeName();
|
||||
quickSetupPanel->setAccentColor(
|
||||
themeIdentityAccent(themeManager->getAvailableThemes().value(lastSeededTheme), lastSeededTheme));
|
||||
|
||||
connect(themeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged);
|
||||
connect(schemeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged);
|
||||
connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent);
|
||||
|
|
@ -47,7 +77,8 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
|
|||
// Mirrors AppearanceSettingsPage's identical listener for the combo-sync
|
||||
// half of this.
|
||||
connect(themeManager, &ThemeManager::themeChanged, this, [this] {
|
||||
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
|
||||
const QString newTheme = SettingsCache::instance().getThemeName();
|
||||
const QString newDir = themeManager->getAvailableThemes().value(newTheme);
|
||||
const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir);
|
||||
const QString current = cfg.colorScheme;
|
||||
|
||||
|
|
@ -56,6 +87,14 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
|
|||
schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
|
||||
schemeCombo->blockSignals(false);
|
||||
|
||||
// Keep the picker's accent in step with the theme's own identity; the
|
||||
// swatch seeded at construction would otherwise stay stale (e.g. green
|
||||
// from a previous theme) when the user toggles themes.
|
||||
if (newTheme != lastSeededTheme) {
|
||||
lastSeededTheme = newTheme;
|
||||
quickSetupPanel->setAccentColor(themeIdentityAccent(newDir, newTheme));
|
||||
}
|
||||
|
||||
maybeAutoGeneratePalette();
|
||||
});
|
||||
|
||||
|
|
@ -158,8 +197,14 @@ void ThemeSetupPage::maybeAutoGeneratePalette()
|
|||
const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
|
||||
const QString scheme = resolvedScheme();
|
||||
|
||||
// The theme dir may resolve to the user profile even for built-in themes
|
||||
// (getAvailableThemes gives the user copy precedence), so consult the
|
||||
// shipped palette too -- both via loadDefaultPaletteConfig's system fallback.
|
||||
// Without it, scheme flips regenerate a fresh palette from the picker accent
|
||||
// and clobber the curated colours the theme explicitly ships.
|
||||
if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() ||
|
||||
PaletteConfig::fromDefault(dirPath, scheme).hasPalette()) {
|
||||
ThemeManager::loadDefaultPaletteConfig(dirPath, SettingsCache::instance().getThemeName(), scheme)
|
||||
.hasPalette()) {
|
||||
return; // theme already has something real to show -- leave it alone
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ private:
|
|||
QComboBox *homeTabBackgroundCombo;
|
||||
|
||||
bool paletteDirty = false;
|
||||
|
||||
/// Theme whose identity accent currently seeds the picker.
|
||||
QString lastSeededTheme;
|
||||
};
|
||||
|
||||
#endif // THEME_SETUP_PAGE_H
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ Item {
|
|||
property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0)
|
||||
property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0)
|
||||
property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0)
|
||||
property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0)
|
||||
property real uVignetteMin: bannerConfig.vignetteMin
|
||||
property real uLogoGlow: bannerConfig.logoGlow
|
||||
fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb"
|
||||
}
|
||||
|
|
@ -33,30 +35,62 @@ Item {
|
|||
property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0)
|
||||
property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0)
|
||||
property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0)
|
||||
property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0)
|
||||
property real uVignetteMin: bannerConfig.vignetteMin
|
||||
property real uLogoGlow: bannerConfig.logoGlow
|
||||
fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb"
|
||||
}
|
||||
|
||||
// The hero logo itself — breathes cleanly over a 0.5–1.0 opacity range
|
||||
Image {
|
||||
id: logo
|
||||
anchors.centerIn: parent
|
||||
// The white logo sits at full opacity on top of the static gradient plate —
|
||||
// no glow, no breathing. The plate matches home_widget's QPainter composite.
|
||||
Item {
|
||||
id: logoHost
|
||||
visible: bannerConfig.logoVisible
|
||||
source: "qrc:/resources/cockatrice-logo-white.svg"
|
||||
anchors.centerIn: parent
|
||||
width: root.height * 0.6
|
||||
height: width * (sourceSize.height > 0 ? sourceSize.height / Math.max(sourceSize.width, 1) : 1)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
opacity: 0.5 + 0.5 * bannerConfig.logoGlow
|
||||
sourceSize: Qt.size(256, 256)
|
||||
height: width
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } }
|
||||
// The full-color logo renders beneath the white mark and is consumed as
|
||||
// a texture (layer.enabled) by the plate shader's silhouette mask, so
|
||||
// the gradient is clipped to the bird exactly as the SVG's gradient
|
||||
// paths are. It is never drawn to the screen itself.
|
||||
Image {
|
||||
id: silhouetteMask
|
||||
anchors.fill: parent
|
||||
source: "qrc:/resources/cockatrice.svg"
|
||||
sourceSize: Qt.size(256, 256)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
visible: false
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
}
|
||||
|
||||
transform: Scale {
|
||||
origin.x: logo.width / 2
|
||||
origin.y: logo.height / 2
|
||||
xScale: 0.94 + 0.06 * bannerConfig.logoGlow
|
||||
yScale: 0.94 + 0.06 * bannerConfig.logoGlow
|
||||
// The logo's gradient plate, drawn behind the mark: a linear
|
||||
// AccentSoft (light) -> AccentStrong (dark) sheet along the same
|
||||
// top-left -> bottom-right userSpaceOnUse axis the baked-in SVG used,
|
||||
// clipped to the bird silhouette via uSilhouette. The white highlight
|
||||
// path above is theme independent. Sized to the logo itself — no
|
||||
// rounded badge, matching home_widget's QPainter composite. Static.
|
||||
// Small ShaderEffect, Qt 6.4-safe.
|
||||
ShaderEffect {
|
||||
id: brandPlate
|
||||
anchors.fill: parent
|
||||
property vector4d uStrong: Qt.vector4d(bannerConfig.brandStrong.r, bannerConfig.brandStrong.g,
|
||||
bannerConfig.brandStrong.b, 1.0)
|
||||
property vector4d uSoft: Qt.vector4d(bannerConfig.brandSoft.r, bannerConfig.brandSoft.g,
|
||||
bannerConfig.brandSoft.b, 1.0)
|
||||
property var uSilhouette: silhouetteMask
|
||||
fragmentShader: "qrc:/onboarding/shaders/brand_plate.frag.qsb"
|
||||
}
|
||||
|
||||
Image {
|
||||
id: logoImage
|
||||
anchors.fill: parent
|
||||
source: "qrc:/resources/cockatrice-logo-white.svg"
|
||||
sourceSize: Qt.size(256, 256)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
#include "shader_banner_widget.h"
|
||||
|
||||
#include "../../theme_manager.h"
|
||||
#include "banner_shader_config.h"
|
||||
#include "brand_colors.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QPainter>
|
||||
#include <QQmlContext>
|
||||
#include <QQmlEngine>
|
||||
|
|
@ -11,11 +14,98 @@
|
|||
|
||||
namespace
|
||||
{
|
||||
// Near-black base palette -- the background is dark and quiet so the green
|
||||
// accent stands out.
|
||||
constexpr QRgb kColorA = 0x1A1A20;
|
||||
constexpr QRgb kColorB = 0x0E0E12;
|
||||
constexpr QRgb kAccent = 0x8BDD6B;
|
||||
// Curated near-black stage -- used only when the active palette resolves no
|
||||
// usable window colour. Matches the banner's original design (dark and quiet
|
||||
// so the accent stands out) and satisfies design-plans §2.1's "identity
|
||||
// survives a bare palette".
|
||||
constexpr QRgb kFallbackColorA = 0x1A1A20;
|
||||
constexpr QRgb kFallbackColorB = 0x0E0E12;
|
||||
|
||||
struct SuggestedColors
|
||||
{
|
||||
QColor colorA;
|
||||
QColor colorB;
|
||||
QColor accent;
|
||||
QColor glowColor;
|
||||
QColor brandStrong;
|
||||
QColor brandSoft;
|
||||
qreal vignetteMin = 0.62;
|
||||
};
|
||||
|
||||
SuggestedColors suggestedBannerColors()
|
||||
{
|
||||
const QPalette &pal = qApp->palette();
|
||||
const QColor window = pal.color(QPalette::Active, QPalette::Window);
|
||||
// Identity accent: the theme's [AppColors] AccentStrong, which appColor()
|
||||
// resolves to QPalette::Highlight when a theme doesn't pin AccentStrong.
|
||||
// Reading bare Highlight ignored curated accent tokens (Plasma's violet
|
||||
// vs Default's green) whenever a palette didn't set the role itself.
|
||||
const QColor accentStrong = themeManager->appColor(AppColor::AccentStrong);
|
||||
if (!window.isValid() || !accentStrong.isValid()) {
|
||||
return {QColor(kFallbackColorA),
|
||||
QColor(kFallbackColorB),
|
||||
kCockatriceBrandGreen,
|
||||
QColor(Qt::white),
|
||||
kCockatriceBrandGreen,
|
||||
QColor(0xC9, 0xFD, 0x62),
|
||||
0.62};
|
||||
}
|
||||
|
||||
// The theme's brand pair: AccentStrong is the deep green, AccentSoft the
|
||||
// lime. These two appColors form the logo's "surrounding gradient" (deep
|
||||
// core grading out to the soft, brand-toned glow) on both the banner and
|
||||
// the home screen.
|
||||
const QColor brandStrong = accentStrong;
|
||||
const QColor brandSoft = themeManager->appColor(AppColor::AccentSoft);
|
||||
|
||||
// Dress the stage for the scheme so the banner never fights the
|
||||
// surrounding window in either mode. Dark palettes keep the original
|
||||
// quiet near-black stage (lightness 29 → 16) with the theme's window
|
||||
// hue; light palettes get a pastel "frosted accent" treatment built from
|
||||
// the accent hue instead of a plain near-white copy: a coloured wash that
|
||||
// clearly belongs to the theme.
|
||||
const qreal luma = 0.299 * window.red() + 0.587 * window.green() + 0.114 * window.blue();
|
||||
const bool lightStage = luma > 115.0;
|
||||
if (lightStage) {
|
||||
const int hue = accentStrong.hslHue();
|
||||
// Achromatic accents (grey) get a neutral near-white stage instead.
|
||||
const int stageSat = hue < 0 ? 0 : 64;
|
||||
const int hueSafe = hue < 0 ? 0 : hue;
|
||||
// Depth is what stops a light stage reading as a washed-out near-white
|
||||
// copy of the page behind the banner: deepen the lower pastel band and
|
||||
// raise saturation so the hue is clearly present while staying frosted.
|
||||
auto pastel = [hueSafe, stageSat](int lightness) { return QColor::fromHsl(hueSafe, stageSat, lightness); };
|
||||
auto pastelLower = [hueSafe](int lightness) { return QColor::fromHsl(hueSafe, 76, lightness); };
|
||||
// Brightness-lifted accent for additive glows: the raw accent on a
|
||||
// light stage must be mid-bright to read instead of washing out, so
|
||||
// lift lightness and saturation together.
|
||||
const int accentLightness = qBound(158, accentStrong.lightness() + 82, 198);
|
||||
const int accentSaturation = hue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 180);
|
||||
const QColor liftedAccent =
|
||||
hue < 0 ? accentStrong : QColor::fromHsl(hueSafe, accentSaturation, accentLightness);
|
||||
// The centre glow (and logo tint in QML) uses the deep accent itself:
|
||||
// a coloured halo/fill behind the logo instead of a white or black one.
|
||||
return {pastel(214), pastelLower(186), liftedAccent, accentStrong, brandStrong, brandSoft, 0.80};
|
||||
}
|
||||
|
||||
// Dark stage: force the window hue down to the banner's curated darkness,
|
||||
// scaling saturation away so chromatic palettes tint it without going
|
||||
// muddy. The accent is the bright, brand-driven tone (hue from the accent
|
||||
// itself, never the -- often grey -- window), and it drives both the
|
||||
// embers/fog and the logo glow so the mark tints like the light stage.
|
||||
auto stage = [&window](int lightness) {
|
||||
const int hue = window.hslHue();
|
||||
const int saturation = hue < 0 ? 0 : qBound(0, qRound(window.hslSaturation() * (lightness / 40.0)), 255);
|
||||
return QColor::fromHsl(hue, saturation, lightness);
|
||||
};
|
||||
const int accentHue = accentStrong.hslHue();
|
||||
const int accentHueSafe = accentHue < 0 ? 0 : accentHue;
|
||||
const int accentLightness = qBound(150, accentStrong.lightness() + 70, 185);
|
||||
const int accentSaturation = accentHue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 160);
|
||||
const QColor accent =
|
||||
accentHue < 0 ? accentStrong : QColor::fromHsl(accentHueSafe, accentSaturation, accentLightness);
|
||||
return {stage(29), stage(16), accent, accent, brandStrong, brandSoft, 0.62};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class GradientFallbackWidget : public QWidget
|
||||
|
|
@ -23,15 +113,27 @@ class GradientFallbackWidget : public QWidget
|
|||
public:
|
||||
using QWidget::QWidget;
|
||||
|
||||
void setColors(const QColor &a, const QColor &b)
|
||||
{
|
||||
if (a != colorA || b != colorB) {
|
||||
colorA = a;
|
||||
colorB = b;
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override
|
||||
{
|
||||
QPainter painter(this);
|
||||
QLinearGradient gradient(0, 0, width(), height());
|
||||
gradient.setColorAt(0.0, QColor(kColorA));
|
||||
gradient.setColorAt(1.0, QColor(kColorB));
|
||||
gradient.setColorAt(0.0, colorA);
|
||||
gradient.setColorAt(1.0, colorB);
|
||||
painter.fillRect(rect(), gradient);
|
||||
}
|
||||
|
||||
private:
|
||||
QColor colorA{QColor(kFallbackColorA)};
|
||||
QColor colorB{QColor(kFallbackColorB)};
|
||||
};
|
||||
|
||||
BannerHost::BannerHost(QWidget *parent) : QWidget(parent)
|
||||
|
|
@ -62,6 +164,9 @@ BannerHost::BannerHost(QWidget *parent) : QWidget(parent)
|
|||
connect(&clock, &QTimer::timeout, this, &BannerHost::tick);
|
||||
clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock
|
||||
|
||||
connect(themeManager, &ThemeManager::themeChanged, this, &BannerHost::applyThemeColors);
|
||||
applyThemeColors();
|
||||
|
||||
applyMotifPreset(currentMotif);
|
||||
updateAspect();
|
||||
}
|
||||
|
|
@ -123,9 +228,9 @@ void BannerHost::applyMotifPreset(Motif motif)
|
|||
|
||||
const Preset p = presetFor(motif);
|
||||
|
||||
config->setColorA(QColor(kColorA));
|
||||
config->setColorB(QColor(kColorB));
|
||||
config->setAccent(QColor(kAccent));
|
||||
config->setColorA(bannerColorA);
|
||||
config->setColorB(bannerColorB);
|
||||
config->setAccent(bannerAccent);
|
||||
config->setLogoVisible(motif == Motif::Welcome);
|
||||
|
||||
if (isFirstApply) {
|
||||
|
|
@ -183,8 +288,34 @@ void BannerHost::hideEvent(QHideEvent *event)
|
|||
clock.stop();
|
||||
}
|
||||
|
||||
void BannerHost::applyThemeColors()
|
||||
{
|
||||
const SuggestedColors colors = suggestedBannerColors();
|
||||
bannerColorA = colors.colorA;
|
||||
bannerColorB = colors.colorB;
|
||||
bannerAccent = colors.accent;
|
||||
|
||||
if (usingFallback) {
|
||||
fallback->setColors(bannerColorA, bannerColorB);
|
||||
fallback->update();
|
||||
} else if (config) {
|
||||
config->setColorA(bannerColorA);
|
||||
config->setColorB(bannerColorB);
|
||||
config->setAccent(bannerAccent);
|
||||
config->setGlowColor(colors.glowColor);
|
||||
config->setBrandStrong(colors.brandStrong);
|
||||
config->setBrandSoft(colors.brandSoft);
|
||||
config->setVignetteMin(colors.vignetteMin);
|
||||
}
|
||||
}
|
||||
|
||||
void BannerHost::tick()
|
||||
{
|
||||
// Palette previews (e.g. accent drags in the wizard's QuickSetupPanel)
|
||||
// apply qApp->palette() without firing themeChanged, so re-derive here;
|
||||
// BannerShaderConfig's setters are equality-guarded, so this is a no-op
|
||||
// unless the colours actually changed.
|
||||
applyThemeColors();
|
||||
if (config) {
|
||||
qreal t = elapsed.elapsed() / 1000.0;
|
||||
config->setTime(t);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#ifndef SHADER_BANNER_WIDGET_H
|
||||
#define SHADER_BANNER_WIDGET_H
|
||||
|
||||
#include <QColor>
|
||||
#include <QElapsedTimer>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
|
@ -53,6 +54,7 @@ protected:
|
|||
private slots:
|
||||
void tick();
|
||||
void onSceneGraphFailed();
|
||||
void applyThemeColors();
|
||||
|
||||
private:
|
||||
struct Preset
|
||||
|
|
@ -73,6 +75,12 @@ private:
|
|||
BannerShaderConfig *config = nullptr;
|
||||
GradientFallbackWidget *fallback = nullptr;
|
||||
|
||||
// Palette-derived banner colours -- the theme's window hue forced down to
|
||||
// the banner's curated darkness, plus the theme's Highlight as accent.
|
||||
QColor bannerColorA;
|
||||
QColor bannerColorB;
|
||||
QColor bannerAccent;
|
||||
|
||||
QTimer clock;
|
||||
QElapsedTimer elapsed;
|
||||
Motif currentMotif = Motif::Welcome;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ layout(std140, binding = 0) uniform buf
|
|||
vec4 uColorA;
|
||||
vec4 uColorB;
|
||||
vec4 uAccent;
|
||||
vec4 uGlowColor;
|
||||
float uVignetteMin;
|
||||
float uLogoGlow;
|
||||
};
|
||||
|
||||
|
|
@ -119,7 +121,7 @@ vec3 backgroundField(vec2 uv, float time)
|
|||
|
||||
// Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent
|
||||
float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02);
|
||||
col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.10;
|
||||
col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.14;
|
||||
|
||||
return col;
|
||||
}
|
||||
|
|
@ -136,14 +138,17 @@ vec3 motifWelcome(vec2 uv, vec3 bg, float t)
|
|||
vec2 center = vec2(asp * 0.5, 0.5);
|
||||
float cDist = length(ac - center);
|
||||
|
||||
// Centre bloom at logo position; intensity scales with uLogoGlow
|
||||
// Centre bloom at logo position; intensity scales with uLogoGlow. The
|
||||
// QML brandGlow halo now supplies the primary logo surround (the two
|
||||
// brand appColors), so this shader bloom is deliberately kept as a subtle
|
||||
// ambience rather than a competing glow.
|
||||
float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp);
|
||||
col += centreLight * 0.20 * uLogoGlow;
|
||||
col += uGlowColor.rgb * centreLight * 0.20 * uLogoGlow;
|
||||
|
||||
// Flow-noise shimmer gated by Gaussian mask at centre
|
||||
float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5;
|
||||
float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp));
|
||||
col += shimmer * shimmerMask * 0.04 * uLogoGlow;
|
||||
col += uGlowColor.rgb * shimmer * shimmerMask * 0.04 * uLogoGlow;
|
||||
|
||||
// 48 ember particles: hash-seeded position, speed, size, brightness.
|
||||
// Embers within a distance threshold of centre are deflected into an
|
||||
|
|
@ -162,8 +167,8 @@ vec3 motifWelcome(vec2 uv, vec3 bg, float t)
|
|||
float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp;
|
||||
float pY = fract(baseY + t * riseSpeed);
|
||||
|
||||
float size = 0.006 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.012;
|
||||
float bright = 0.15 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.30;
|
||||
float size = 0.010 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.014;
|
||||
float bright = 0.18 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.32;
|
||||
|
||||
// Fade out near top/bottom edges
|
||||
float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY);
|
||||
|
|
@ -232,7 +237,7 @@ vec3 motifCardDatabase(vec2 uv, vec3 bg, float t)
|
|||
|
||||
// Semi-transparent dark fill
|
||||
float fill = smoothstep(0.015, -0.005, d);
|
||||
col = mix(col, uColorB.rgb * 0.55, fill * 0.50);
|
||||
col = mix(col, uColorB.rgb * 0.60, fill * 0.62);
|
||||
|
||||
// Accent outline
|
||||
float edge = smoothstep(0.035, 0.0, abs(d));
|
||||
|
|
@ -309,7 +314,7 @@ vec3 motifAccount(vec2 uv, vec3 bg, float t)
|
|||
|
||||
// Node glow via bloom; intensity modulated by pulse
|
||||
float dist = length(ac - pos);
|
||||
col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.20, 0.45, pulse);
|
||||
col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.30, 0.55, pulse);
|
||||
}
|
||||
|
||||
// Edges: connect nodes within a radius threshold
|
||||
|
|
@ -323,19 +328,19 @@ vec3 motifAccount(vec2 uv, vec3 bg, float t)
|
|||
vec2 ba = nodePos[j] - nodePos[i];
|
||||
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
|
||||
float lineDist = length(pa - ba * h);
|
||||
col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.10;
|
||||
col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.14;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Central bloom at banner centre
|
||||
float cDist = length(ac - center);
|
||||
col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12;
|
||||
col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.20;
|
||||
|
||||
// Periodic expanding ring from centre
|
||||
float ripplePhase = t * 0.4;
|
||||
float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7);
|
||||
col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.10;
|
||||
col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.14;
|
||||
|
||||
return col;
|
||||
}
|
||||
|
|
@ -456,6 +461,8 @@ void main()
|
|||
else if (uMode < 4.5) col = motifPreferences(uv, bg, t);
|
||||
else col = motifFinish(uv, bg, t);
|
||||
|
||||
col *= mix(0.62, 1.0, vignette(uv));
|
||||
// Corner vignette; uVignetteMin is scheme-driven (0.62 on dark stages,
|
||||
// gentler on light ones so near-white corners don't go muddy grey).
|
||||
col *= mix(uVignetteMin, 1.0, vignette(uv));
|
||||
fragColor = vec4(col, 1.0) * qt_Opacity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
#version 440
|
||||
|
||||
// The logo's gradient plate, drawn by us rather than the baked-in SVG: a
|
||||
// linear blend between the two brand appColors (light AccentSoft at the
|
||||
// top-left grading to dark AccentStrong at the bottom-right, mirroring
|
||||
// cockatrice.svg's linearGradient4265-7-8 userSpaceOnUse axis), clipped to
|
||||
// the bird's full silhouette via the full-color logo's alpha (uSilhouette).
|
||||
// The white highlight path (cockatrice-logo-white) is overlaid in QML on top,
|
||||
// exactly as the SVG stacks its white path over the gradient paths. Fully
|
||||
// static: no glow, no breathing — the plate just sits there like the home
|
||||
// widget's QPainter composite.
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf
|
||||
{
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
vec4 uStrong;
|
||||
vec4 uSoft;
|
||||
};
|
||||
|
||||
// The full-color logo's alpha channel acts as the silhouette mask: the
|
||||
// gradient only appears inside the bird, exactly like the SVG's gradient paths.
|
||||
layout(binding = 1) uniform sampler2D uSilhouette;
|
||||
|
||||
void main()
|
||||
{
|
||||
// Recreate cockatrice.svg's own gradient geometry (linearGradient4265-7-8,
|
||||
// userSpaceOnUse): light AccentSoft at the start point S=(-8.097,-97.746),
|
||||
// dark AccentStrong at the end E=(162.455,295.208), on the SVG's 300x300
|
||||
// canvas. Normalized to UV space, V=E-S=(0.5685,1.3098), so
|
||||
// t = dot(uv - S_norm, V)/|V|^2 with S_norm=(-0.0270,-0.3258).
|
||||
float t = clamp(dot(qt_TexCoord0 - vec2(-0.02699, -0.32582), vec2(0.56851, 1.30985)) / 2.03891, 0.0, 1.0);
|
||||
vec3 color = mix(uSoft.rgb, uStrong.rgb, t);
|
||||
|
||||
// Anti-aliased silhouette clip from the full-color logo's alpha.
|
||||
float alpha = texture(uSilhouette, qt_TexCoord0).a;
|
||||
|
||||
fragColor = vec4(color * alpha, alpha) * qt_Opacity;
|
||||
}
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
#include "printing_selector_card_overlay_widget.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../card_picture_loader/card_picture_loader.h"
|
||||
#include "../cards/card_info_picture_widget.h"
|
||||
#include "printing_selector_card_display_widget.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QFileDialog>
|
||||
#include <QImageReader>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QMouseEvent>
|
||||
#include <QPixmapCache>
|
||||
#include <QScreen>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtMath>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
|
|
@ -15,6 +21,49 @@
|
|||
#include <libcockatrice/settings/card_override_settings.h>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief Places the preview beside the highlighted action inside the given screen.
|
||||
*
|
||||
* Side-aware: hugs the side of the action that has room, aligned with its row, then
|
||||
* clamps every edge so the preview always lands fully on-screen on first show.
|
||||
*/
|
||||
QPoint
|
||||
previewPositionNear(const QRect &actionRect, const QSize &labelSize, const QRect &screenGeometry, int previewOffset)
|
||||
{
|
||||
const bool rightFits = actionRect.right() + previewOffset + labelSize.width() <= screenGeometry.right();
|
||||
const bool leftFits = actionRect.left() - previewOffset - labelSize.width() >= screenGeometry.left();
|
||||
|
||||
int x;
|
||||
if (rightFits) {
|
||||
x = actionRect.right() + previewOffset;
|
||||
} else if (leftFits) {
|
||||
x = actionRect.left() - previewOffset - labelSize.width();
|
||||
} else {
|
||||
x = actionRect.left();
|
||||
}
|
||||
x = qMax(screenGeometry.left(), x);
|
||||
x = qMin(screenGeometry.right() - labelSize.width() + 1, x);
|
||||
|
||||
const bool belowFits = actionRect.bottom() + previewOffset + labelSize.height() <= screenGeometry.bottom();
|
||||
const bool aboveFits = actionRect.top() - previewOffset - labelSize.height() >= screenGeometry.top();
|
||||
|
||||
int y;
|
||||
if (belowFits) {
|
||||
y = actionRect.bottom() + previewOffset;
|
||||
} else if (aboveFits) {
|
||||
y = actionRect.top() - previewOffset - labelSize.height();
|
||||
} else {
|
||||
y = actionRect.top();
|
||||
}
|
||||
y = qMax(screenGeometry.top(), y);
|
||||
y = qMin(screenGeometry.bottom() - labelSize.height() + 1, y);
|
||||
|
||||
return {x, y};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Constructs a PrintingSelectorCardOverlayWidget for displaying a card overlay.
|
||||
*
|
||||
|
|
@ -50,6 +99,31 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa
|
|||
|
||||
initializePinBadge();
|
||||
|
||||
// Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps
|
||||
// it a frameless, non-activating top-level window despite the parent).
|
||||
cardOverridePreviewLabel = new QLabel(this, Qt::ToolTip);
|
||||
cardOverridePreviewLabel->setWindowFlag(Qt::FramelessWindowHint);
|
||||
cardOverridePreviewLabel->setAttribute(Qt::WA_ShowWithoutActivating);
|
||||
cardOverridePreviewLabel->setScaledContents(true);
|
||||
cardOverridePreviewLabel->hide();
|
||||
|
||||
// While the preview is visible, keep it honest: when the hovered printing's art
|
||||
// resolves (all alternate printings share the root card's CardInfo), redraw it in place.
|
||||
if (rootCard.getCardPtr()) {
|
||||
connect(rootCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this] {
|
||||
if (cardOverridePreviewLabel->isVisible()) {
|
||||
refreshPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Alt-Tab / app-inactive must not strand the floating preview.
|
||||
connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) {
|
||||
if (state != Qt::ApplicationActive) {
|
||||
hidePreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Update when this overlay emits cardPreferenceChanged or when size/scale changes
|
||||
connect(this, &PrintingSelectorCardOverlayWidget::cardPreferenceChanged, this,
|
||||
&PrintingSelectorCardOverlayWidget::updatePinBadgeVisibility);
|
||||
|
|
@ -185,19 +259,23 @@ void PrintingSelectorCardOverlayWidget::leaveEvent(QEvent *event)
|
|||
}
|
||||
|
||||
/**
|
||||
* @brief Creates and shows a custom context menu when the right mouse button is clicked.
|
||||
* @brief Creates and shows the card-overlay context menu.
|
||||
*
|
||||
* The context menu includes an option to show related cards, which displays a submenu with actions
|
||||
* for each related card. When an action is triggered, the card information is updated, and the
|
||||
* printing selector is shown.
|
||||
* The menu includes the card art preference (Pin/Unpin Printing), the Image Overrides
|
||||
* submenu (Load Custom Image, Clear Custom Image, and one entry per alternate printing with a
|
||||
* live preview), and the Show Related cards submenu.
|
||||
*
|
||||
* @param point The position of the mouse when the right-click occurred.
|
||||
* @param point The local position the menu should pop at.
|
||||
*/
|
||||
void PrintingSelectorCardOverlayWidget::customMenu(QPoint point)
|
||||
{
|
||||
QMenu menu;
|
||||
|
||||
auto *preferenceMenu = new QMenu(tr("Preference"));
|
||||
hidePreview(); // Clear any preview state left over from a previous menu run.
|
||||
|
||||
// Submenus are owned by the stack-allocated top-level menu (addMenu() does not
|
||||
// transfer ownership).
|
||||
auto *preferenceMenu = new QMenu(tr("Preference"), &menu);
|
||||
menu.addMenu(preferenceMenu);
|
||||
|
||||
const auto &preferredProviderId =
|
||||
|
|
@ -219,8 +297,66 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point)
|
|||
});
|
||||
}
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
auto *overrideMenu = new QMenu(tr("Image Overrides"), &menu);
|
||||
|
||||
auto *loadCustomAction = overrideMenu->addAction(tr("Load Custom Image..."));
|
||||
auto *clearOverrideAction = overrideMenu->addAction(tr("Clear Custom Image"));
|
||||
|
||||
// Nothing to clear on a card that has no local override yet.
|
||||
clearOverrideAction->setEnabled(CardPictureLoader::hasLocalOverrides(rootCard));
|
||||
|
||||
overrideMenu->addSeparator();
|
||||
|
||||
const auto &allSets = rootCard.getInfo().getSets();
|
||||
|
||||
for (const auto &set : allSets) {
|
||||
for (const auto &printing : set) {
|
||||
if (printing == rootCard.getPrinting()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The submenu is already scoped to this card, so the rows lead with set +
|
||||
// collector; only printings with a distinct display name add their own name.
|
||||
const CardSetPtr cardSet = printing.getSet();
|
||||
if (!cardSet) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QString label = tr("%1 %2").arg(cardSet->getCorrectedShortName(), printing.getProperty("num"));
|
||||
|
||||
auto *action = overrideMenu->addAction(label);
|
||||
|
||||
ExactCard overrideCard(rootCard.getCardPtr(), printing);
|
||||
action->setData(QVariant::fromValue(overrideCard));
|
||||
|
||||
connect(action, &QAction::triggered, this, [this, overrideCard]() {
|
||||
CardPictureLoader::getInstance().installPrintingOverride(rootCard, overrideCard);
|
||||
QPixmapCache::clear();
|
||||
rootCard.emitPixmapUpdated(); // refresh the overlay art in place, like the other paths
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
connect(clearOverrideAction, &QAction::triggered, this, [this]() {
|
||||
CardPictureLoader::deleteAllLocalOverrides(rootCard);
|
||||
QPixmapCache::clear();
|
||||
rootCard.emitPixmapUpdated(); // force UI refresh
|
||||
});
|
||||
|
||||
connect(loadCustomAction, &QAction::triggered, this, &PrintingSelectorCardOverlayWidget::loadCustomImage);
|
||||
|
||||
connect(overrideMenu, &QMenu::hovered, this, &PrintingSelectorCardOverlayWidget::showPreviewForAction);
|
||||
connect(overrideMenu, &QMenu::aboutToHide, this, &PrintingSelectorCardOverlayWidget::hidePreview);
|
||||
connect(overrideMenu, &QMenu::triggered, this, &PrintingSelectorCardOverlayWidget::hidePreview);
|
||||
|
||||
menu.addMenu(overrideMenu);
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// filling out the related cards submenu
|
||||
auto *relatedMenu = new QMenu(tr("Show Related cards"));
|
||||
auto *relatedMenu = new QMenu(tr("Show Related cards"), &menu);
|
||||
menu.addMenu(relatedMenu);
|
||||
auto relatedCards = rootCard.getInfo().getAllRelatedCards();
|
||||
if (relatedCards.isEmpty()) {
|
||||
|
|
@ -235,7 +371,11 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point)
|
|||
});
|
||||
}
|
||||
}
|
||||
// The preview anchors itself to this popup's global geometry while it is open, so the
|
||||
// pointer must stay valid for the whole exec() and be dropped before the stack unwinds.
|
||||
previewSourceMenu = overrideMenu;
|
||||
menu.exec(this->mapToGlobal(point));
|
||||
previewSourceMenu = nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -291,3 +431,136 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge()
|
|||
pinBadge->setVisible(false);
|
||||
pinBadge->raise();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Asks for an image file and installs it as the card's custom art.
|
||||
*
|
||||
* Unreadable files answer with a visible warning instead of a silent no-op.
|
||||
*/
|
||||
void PrintingSelectorCardOverlayWidget::loadCustomImage()
|
||||
{
|
||||
QString filePath = QFileDialog::getOpenFileName(this, tr("Select Card Image"), QString(),
|
||||
tr("Images (*.png *.jpg *.jpeg *.webp)"));
|
||||
|
||||
if (filePath.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QPixmap pixmap(filePath);
|
||||
if (pixmap.isNull()) {
|
||||
// No silent paths: a file that cannot be read answers visibly instead of a no-op.
|
||||
QMessageBox::warning(this, tr("Load Custom Image"), tr("The selected file could not be read as an image."));
|
||||
return;
|
||||
}
|
||||
|
||||
CardPictureLoader::getInstance().saveCardImageToLocalStorage(rootCard, pixmap, true);
|
||||
|
||||
QPixmapCache::clear();
|
||||
rootCard.emitPixmapUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Shows the hover preview for a highlighted printing entry in the Image Overrides submenu.
|
||||
*
|
||||
* QMenu::hovered fires on keyboard highlight too, so the preview appears when arrows walk
|
||||
* onto a printing entry, not only under the mouse.
|
||||
*
|
||||
* Non-printing entries (e.g., Load Custom Image, Clear Custom Image) hide the preview.
|
||||
*
|
||||
* @param action The action that was highlighted.
|
||||
*/
|
||||
void PrintingSelectorCardOverlayWidget::showPreviewForAction(QAction *action)
|
||||
{
|
||||
if (!action) {
|
||||
hidePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const QVariant data = action->data();
|
||||
|
||||
if (!data.canConvert<ExactCard>()) {
|
||||
hidePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const ExactCard previewCard = qvariant_cast<ExactCard>(data);
|
||||
if (previewCard.isEmpty()) {
|
||||
hidePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
hoveredOverrideCard = previewCard;
|
||||
hoveredOverrideAction = action;
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Renders the hover preview for the currently highlighted printing.
|
||||
*
|
||||
* The preview shows the loading placeholder while the art is pending and swaps in the real
|
||||
* art when it resolves. The label is positioned against its already-resized geometry so the
|
||||
* first-ever show at the screen's edges stays fully on-screen.
|
||||
*/
|
||||
void PrintingSelectorCardOverlayWidget::refreshPreview()
|
||||
{
|
||||
if (hoveredOverrideCard.isEmpty()) {
|
||||
hidePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
constexpr QSize previewSize(240, 336);
|
||||
constexpr int previewOffset = 20;
|
||||
|
||||
QPixmap pixmap;
|
||||
CardPictureLoader::getPixmap(pixmap, hoveredOverrideCard, previewSize);
|
||||
|
||||
if (pixmap.isNull()) {
|
||||
// Keep the preview honest while loading: show the loading placeholder instead of a void.
|
||||
// Fetch at the logical size and let the label scale it, so the placeholder matches the
|
||||
// real art's footprint rather than doubling on HiDPI displays.
|
||||
CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, previewSize);
|
||||
}
|
||||
|
||||
cardOverridePreviewLabel->setPixmap(pixmap);
|
||||
// QPixmap::size() is physical pixels; the label layout must use the device-independent size
|
||||
// so the preview keeps a constant footprint across DPI settings (QScreen geometry is logical).
|
||||
const QSize labelSize = pixmap.deviceIndependentSize().toSize();
|
||||
cardOverridePreviewLabel->resize(labelSize);
|
||||
|
||||
// Anchor the preview to the walked submenu popup rather than QCursor::pos(), which is idle
|
||||
// under keyboard-only operation: a keyboard-highlighted row must preview at the same place as
|
||||
// a hovered one. The mouse path is unchanged in effect — the popup sits under the cursor, so
|
||||
// the preview stays beside the row in both modalities.
|
||||
const QMenu *popup = previewSourceMenu;
|
||||
if (!popup || !popup->isVisible() || !hoveredOverrideAction) {
|
||||
hidePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const QRect popupGeometry = popup->geometry();
|
||||
const QRect actionRectLocal = popup->actionGeometry(hoveredOverrideAction);
|
||||
const QRect actionRect(popupGeometry.topLeft() + actionRectLocal.topLeft(), actionRectLocal.size());
|
||||
|
||||
QScreen *screen = QGuiApplication::screenAt(popupGeometry.center());
|
||||
if (!screen) {
|
||||
hidePreview();
|
||||
return;
|
||||
}
|
||||
const QRect &screenGeometry = screen->geometry();
|
||||
|
||||
cardOverridePreviewLabel->move(previewPositionNear(actionRect, labelSize, screenGeometry, previewOffset));
|
||||
cardOverridePreviewLabel->show();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Hides the hover preview and forgets the currently highlighted printing.
|
||||
*/
|
||||
void PrintingSelectorCardOverlayWidget::hidePreview()
|
||||
{
|
||||
hoveredOverrideCard = ExactCard();
|
||||
hoveredOverrideAction = nullptr;
|
||||
|
||||
if (cardOverridePreviewLabel) {
|
||||
cardOverridePreviewLabel->hide();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
class QAction;
|
||||
class QMenu;
|
||||
|
||||
class PrintingSelectorCardOverlayWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
|
@ -43,11 +46,19 @@ private slots:
|
|||
|
||||
private:
|
||||
void initializePinBadge();
|
||||
void loadCustomImage();
|
||||
void showPreviewForAction(QAction *action);
|
||||
void refreshPreview();
|
||||
void hidePreview();
|
||||
CardInfoPictureWidget *cardInfoPicture;
|
||||
AllZonesCardAmountWidget *allZonesCardAmountWidget;
|
||||
QLabel *pinBadge = nullptr;
|
||||
AbstractTabDeckEditor *deckEditor;
|
||||
ExactCard rootCard;
|
||||
QLabel *cardOverridePreviewLabel = nullptr;
|
||||
ExactCard hoveredOverrideCard;
|
||||
QMenu *previewSourceMenu = nullptr;
|
||||
QAction *hoveredOverrideAction = nullptr;
|
||||
};
|
||||
|
||||
#endif // PRINTING_SELECTOR_CARD_OVERLAY_WIDGET_H
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator,
|
|||
return;
|
||||
}
|
||||
|
||||
bool overrideRestrictions = !tabSupervisor->getAdminLocked();
|
||||
bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions();
|
||||
|
||||
// Joining a full game without override privileges silently becomes a
|
||||
// spectator join, so ask first instead of surprising the player.
|
||||
|
|
@ -462,7 +462,7 @@ void GameSelector::enableButtonsForIndex(const QModelIndex ¤t)
|
|||
}
|
||||
|
||||
const ServerInfo_Game &game = gameListModel->getGame(current.data(Qt::UserRole).toInt());
|
||||
bool overrideRestrictions = !tabSupervisor->getAdminLocked();
|
||||
bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions();
|
||||
|
||||
spectateButton->setEnabled(game.spectators_allowed() || overrideRestrictions);
|
||||
joinButton->setEnabled(game.player_count() < game.max_players() || overrideRestrictions);
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ int RemoteDeckList_TreeModel::rowCount(const QModelIndex &parent) const
|
|||
|
||||
int RemoteDeckList_TreeModel::columnCount(const QModelIndex & /*parent*/) const
|
||||
{
|
||||
return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) const
|
||||
|
|
@ -121,7 +121,7 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
|
|||
if (!index.isValid()) {
|
||||
return QVariant();
|
||||
}
|
||||
if (index.column() >= 3) {
|
||||
if (index.column() >= 4) {
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
|
|
@ -134,12 +134,29 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
|
|||
switch (index.column()) {
|
||||
case 0:
|
||||
return node->getName();
|
||||
case 3:
|
||||
// Report the node's own bit, not the inherited effective
|
||||
// state, so it stays in step with what publishing toggles.
|
||||
if (node->isPublic()) {
|
||||
return tr("Public");
|
||||
}
|
||||
return isEffectivelyPublic(node) ? tr("Public (inherited)") : tr("Private");
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
case Qt::DecorationRole:
|
||||
return index.column() == 0 ? dirIcon : QVariant();
|
||||
case Qt::ToolTipRole:
|
||||
if (index.column() == 3) {
|
||||
if (node->isPublic()) {
|
||||
return tr("This folder is visible to other users");
|
||||
}
|
||||
return isEffectivelyPublic(node)
|
||||
? tr("This folder is private, but a parent folder is public (inherited).")
|
||||
: tr("This folder is only visible to you");
|
||||
}
|
||||
return QVariant();
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
|
|
@ -153,6 +170,13 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
|
|||
return file->getId();
|
||||
case 2:
|
||||
return file->getUploadTime();
|
||||
case 3:
|
||||
// Report the node's own bit, not the inherited effective
|
||||
// state, so it stays in step with what publishing toggles.
|
||||
if (file->isPublic()) {
|
||||
return tr("Public");
|
||||
}
|
||||
return isEffectivelyPublic(file) ? tr("Public (inherited)") : tr("Private");
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
|
|
@ -161,6 +185,16 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
|
|||
return index.column() == 0 ? fileIcon : QVariant();
|
||||
case Qt::TextAlignmentRole:
|
||||
return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft;
|
||||
case Qt::ToolTipRole:
|
||||
if (index.column() == 3) {
|
||||
if (file->isPublic()) {
|
||||
return tr("This deck is visible to other users");
|
||||
}
|
||||
return isEffectivelyPublic(file)
|
||||
? tr("This deck is private, but a parent folder is public (inherited).")
|
||||
: tr("This deck is only visible to you");
|
||||
}
|
||||
return QVariant();
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
|
|
@ -183,6 +217,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien
|
|||
return tr("ID");
|
||||
case 2:
|
||||
return tr("Upload time");
|
||||
case 3:
|
||||
return tr("Visibility");
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
|
|
@ -239,13 +275,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt
|
|||
time.setSecsSinceEpoch(fileInfo.creation_time());
|
||||
|
||||
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
|
||||
parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent));
|
||||
parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent, fileInfo.is_public()));
|
||||
endInsertRows();
|
||||
}
|
||||
|
||||
void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent)
|
||||
{
|
||||
DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent);
|
||||
newItem->setIsPublic(folder.folder().is_public());
|
||||
const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder();
|
||||
const int folderItemsSize = folderInfo.items_size();
|
||||
for (int i = 0; i < folderItemsSize; ++i) {
|
||||
|
|
@ -285,6 +322,21 @@ void RemoteDeckList_TreeModel::refreshTree()
|
|||
client->sendCommand(pend);
|
||||
}
|
||||
|
||||
bool RemoteDeckList_TreeModel::isEffectivelyPublic(const Node *node) const
|
||||
{
|
||||
if (node == nullptr || node == root) {
|
||||
return false;
|
||||
}
|
||||
const Node *current = node;
|
||||
while (current != nullptr) {
|
||||
if (current->isPublic()) {
|
||||
return true;
|
||||
}
|
||||
current = current->getParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void RemoteDeckList_TreeModel::clearTree()
|
||||
{
|
||||
beginResetModel();
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ public:
|
|||
protected:
|
||||
DirectoryNode *parent;
|
||||
QString name;
|
||||
bool publicFlag;
|
||||
|
||||
public:
|
||||
explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) : parent(_parent), name(_name)
|
||||
explicit Node(const QString &_name, DirectoryNode *_parent = nullptr)
|
||||
: parent(_parent), name(_name), publicFlag(false)
|
||||
{
|
||||
}
|
||||
virtual ~Node() = default;
|
||||
|
|
@ -41,6 +43,14 @@ public:
|
|||
{
|
||||
return name;
|
||||
}
|
||||
[[nodiscard]] bool isPublic() const
|
||||
{
|
||||
return publicFlag;
|
||||
}
|
||||
void setIsPublic(bool _public)
|
||||
{
|
||||
publicFlag = _public;
|
||||
}
|
||||
};
|
||||
class DirectoryNode : public Node, public QList<Node *>
|
||||
{
|
||||
|
|
@ -59,9 +69,14 @@ public:
|
|||
QDateTime uploadTime;
|
||||
|
||||
public:
|
||||
FileNode(const QString &_name, int _id, const QDateTime &_uploadTime, DirectoryNode *_parent = nullptr)
|
||||
FileNode(const QString &_name,
|
||||
int _id,
|
||||
const QDateTime &_uploadTime,
|
||||
DirectoryNode *_parent = nullptr,
|
||||
bool _isPublic = false)
|
||||
: Node(_name, _parent), id(_id), uploadTime(_uploadTime)
|
||||
{
|
||||
setIsPublic(_isPublic);
|
||||
}
|
||||
[[nodiscard]] int getId() const
|
||||
{
|
||||
|
|
@ -109,6 +124,11 @@ public:
|
|||
{
|
||||
return root;
|
||||
}
|
||||
/**
|
||||
* @brief Whether a node is visible to other users (own flag or inherited
|
||||
* from any ancestor folder).
|
||||
*/
|
||||
[[nodiscard]] bool isEffectivelyPublic(const Node *node) const;
|
||||
void addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent);
|
||||
void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent);
|
||||
DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
|
|||
aDetails = new QAction(QString(), this);
|
||||
aChat = new QAction(QString(), this);
|
||||
aShowGames = new QAction(QString(), this);
|
||||
aViewPublicDecks = new QAction(QString(), this);
|
||||
aAddToBuddyList = new QAction(QString(), this);
|
||||
aRemoveFromBuddyList = new QAction(QString(), this);
|
||||
aAddToIgnoreList = new QAction(QString(), this);
|
||||
|
|
@ -64,6 +65,7 @@ void UserContextMenu::retranslateUi()
|
|||
aDetails->setText(tr("User &details"));
|
||||
aChat->setText(tr("Private &chat"));
|
||||
aShowGames->setText(tr("Show this user's &games"));
|
||||
aViewPublicDecks->setText(tr("View this user's &public decks"));
|
||||
aAddToBuddyList->setText(tr("Add to &buddy list"));
|
||||
aRemoveFromBuddyList->setText(tr("Remove from &buddy list"));
|
||||
aAddToIgnoreList->setText(tr("Add to &ignore list"));
|
||||
|
|
@ -376,6 +378,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
|||
}
|
||||
menu->addAction(aDetails);
|
||||
menu->addAction(aShowGames);
|
||||
if (userLevel.testFlag(ServerInfo_User::IsRegistered)) {
|
||||
menu->addAction(aViewPublicDecks);
|
||||
}
|
||||
menu->addAction(aChat);
|
||||
const QList<GameInviteOption> inviteOptions = inviteOptionsForUser(userName);
|
||||
if (!inviteOptions.isEmpty()) {
|
||||
|
|
@ -455,6 +460,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
|||
aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName));
|
||||
aShowGames->setEnabled(online);
|
||||
aReport->setEnabled(anotherUser);
|
||||
aViewPublicDecks->setEnabled(anotherUser);
|
||||
aAddToBuddyList->setEnabled(anotherUser);
|
||||
aRemoveFromBuddyList->setEnabled(anotherUser);
|
||||
aAddToIgnoreList->setEnabled(anotherUser);
|
||||
|
|
@ -481,6 +487,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
|||
execChat(userName);
|
||||
} else if (actionClicked == aShowGames) {
|
||||
execShowGames(userName);
|
||||
} else if (actionClicked == aViewPublicDecks) {
|
||||
execViewPublicDecks(userName);
|
||||
} else if (actionClicked == aAddToBuddyList) {
|
||||
execAddToBuddy(userName);
|
||||
} else if (actionClicked == aRemoveFromBuddyList) {
|
||||
|
|
@ -604,6 +612,11 @@ void UserContextMenu::execShowGames(const QString &userName)
|
|||
client->sendCommand(pend);
|
||||
}
|
||||
|
||||
void UserContextMenu::execViewPublicDecks(const QString &userName)
|
||||
{
|
||||
tabSupervisor->openTabPublicDecks(userName);
|
||||
}
|
||||
|
||||
void UserContextMenu::execAddToBuddy(const QString &userName)
|
||||
{
|
||||
Command_AddToList cmd;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ private:
|
|||
QAction *aUserName;
|
||||
QAction *aDetails;
|
||||
QAction *aShowGames;
|
||||
QAction *aViewPublicDecks;
|
||||
QAction *aChat;
|
||||
QAction *aAddToBuddyList, *aRemoveFromBuddyList;
|
||||
QAction *aAddToIgnoreList, *aRemoveFromIgnoreList;
|
||||
|
|
@ -111,6 +112,7 @@ public:
|
|||
void execInvite(const QString &userName);
|
||||
void execDetails(const QString &userName);
|
||||
void execShowGames(const QString &userName);
|
||||
void execViewPublicDecks(const QString &userName);
|
||||
void execAddToBuddy(const QString &userName);
|
||||
void execRemoveFromBuddy(const QString &userName);
|
||||
void execAddToIgnore(const QString &userName);
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
|||
connect(&schemeCombo, &QComboBox::currentIndexChanged, this,
|
||||
[this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); });
|
||||
|
||||
// Qt widget style; "Default" lets the application decide
|
||||
styleCombo.addItem(tr("Default"), QStringLiteral("Default"));
|
||||
// Qt widget style; "System" lets the application decide
|
||||
styleCombo.addItem(tr("System"), QStringLiteral("System"));
|
||||
for (const QString &key : QStyleFactory::keys()) {
|
||||
styleCombo.addItem(key, key);
|
||||
}
|
||||
|
|
@ -132,6 +132,10 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
|||
connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
|
||||
&AppearanceSettings::setHomeTabDisplayCardName);
|
||||
|
||||
homeTabBackgroundDimCheckBox.setChecked(settings.appearance().getHomeTabBackgroundDim());
|
||||
connect(&homeTabBackgroundDimCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
|
||||
&AppearanceSettings::setHomeTabBackgroundDim);
|
||||
|
||||
for (const auto &entry : HomeTabButtonColor::all()) {
|
||||
homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey));
|
||||
}
|
||||
|
|
@ -150,6 +154,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
|||
homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2);
|
||||
homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0);
|
||||
homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1);
|
||||
homeTabGrid->addWidget(&homeTabBackgroundDimCheckBox, 4, 0, 1, 2);
|
||||
|
||||
homeTabGroupBox = new QGroupBox;
|
||||
homeTabGroupBox->setLayout(homeTabGrid);
|
||||
|
|
@ -508,9 +513,12 @@ void AppearanceSettingsPage::retranslateUi()
|
|||
homeTabBackgroundShuffleFrequencyLabel.setText(tr("Home tab background shuffle frequency:"));
|
||||
homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled"));
|
||||
homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right"));
|
||||
homeTabBackgroundDimCheckBox.setText(tr("Dim the home tab background"));
|
||||
homeTabBackgroundDimCheckBox.setToolTip(
|
||||
tr("Draw a translucent overlay over the home tab background so buttons and text stand out"));
|
||||
homeTabButtonColorSourceLabel.setText(tr("Home tab button color:"));
|
||||
homeTabButtonColorSourceBox.setToolTip(
|
||||
tr("Automatic: extract from background if present, otherwise use theme default"));
|
||||
tr("Use the theme's identity accent colors, or extract colors from the background image"));
|
||||
|
||||
playmatGroupBox->setTitle(tr("Playmat settings"));
|
||||
playmatVisibilityLabel.setText(tr("Playmat visibility:"));
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ private:
|
|||
QLabel homeTabBackgroundShuffleFrequencyLabel;
|
||||
QSpinBox homeTabBackgroundShuffleFrequencySpinBox;
|
||||
QCheckBox homeTabDisplayCardNameCheckBox;
|
||||
QCheckBox homeTabBackgroundDimCheckBox;
|
||||
QLabel homeTabButtonColorSourceLabel;
|
||||
QComboBox homeTabButtonColorSourceBox;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
#include "general_settings_page.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../interface/card_picture_loader/card_picture_loader.h"
|
||||
#include "../main.h"
|
||||
#include "../server/user/user_info_connection.h"
|
||||
#include "update/client/release_channel.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QGridLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QTranslator>
|
||||
#include <libcockatrice/card/card_localization.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <libcockatrice/settings/download_settings.h>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <libcockatrice/settings/tabs_settings.h>
|
||||
|
|
@ -46,10 +52,27 @@ GeneralSettingsPage::GeneralSettingsPage()
|
|||
connect(&languageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||
&GeneralSettingsPage::languageBoxChanged);
|
||||
|
||||
// card text & images language, independent of the UI language
|
||||
cardLanguageBox.addItem(tr("English"), "en");
|
||||
for (const QString &code : CardLocalization::supportedLanguages()) {
|
||||
cardLanguageBox.addItem(CardLocalization::languageDisplayName(code), code);
|
||||
}
|
||||
const int cardLangIndex = cardLanguageBox.findData(SettingsCache::instance().cardsDisplay().getCardLang());
|
||||
cardLanguageBox.setCurrentIndex(cardLangIndex < 0 ? 0 : cardLangIndex);
|
||||
|
||||
connect(&cardLanguageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||
&GeneralSettingsPage::cardLanguageBoxChanged);
|
||||
|
||||
auto *languageGrid = new QGridLayout;
|
||||
languageGrid->addWidget(&languageLabel, 0, 0);
|
||||
languageGrid->addWidget(&languageBox, 0, 1);
|
||||
languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight);
|
||||
languageGrid->addWidget(&cardLanguageLabel, 1, 0);
|
||||
languageGrid->addWidget(&cardLanguageBox, 1, 1);
|
||||
languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1);
|
||||
languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight);
|
||||
|
||||
cardLanguageNoteLabel.setWordWrap(true);
|
||||
cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
|
||||
languageGroupBox = new QGroupBox;
|
||||
languageGroupBox->setLayout(languageGrid);
|
||||
|
|
@ -412,6 +435,52 @@ void GeneralSettingsPage::languageBoxChanged(int index)
|
|||
SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString());
|
||||
}
|
||||
|
||||
void GeneralSettingsPage::cardLanguageBoxChanged(int index)
|
||||
{
|
||||
const QString lang = cardLanguageBox.itemData(index).toString();
|
||||
SettingsCache::instance().cardsDisplay().setCardLang(lang);
|
||||
|
||||
// Switching to a non-default language only takes effect after the card
|
||||
// database is re-imported with that language selected; English data is always
|
||||
// present, so switching back to English needs no prompt.
|
||||
if (lang == "en") {
|
||||
return;
|
||||
}
|
||||
|
||||
// The binary cache does not track the language its entries were imported in,
|
||||
// and the downloaded pictures were fetched with English art names, so both are
|
||||
// stale until Oracle re-imports the database in the new language: drop them.
|
||||
QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache");
|
||||
CardPictureLoader::clearNetworkCache();
|
||||
CardPictureLoader::clearPixmapCache();
|
||||
|
||||
// Art is resolved by the translated card name for non-English languages, so the
|
||||
// matching Scryfall URL is added to the top of the download list. It stays
|
||||
// visible in the deck editor settings, where it can be removed or reordered.
|
||||
const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl();
|
||||
|
||||
QString message = tr("<p>The card database only contains English card data. To see cards in <b>%1</b>, "
|
||||
"<b>Oracle</b> must run once with this language selected and re-import the card "
|
||||
"database.</p>"
|
||||
"<p>The cached database and the downloaded card pictures have been cleared, so a "
|
||||
"re-import is picked up without stale entries.</p>")
|
||||
.arg(cardLanguageBox.itemText(index));
|
||||
if (localizedUrlAdded) {
|
||||
message += tr("<p>The Scryfall URL that resolves card art by translated name was added to the top of your "
|
||||
"download list. You can remove or reorder it any time.</p>");
|
||||
}
|
||||
message += tr("<p>Run Oracle now?</p>");
|
||||
|
||||
const QMessageBox::StandardButton answer = QMessageBox::question(
|
||||
this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
|
||||
|
||||
// The answer only controls whether Oracle starts right away; the caches stay
|
||||
// cleared so the next import or launch rebuilds them in the new language.
|
||||
if (answer == QMessageBox::Yes) {
|
||||
emit cardDatabaseUpdateRequested();
|
||||
}
|
||||
}
|
||||
|
||||
void GeneralSettingsPage::updateStartupServerControlsVisibility()
|
||||
{
|
||||
const int index = startupTabSelector.currentIndex();
|
||||
|
|
@ -429,6 +498,10 @@ void GeneralSettingsPage::retranslateUi()
|
|||
|
||||
languageGroupBox->setTitle(tr("Language settings"));
|
||||
languageLabel.setText(tr("Language:"));
|
||||
cardLanguageBox.setItemText(0, tr("English"));
|
||||
cardLanguageLabel.setText(tr("Card text & images language:"));
|
||||
cardLanguageNoteLabel.setText(
|
||||
tr("Foreign card names, text and art apply after you update the card database (Oracle)."));
|
||||
advertiseTranslationPageLabel.setText(
|
||||
QString("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ public:
|
|||
static QStringList findQmFiles();
|
||||
static QString languageName(const QString &lang);
|
||||
|
||||
signals:
|
||||
/// Request to re-import the card database with the newly selected card language
|
||||
void cardDatabaseUpdateRequested();
|
||||
|
||||
private slots:
|
||||
void deckPathButtonClicked();
|
||||
void filtersPathButtonClicked();
|
||||
|
|
@ -33,6 +37,7 @@ private slots:
|
|||
void tokenDatabasePathButtonClicked();
|
||||
void resetAllPathsClicked();
|
||||
void languageBoxChanged(int index);
|
||||
void cardLanguageBoxChanged(int index);
|
||||
void updateStartupServerControlsVisibility();
|
||||
|
||||
private:
|
||||
|
|
@ -46,6 +51,10 @@ private:
|
|||
QComboBox languageBox;
|
||||
QLabel advertiseTranslationPageLabel;
|
||||
|
||||
QLabel cardLanguageLabel;
|
||||
QComboBox cardLanguageBox;
|
||||
QLabel cardLanguageNoteLabel;
|
||||
|
||||
QLabel updateReleaseChannelLabel;
|
||||
QComboBox updateReleaseChannelBox;
|
||||
QCheckBox startupUpdateCheckCheckBox;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../../client/settings/shortcuts_settings.h"
|
||||
#include "../cards/additional_info/deck_color_identity.h"
|
||||
#include "../client/network/interfaces/deck_stats_interface.h"
|
||||
#include "../client/network/interfaces/tapped_out_interface.h"
|
||||
#include "../deck_editor/deck_state_manager.h"
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
#include "../interface/widgets/dialogs/dlg_load_deck.h"
|
||||
#include "../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h"
|
||||
#include "../interface/widgets/dialogs/dlg_load_deck_from_website.h"
|
||||
#include "../interface/widgets/dialogs/dlg_share_deck.h"
|
||||
#include "../utility/visibility_change_listener.h"
|
||||
#include "tab_supervisor.h"
|
||||
|
||||
|
|
@ -323,6 +325,7 @@ bool AbstractTabDeckEditor::actSaveDeck()
|
|||
Command_DeckUpload cmd;
|
||||
cmd.set_deck_id(static_cast<google::protobuf::uint32>(loadedDeck.lastLoadInfo.remoteDeckId));
|
||||
cmd.set_deck_list(deckString.toStdString());
|
||||
cmd.set_color_identity(getDeckColorIdentity(loadedDeck.deckList, CardDatabaseManager::query()).toStdString());
|
||||
|
||||
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished);
|
||||
|
|
@ -382,6 +385,27 @@ bool AbstractTabDeckEditor::actSaveDeckAs()
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Opens the deck share dialog with the current deck preselected.
|
||||
*/
|
||||
void AbstractTabDeckEditor::actShareDeck()
|
||||
{
|
||||
AbstractClient *client = tabSupervisor->getServerClient();
|
||||
if (client->getStatus() != StatusLoggedIn) {
|
||||
QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck."));
|
||||
return;
|
||||
}
|
||||
|
||||
const QSharedPointer<DeckList> deck = deckStateManager->getDeckListShared();
|
||||
if (deck->isBlankDeck()) {
|
||||
QMessageBox::information(this, tr("Share deck"), tr("The deck is empty. Add cards before sharing it."));
|
||||
return;
|
||||
}
|
||||
|
||||
DlgShareDeck shareDialog(client, deck, this);
|
||||
shareDialog.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Callback for remote deck save completion.
|
||||
* @param response Server response.
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ protected slots:
|
|||
/** @brief Saves the current deck under a new name. */
|
||||
virtual bool actSaveDeckAs();
|
||||
|
||||
/** @brief Opens the deck share dialog for the current deck. */
|
||||
void actShareDeck();
|
||||
|
||||
/** @brief Loads a deck from the clipboard. */
|
||||
virtual void actLoadDeckFromClipboard();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "archidekt_api_response_deck_display_widget.h"
|
||||
|
||||
#include "../../../../../../client/settings/cache_settings.h"
|
||||
#include "../../../../../deck_loader/card_node_function.h"
|
||||
#include "../../../../../deck_loader/deck_loader.h"
|
||||
#include "../../../../cards/card_size_widget.h"
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
|
||||
#include <QSortFilterProxyModel>
|
||||
#include <libcockatrice/card/import/card_name_normalizer.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent,
|
||||
ArchidektApiResponseDeck _response,
|
||||
|
|
@ -120,6 +122,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi
|
|||
}
|
||||
|
||||
model = new DeckListModel(this);
|
||||
model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang());
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model,
|
||||
[this](const QString &lang) { model->setDisplayLanguage(lang); });
|
||||
connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset);
|
||||
|
||||
auto decklist = QSharedPointer<DeckList>(new DeckList);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue